diff --git a/components/email/__tests__/download-all-attachments.test.tsx b/components/email/__tests__/download-all-attachments.test.tsx new file mode 100644 index 000000000..df88e19fc --- /dev/null +++ b/components/email/__tests__/download-all-attachments.test.tsx @@ -0,0 +1,313 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import JSZip from 'jszip'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { JMAPClient } from '@/lib/jmap/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import { ToastContainer } from '@/components/ui/toast'; +import en from '@/locales/en/common.json'; + +// Keep the actual viewer handler, translations, JMAP routing/auth/retries and +// JSZip. Stub unrelated viewer hosts, as in the other viewer component tests. +vi.unmock('next-intl'); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/components/email/external-sender-chip', () => ({ ExternalSenderChip: () => null })); + +const { EmailViewer } = await import('../email-viewer'); +const initialAuth = useAuthStore.getState(); +const initialEmail = useEmailStore.getState(); +const initialSettings = useSettingsStore.getState(); +const clients: JMAPClient[] = []; +const alpha = new TextEncoder().encode('synthetic alpha\nΚαλημέρα\n'); +const beta = new Uint8Array([0, 1, 127, 128, 255, 10]); +const attachments = [ + { partId: '2', blobId: 'blob-a', name: 'report.txt', type: 'application/octet-stream', size: alpha.length }, + { partId: '3', blobId: 'blob-b', name: 'data.bin', type: 'application/octet-stream', size: beta.length }, +]; +const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox; +const routes = ['primary', 'shared', 'secondary-stamped', 'secondary-folder'] as const; +type Route = typeof routes[number]; + +function DownloadToasts() { + const toasts = useToastStore(s => s.toasts); + return ; +} + +function bytesResponse(bytes: Uint8Array) { + const buffer = new Uint8Array(bytes).buffer; + const response = new Response(buffer, { headers: { 'Content-Type': 'application/octet-stream' } }); + // Node's Response returns a Node Blob. JSZip uses the DOM FileReader, so + // return the identical bytes as a jsdom Blob, as in the drag routing tests. + response.blob = async () => new Blob([buffer], { type: 'application/octet-stream' }); + return response; +} + +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +async function connect(secondary: boolean) { + const server = `https://${secondary ? 'secondary' : 'primary'}.example.test`; + const accountId = secondary ? 'other-primary' : 'postal12345'; + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { + [accountId]: { name: 'Own', isPersonal: true, accountCapabilities: {} }, + nrichmond: { name: 'Shared', isPersonal: false, accountCapabilities: {} }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': accountId }, + apiUrl: `${server}/api`, + downloadUrl: `${server}/${secondary ? 'objects' : 'download'}/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${server}/upload/{accountId}`, eventSourceUrl: '', + }), { status: 200 })); + const client = secondary + ? JMAPClient.withBearer(server, 'synthetic-secondary-token', 'secondary-login') + : new JMAPClient(server, 'primary-login', 'synthetic-password'); + clients.push(client); + await client.connect(); + return client; +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + URL.createObjectURL = vi.fn(() => 'blob:download-all'); + URL.revokeObjectURL = vi.fn(); + useToastStore.getState().clearToasts(); + useSettingsStore.setState({ markAsReadDelay: -1, attachmentImagePreviewsEnabled: false, mailAttachmentAction: 'download' }); +}); + +afterEach(() => { + cleanup(); + clients.splice(0).forEach(client => client.disconnect()); + useAuthStore.setState(initialAuth); + useEmailStore.setState(initialEmail); + useSettingsStore.setState(initialSettings); + useToastStore.getState().clearToasts(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function openViewer(route: Route = 'secondary-stamped', parts = attachments) { + const a = await connect(false); + const b = await connect(true); + useAuthStore.setState({ client: a, activeAccountId: 'login-primary', identities: [], + getClientForAccount: id => id === 'login-primary' ? a : id === 'login-other' ? b : undefined, + getAllConnectedClients: () => new Map([['login-primary', a], ['login-other', b]]), + }); + const email: Email = { + id: 'email', threadId: 'thread', blobId: 'raw-blob', subject: 'Download test', + mailboxIds: {}, keywords: { $seen: true }, size: 100, hasAttachment: true, + receivedAt: '2026-01-01T00:00:00Z', from: [{ email: 'sender@example.test' }], to: [], + preview: 'Body', textBody: [{ partId: '1', blobId: 'body-blob', type: 'text/plain', size: 4 }], htmlBody: [], + bodyValues: { '1': { value: 'Body', isEncodingProblem: false, isTruncated: false } }, + attachments: parts, + ...(route === 'secondary-stamped' ? { sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' } : {}), + }; + const selectedMailbox = route === 'primary' ? 'inbox' : sharedMailbox.id; + useEmailStore.setState({ selectedEmail: email, selectedMailbox, isUnifiedView: false, + viewingAccountId: route === 'secondary-folder' ? 'login-other' : null, + mailboxes: [primaryMailbox, sharedMailbox], accountMailboxes: { 'login-other': [primaryMailbox, sharedMailbox] }, + searchQuery: '', searchMailboxId: '', + }); + const fetchMock = vi.mocked(fetch); + fetchMock.mockReset(); + fetchMock.mockRejectedValue(new Error('Unexpected network request')); + const downloadA = vi.spyOn(a, 'downloadBlob'); + const downloadB = vi.spyOn(b, 'downloadBlob'); + const downloads: { href: string; name: string; connected: boolean; notifications: number }[] = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function(this: HTMLAnchorElement) { + downloads.push({ href: this.href, name: this.download, connected: document.body.contains(this), + notifications: useToastStore.getState().toasts.length }); + }); + render( + + + ); + const button = screen.getAllByTitle('Download all')[0]; + const secondary = route.startsWith('secondary'); + const owner = route === 'primary' ? 'postal12345' : 'nrichmond'; + function assertRequests(blobs: string[]) { + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual(blobs.map(id => { + const name = parts.find(part => part.blobId === id)!.name; + return `https://${secondary ? 'secondary' : 'primary'}.example.test/${secondary ? 'objects' : 'download'}/${owner}/${id}/${name}?accept=application%2Foctet-stream`; + })); + blobs.forEach((_id, i) => { + expect(fetchMock.mock.calls[i][1]?.headers).toEqual(expect.objectContaining({ + Authorization: secondary ? 'Bearer synthetic-secondary-token' : `Basic ${btoa('primary-login:synthetic-password')}`, + })); + }); + expect(downloadA).not.toHaveBeenCalled(); + expect(downloadB).not.toHaveBeenCalled(); + expect(useEmailStore.getState().selectedEmail).toBe(email); + } + function click() { + fireEvent.click(button); + expect(button).toBeDisabled(); + expect(button.querySelector('.animate-spin')).not.toBeNull(); + } + async function finish() { + await waitFor(() => expect(button).toBeEnabled(), { timeout: 5000 }); + for (const control of screen.getAllByTitle('Download all')) expect(control).toBeEnabled(); + expect(button.querySelector('.animate-spin')).toBeNull(); + } + async function assertZip(names: string[], contents: Uint8Array[]) { + // Browser-download initiation only; this does not verify native file creation. + expect(downloads).toEqual([{ href: 'blob:download-all', name: 'attachments_Download test.zip', connected: true, notifications: 0 }]); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob; + expect(blob.type).toBe('application/zip'); + const zip = await JSZip.loadAsync(await readBlob(blob), { checkCRC32: true }); + expect(Object.keys(zip.files)).toEqual(names); + for (let i = 0; i < names.length; i++) { + expect(Array.from(await zip.file(names[i])!.async('uint8array'))).toEqual(Array.from(contents[i])); + } + } + function success() { + fetchMock.mockImplementation(async url => bytesResponse(String(url).includes('/blob-a/') ? alpha : beta)); + } + function noDownload() { + expect(downloads).toEqual([]); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + } + async function feedback(type: 'warning' | 'error', title: string) { + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([expect.objectContaining({ type, title })])); + expect(screen.getByText(title)).toBeVisible(); + } + async function recover(previousRequests: string[]) { + act(() => useToastStore.getState().clearToasts()); + success(); click(); + await waitFor(() => expect(downloads).toHaveLength(1)); + await finish(); + assertRequests([...previousRequests, 'blob-a', 'blob-b']); + expect(useToastStore.getState().toasts).toEqual([]); + await assertZip(['report.txt', 'data.bin'], [alpha, beta]); + } + return { fetchMock, downloads, button, click, finish, assertRequests, assertZip, success, noDownload, feedback, recover }; +} + +describe('Download all attachments', () => { + it.each(routes)('downloads exact filenames and bytes through %s routing without feedback', async route => { + const p = await openViewer(route); + p.success(); p.click(); + await waitFor(() => expect(p.downloads).toHaveLength(1)); + await p.finish(); + p.assertRequests(['blob-a', 'blob-b']); + expect(useToastStore.getState().toasts).toEqual([]); + await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); + }); + + it('preserves distinct bytes when attachment names collide', async () => { + const p = await openViewer('shared', attachments.map(part => ({ ...part, name: 'report.txt' }))); + p.success(); p.click(); + await waitFor(() => expect(p.downloads).toHaveLength(1)); + await p.finish(); + p.assertRequests(['blob-a', 'blob-b']); + expect(useToastStore.getState().toasts).toEqual([]); + await p.assertZip(['report.txt', 'report (1).txt'], [alpha, beta]); + }); + + it.each([1, 2])('discloses %i omitted attachments once, after initiating the partial ZIP download', async omitted => { + const parts = omitted === 1 ? attachments : [...attachments, { ...attachments[1], partId: '4', blobId: 'blob-c', name: 'notes.bin' }]; + const p = await openViewer('secondary-stamped', parts); + p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(new Response('refused', { status: 403 })); + if (omitted === 2) p.fetchMock.mockResolvedValueOnce(new Response('unavailable', { status: 503 })); + p.click(); + await waitFor(() => expect(p.downloads).toHaveLength(1)); + await p.finish(); + p.assertRequests(parts.map(part => part.blobId)); + await p.assertZip(['report.txt'], [alpha]); + await p.feedback('warning', omitted === 1 ? '1 attachment was omitted from the ZIP.' : '2 attachments were omitted from the ZIP.'); + }); + + it('reports total member failure once without an empty ZIP and retries through the same control', async () => { + const p = await openViewer(); + p.fetchMock.mockResolvedValueOnce(new Response('refused', { status: 403 })).mockResolvedValueOnce(new Response('unavailable', { status: 503 })); + p.click(); await p.finish(); + p.assertRequests(['blob-a', 'blob-b']); + p.noDownload(); + await p.feedback('error', "Couldn't download"); + await p.recover(['blob-a', 'blob-b']); + }); + + it('counts only an exhausted network retry as an omission and makes fresh requests on another attempt', async () => { + const p = await openViewer(); + p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha)) + .mockRejectedValueOnce(new TypeError('synthetic offline')).mockRejectedValueOnce(new TypeError('synthetic offline')); + p.click(); + await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 }); + await p.finish(); + p.assertRequests(['blob-a', 'blob-b', 'blob-b']); + await p.assertZip(['report.txt'], [alpha]); + await p.feedback('warning', '1 attachment was omitted from the ZIP.'); + // Keep the UI/client/message, dismiss the old notification and recover. + p.downloads.length = 0; + vi.mocked(URL.createObjectURL).mockClear(); + await p.recover(['blob-a', 'blob-b', 'blob-b']); + }); + + it.each([false, true])('reports ZIP-generation rejection once, including after member failure=%s, and can retry', async partial => { + const p = await openViewer(); + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(JSZip.prototype, 'generateAsync').mockRejectedValueOnce(new Error('synthetic ZIP failure')); + p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(partial + ? new Response('refused', { status: 403 }) : bytesResponse(beta)); + p.click(); await p.finish(); + p.assertRequests(['blob-a', 'blob-b']); + p.noDownload(); + await p.feedback('error', "Couldn't download"); + expect(diagnostic).toHaveBeenCalledWith('Failed to download attachments:', expect.any(Error)); + // No unhandled-rejection suppression: Vitest must exit successfully. + await p.recover(['blob-a', 'blob-b']); + }); + + it('does not report a terminal failure when the existing automatic retry recovers', async () => { + const p = await openViewer(); + p.fetchMock.mockRejectedValueOnce(new TypeError('synthetic transient')) + .mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(bytesResponse(beta)); + p.click(); + await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 }); + await p.finish(); + p.assertRequests(['blob-a', 'blob-a', 'blob-b']); + expect(useToastStore.getState().toasts).toEqual([]); + await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); + }); + + it('keeps the control disabled while fetching and ignores another click', async () => { + const p = await openViewer(); + let release!: (response: Response) => void; + p.fetchMock.mockImplementationOnce(() => new Promise(resolve => { release = resolve; })).mockResolvedValueOnce(bytesResponse(beta)); + p.click(); + await waitFor(() => expect(p.fetchMock).toHaveBeenCalledTimes(1)); + fireEvent.click(p.button); + expect(p.fetchMock).toHaveBeenCalledTimes(1); + expect(useToastStore.getState().toasts).toEqual([]); + await act(async () => release(bytesResponse(alpha))); + await waitFor(() => expect(p.downloads).toHaveLength(1)); + await p.finish(); + p.assertRequests(['blob-a', 'blob-b']); + await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index c38ab278e..ad0d816dc 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3028,7 +3028,10 @@ export function EmailViewer({ } } - if (added === 0) return; + if (added === 0) { + toast.error(tFiles('download_error')); + return; + } const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' }); const objectUrl = URL.createObjectURL(zipBlob); @@ -3039,10 +3042,15 @@ export function EmailViewer({ anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + const omitted = effectiveAttachments.length - added; + if (omitted > 0) toast.warning(t('download_all_partial', { count: omitted })); + } catch (error) { + console.error('Failed to download attachments:', error); + toast.error(tFiles('download_error')); } finally { setIsDownloadingAll(false); } - }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]); + }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]); // Shared "Download all" chip, shown only when bundling is worthwhile (2+). const downloadAllButton = effectiveAttachments.length > 1 ? ( diff --git a/locales/ar/common.json b/locales/ar/common.json index 53d1387de..3cbcd5ca8 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -450,6 +450,7 @@ "important": "مهم", "download": "تنزيل", "download_all": "تنزيل الكل", + "download_all_partial": "{count, plural, zero {لم تُحذف أي مرفقات من ملف ZIP.} one {حُذف مرفق واحد من ملف ZIP.} two {حُذف مرفقان من ملف ZIP.} few {حُذفت # مرفقات من ملف ZIP.} many {حُذف # مرفقًا من ملف ZIP.} other {حُذف # مرفق من ملف ZIP.}}", "from": "من", "to": "إلى", "cc": "نسخة", diff --git a/locales/ca/common.json b/locales/ca/common.json index f6518c120..62e9d8c91 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -450,6 +450,7 @@ "important": "Important", "download": "Baixa", "download_all": "Baixa-ho tot", + "download_all_partial": "{count, plural, one {S’ha omès # fitxer adjunt del ZIP.} other {S’han omès # fitxers adjunts del ZIP.}}", "from": "De", "to": "Per a", "cc": "CC", diff --git a/locales/cs/common.json b/locales/cs/common.json index a52d94e20..dbb1c0e31 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -450,6 +450,7 @@ "important": "Důležité", "download": "Stáhnout", "download_all": "Stáhnout vše", + "download_all_partial": "{count, plural, one {Ze ZIPu byla vynechána # příloha.} few {Ze ZIPu byly vynechány # přílohy.} many {Ze ZIPu bylo vynecháno # přílohy.} other {Ze ZIPu bylo vynecháno # příloh.}}", "from": "Od", "to": "Komu", "cc": "Kopie", diff --git a/locales/da/common.json b/locales/da/common.json index 3e04e5ef2..0e8eab4d8 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -450,6 +450,7 @@ "important": "Vigtig", "download": "Download", "download_all": "Download alle", + "download_all_partial": "{count, plural, one {# vedhæftet fil blev udeladt fra ZIP-filen.} other {# vedhæftede filer blev udeladt fra ZIP-filen.}}", "from": "Fra", "to": "Til", "cc": "CC", diff --git a/locales/de/common.json b/locales/de/common.json index 87f90a0e2..b9133482b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -450,6 +450,7 @@ "important": "Wichtig", "download": "Herunterladen", "download_all": "Alle herunterladen", + "download_all_partial": "{count, plural, one {# Anhang wurde im ZIP-Archiv ausgelassen.} other {# Anhänge wurden im ZIP-Archiv ausgelassen.}}", "from": "Von", "to": "An", "cc": "CC", diff --git a/locales/en/common.json b/locales/en/common.json index e370559a0..48e0a43b3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -450,6 +450,7 @@ "important": "Important", "download": "Download", "download_all": "Download all", + "download_all_partial": "{count, plural, one {# attachment was omitted from the ZIP.} other {# attachments were omitted from the ZIP.}}", "from": "From", "to": "To", "cc": "CC", diff --git a/locales/es/common.json b/locales/es/common.json index 45d4255d7..9f5d6ac1d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -450,6 +450,7 @@ "important": "Importante", "download": "Descargar", "download_all": "Descargar todo", + "download_all_partial": "{count, plural, one {Se ha omitido # archivo adjunto del ZIP.} other {Se han omitido # archivos adjuntos del ZIP.}}", "from": "De", "to": "Para", "cc": "CC", diff --git a/locales/et/common.json b/locales/et/common.json index 409dc7914..9d687114a 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -450,6 +450,7 @@ "important": "Oluline", "download": "Laadi alla", "download_all": "Laadi kõik alla", + "download_all_partial": "{count, plural, one {ZIP-failist jäeti välja # manus.} other {ZIP-failist jäeti välja # manust.}}", "from": "Saatja", "to": "Saaja", "cc": "Koopia", diff --git a/locales/fa/common.json b/locales/fa/common.json index c67b0be54..df8107ed2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -450,6 +450,7 @@ "important": "مهم", "download": "دانلود", "download_all": "دانلود همه", + "download_all_partial": "{count, plural, one {# پیوست از فایل ZIP حذف شد.} other {# پیوست از فایل ZIP حذف شد.}}", "from": "از", "to": "به", "cc": "رونوشت", diff --git a/locales/fr/common.json b/locales/fr/common.json index 05d4a448c..284e0ddc8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -450,6 +450,7 @@ "important": "Important", "download": "Télécharger", "download_all": "Tout télécharger", + "download_all_partial": "{count, plural, one {# pièce jointe a été omise du ZIP.} other {# pièces jointes ont été omises du ZIP.}}", "from": "De", "to": "À", "cc": "CC", diff --git a/locales/he/common.json b/locales/he/common.json index 639fb6c7a..0ac358cb1 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -399,6 +399,7 @@ "important": "חָשׁוּב", "download": "הורד", "download_all": "הורד הכל", + "download_all_partial": "{count, plural, one {# קובץ מצורף הושמט מקובץ ה-ZIP.} two {# קבצים מצורפים הושמטו מקובץ ה-ZIP.} other {# קבצים מצורפים הושמטו מקובץ ה-ZIP.}}", "from": "מאת", "to": "אל", "cc": "עותק", diff --git a/locales/hu/common.json b/locales/hu/common.json index d73d1024c..f1cf3d685 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -450,6 +450,7 @@ "important": "Fontos", "download": "Letöltés", "download_all": "Összes letöltése", + "download_all_partial": "{count, plural, one {# melléklet kimaradt a ZIP-fájlból.} other {# melléklet kimaradt a ZIP-fájlból.}}", "from": "Feladó", "to": "Címzett", "cc": "Másolat", diff --git a/locales/it/common.json b/locales/it/common.json index d233c37f2..7b85ee009 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -450,6 +450,7 @@ "important": "Importante", "download": "Scarica", "download_all": "Scarica tutto", + "download_all_partial": "{count, plural, one {# allegato è stato omesso dallo ZIP.} other {# allegati sono stati omessi dallo ZIP.}}", "from": "Da", "to": "A", "cc": "CC", diff --git a/locales/ja/common.json b/locales/ja/common.json index 14443e59d..6f76e7751 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -450,6 +450,7 @@ "important": "重要", "download": "ダウンロード", "download_all": "すべてダウンロード", + "download_all_partial": "ZIPに含まれなかった添付ファイルは{count}件です。", "from": "送信者", "to": "宛先", "cc": "CC", diff --git a/locales/ko/common.json b/locales/ko/common.json index 96e51cd9f..1d7b11282 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -450,6 +450,7 @@ "important": "중요", "download": "다운로드", "download_all": "모두 다운로드", + "download_all_partial": "ZIP에 첨부파일 {count}개가 포함되지 않았습니다.", "from": "보낸 사람", "to": "받는 사람", "cc": "참조", diff --git a/locales/lv/common.json b/locales/lv/common.json index ea3047e14..e85ee3bfc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -450,6 +450,7 @@ "important": "Svarīgi", "download": "Lejupielādēt", "download_all": "Lejupielādēt visu", + "download_all_partial": "{count, plural, zero {# pielikumi netika iekļauti ZIP failā.} one {# pielikums netika iekļauts ZIP failā.} other {# pielikumi netika iekļauti ZIP failā.}}", "from": "No", "to": "Kam", "cc": "Kopija", diff --git a/locales/mn/common.json b/locales/mn/common.json index e6ea18a15..1736cd115 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -450,6 +450,7 @@ "important": "Чухал", "download": "Татаж авах", "download_all": "Бүгдийг нь татаж авах", + "download_all_partial": "ZIP файлд {count} хавсралт ороогүй.", "from": "", "to": "Хэнд", "cc": "Хуулбар", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6794e637c..a66624e80 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -450,6 +450,7 @@ "important": "Viktig", "download": "Last ned", "download_all": "Last ned alle", + "download_all_partial": "{count, plural, one {# vedlegg ble utelatt fra ZIP-filen.} other {# vedlegg ble utelatt fra ZIP-filen.}}", "from": "Fra", "to": "Til", "cc": "CC", diff --git a/locales/nl/common.json b/locales/nl/common.json index 21f2232f5..a416a046c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -450,6 +450,7 @@ "important": "Belangrijk", "download": "Downloaden", "download_all": "Alles downloaden", + "download_all_partial": "{count, plural, one {# bijlage is weggelaten uit het ZIP-bestand.} other {# bijlagen zijn weggelaten uit het ZIP-bestand.}}", "from": "Van", "to": "Aan", "cc": "CC", diff --git a/locales/pl/common.json b/locales/pl/common.json index b0aae170a..6e68a40dd 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -450,6 +450,7 @@ "important": "Ważne", "download": "Pobierz", "download_all": "Pobierz wszystkie", + "download_all_partial": "{count, plural, one {Pominięto # załącznik w archiwum ZIP.} few {Pominięto # załączniki w archiwum ZIP.} many {Pominięto # załączników w archiwum ZIP.} other {Pominięto # załącznika w archiwum ZIP.}}", "from": "Od", "to": "Do", "cc": "DW", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3603dd30d..6b3a3d5a0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -450,6 +450,7 @@ "important": "Importante", "download": "Baixar", "download_all": "Baixar tudo", + "download_all_partial": "{count, plural, one {# anexo foi omitido do ZIP.} other {# anexos foram omitidos do ZIP.}}", "from": "De", "to": "Para", "cc": "CC", diff --git a/locales/ro/common.json b/locales/ro/common.json index 6f8502c27..7acb060ea 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -450,6 +450,7 @@ "important": "Important", "download": "Descărcați", "download_all": "Descărcați tot", + "download_all_partial": "{count, plural, one {# atașament a fost omis din ZIP.} few {# atașamente au fost omise din ZIP.} other {# de atașamente au fost omise din ZIP.}}", "from": "De la", "to": "Către", "cc": "CC", diff --git a/locales/ru/common.json b/locales/ru/common.json index b88e0e8f0..a7398807b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -450,6 +450,7 @@ "important": "Важное", "download": "Скачать", "download_all": "Скачать все", + "download_all_partial": "{count, plural, one {# вложение не включено в ZIP-архив.} few {# вложения не включены в ZIP-архив.} many {# вложений не включены в ZIP-архив.} other {# вложения не включены в ZIP-архив.}}", "from": "От", "to": "Кому", "cc": "Копия", diff --git a/locales/sk/common.json b/locales/sk/common.json index 7c2579d36..ce37f6b31 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -450,6 +450,7 @@ "important": "Dôležité", "download": "Stiahnuť", "download_all": "Stiahnuť všetko", + "download_all_partial": "{count, plural, one {Zo ZIPu bola vynechaná # príloha.} few {Zo ZIPu boli vynechané # prílohy.} many {Zo ZIPu bolo vynechaných # prílohy.} other {Zo ZIPu bolo vynechaných # príloh.}}", "from": "Od", "to": "Komu", "cc": "Kópia", diff --git a/locales/th/common.json b/locales/th/common.json index e8ce9fe3b..cef4c4299 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1293,6 +1293,7 @@ "important": "สำคัญ", "download": "ดาวน์โหลด", "download_all": "ดาวน์โหลดทั้งหมด", + "download_all_partial": "ไฟล์แนบ {count} รายการไม่ถูกรวมในไฟล์ ZIP", "from": "จาก", "to": "ถึง", "cc": "สำเนา (CC)", diff --git a/locales/tr/common.json b/locales/tr/common.json index 848d215f2..7cfa61ffb 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -450,6 +450,7 @@ "important": "Önemli", "download": "İndir", "download_all": "Tümünü indir", + "download_all_partial": "ZIP dosyasına {count} ek dahil edilemedi.", "from": "Kimden", "to": "Kime", "cc": "CC", diff --git a/locales/uk/common.json b/locales/uk/common.json index f6acc947b..c99127ea0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -450,6 +450,7 @@ "important": "важливо", "download": "Завантажити", "download_all": "Завантажити все", + "download_all_partial": "{count, plural, one {# вкладення не включено до ZIP-архіву.} few {# вкладення не включено до ZIP-архіву.} many {# вкладень не включено до ZIP-архіву.} other {# вкладення не включено до ZIP-архіву.}}", "from": "Від", "to": "до", "cc": "CC", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f3aa1b8b8..e72f394d6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -450,6 +450,7 @@ "important": "重要", "download": "下載", "download_all": "全部下載", + "download_all_partial": "有 {count} 個附件未包含在 ZIP 中。", "from": "寄件者", "to": "收件者", "cc": "副本", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3e0ae974b..e01dbb08c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -450,6 +450,7 @@ "important": "重要", "download": "下载", "download_all": "全部下载", + "download_all_partial": "有 {count} 个附件未包含在 ZIP 中。", "from": "发件人", "to": "收件人", "cc": "抄送",