mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: open the files inside ZIP archives and attached messages
This commit is contained in:
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
|
||||
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا."
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.",
|
||||
"archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
|
||||
"archive_first_files": "أول {count} ملف",
|
||||
"archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
|
||||
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí."
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# fitxer} other {# fitxers}}",
|
||||
"archive_first_files": "Els primers {count} fitxers",
|
||||
"archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"archive_unreadable": "Aquest arxiu no es pot obrir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát.",
|
||||
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit."
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit.",
|
||||
"archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
|
||||
"archive_first_files": "Prvních {count} souborů",
|
||||
"archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"archive_unreadable": "Tento archiv zde nelze otevřít."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her.",
|
||||
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her."
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filer",
|
||||
"archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.",
|
||||
"archive_unreadable": "Dette arkiv kan ikke åbnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
|
||||
+5
-1
@@ -416,7 +416,11 @@
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
|
||||
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden."
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.",
|
||||
"archive_files": "{count, plural, one {# Datei} other {# Dateien}}",
|
||||
"archive_first_files": "Die ersten {count} Dateien",
|
||||
"archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.",
|
||||
"archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
|
||||
+5
-1
@@ -427,7 +427,11 @@
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here.",
|
||||
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
|
||||
"pdf_unreadable": "This PDF can't be shown here."
|
||||
"pdf_unreadable": "This PDF can't be shown here.",
|
||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||
"archive_first_files": "The first {count} files",
|
||||
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
|
||||
"archive_unreadable": "This archive can't be opened here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí.",
|
||||
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí."
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# archivo} other {# archivos}}",
|
||||
"archive_first_files": "Los primeros {count} archivos",
|
||||
"archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"archive_unreadable": "Este archivo comprimido no se puede abrir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود.",
|
||||
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود."
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود.",
|
||||
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
|
||||
"archive_first_files": "{count} فایل نخست",
|
||||
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"archive_unreadable": "این بایگانی اینجا باز نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
|
||||
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici."
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici.",
|
||||
"archive_files": "{count, plural, one {# fichier} other {# fichiers}}",
|
||||
"archive_first_files": "Les {count} premiers fichiers",
|
||||
"archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.",
|
||||
"archive_unreadable": "Cette archive ne peut pas être ouverte ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
|
||||
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן."
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
|
||||
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
|
||||
"archive_first_files": "{count} הקבצים הראשונים",
|
||||
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le.",
|
||||
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg."
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.",
|
||||
"archive_files": "{count, plural, one {# fájl} other {# fájl}}",
|
||||
"archive_first_files": "Az első {count} fájl",
|
||||
"archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"archive_unreadable": "Ez az archívum itt nem nyitható meg."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui.",
|
||||
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui."
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui.",
|
||||
"archive_files": "{count, plural, one {# file} other {# file}}",
|
||||
"archive_first_files": "I primi {count} file",
|
||||
"archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"archive_unreadable": "Questo archivio non può essere aperto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。",
|
||||
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。"
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。",
|
||||
"archive_files": "{count, plural, other {# 個のファイル}}",
|
||||
"archive_first_files": "最初の {count} 個のファイル",
|
||||
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"archive_unreadable": "このアーカイブはここでは開けません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
|
||||
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다."
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
|
||||
"archive_files": "{count, plural, other {파일 #개}}",
|
||||
"archive_first_files": "처음 {count}개 파일",
|
||||
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot.",
|
||||
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt."
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt.",
|
||||
"archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
|
||||
"archive_first_files": "Pirmie {count} faili",
|
||||
"archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"archive_unreadable": "Šo arhīvu šeit nevar atvērt."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
|
||||
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй."
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
|
||||
"archive_files": "{count, plural, one {# файл} other {# файл}}",
|
||||
"archive_first_files": "Эхний {count} файл",
|
||||
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her.",
|
||||
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her."
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filene",
|
||||
"archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.",
|
||||
"archive_unreadable": "Dette arkivet kan ikke åpnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
|
||||
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven."
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.",
|
||||
"archive_files": "{count, plural, one {# bestand} other {# bestanden}}",
|
||||
"archive_first_files": "De eerste {count} bestanden",
|
||||
"archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.",
|
||||
"archive_unreadable": "Dit archief kan hier niet worden geopend."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
|
||||
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić."
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.",
|
||||
"archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
|
||||
"archive_first_files": "Pierwsze {count} plików",
|
||||
"archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.",
|
||||
"archive_unreadable": "Tego archiwum nie można tutaj otworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
|
||||
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui."
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui.",
|
||||
"archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}",
|
||||
"archive_first_files": "Os primeiros {count} ficheiros",
|
||||
"archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"archive_unreadable": "Este arquivo não pode ser aberto aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici.",
|
||||
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici."
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici.",
|
||||
"archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
|
||||
"archive_first_files": "Primele {count} fișiere",
|
||||
"archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.",
|
||||
"archive_unreadable": "Această arhivă nu poate fi deschisă aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
|
||||
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь."
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
|
||||
"archive_first_files": "Первые {count} файлов",
|
||||
"archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"archive_unreadable": "Этот архив нельзя открыть здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
|
||||
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť."
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť.",
|
||||
"archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
|
||||
"archive_first_files": "Prvých {count} súborov",
|
||||
"archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"archive_unreadable": "Tento archív tu nie je možné otvoriť."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor.",
|
||||
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor."
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor.",
|
||||
"archive_files": "{count, plural, one {# dosya} other {# dosya}}",
|
||||
"archive_first_files": "İlk {count} dosya",
|
||||
"archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"archive_unreadable": "Bu arşiv burada açılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут.",
|
||||
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут."
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
|
||||
"archive_first_files": "Перші {count} файлів",
|
||||
"archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"archive_unreadable": "Цей архів не можна відкрити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
|
||||
@@ -410,7 +410,11 @@
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。",
|
||||
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。"
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。",
|
||||
"archive_files": "{count, plural, other {# 個檔案}}",
|
||||
"archive_first_files": "前 {count} 個檔案",
|
||||
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"archive_unreadable": "無法在此開啟這個壓縮檔。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
|
||||
+5
-1
@@ -410,7 +410,11 @@
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。",
|
||||
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
|
||||
"pdf_unreadable": "无法在此显示该 PDF。"
|
||||
"pdf_unreadable": "无法在此显示该 PDF。",
|
||||
"archive_files": "{count, plural, other {# 个文件}}",
|
||||
"archive_first_files": "前 {count} 个文件",
|
||||
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
|
||||
"archive_unreadable": "无法在此打开该压缩包。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio,
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -10,10 +10,17 @@ import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
|
||||
import {
|
||||
handoffMimeType, needsNativeImageDecoder, needsWebImageViewer, type PreviewKind,
|
||||
} from '../../lib/file-preview';
|
||||
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
|
||||
import type { ZipArchive } from '../../lib/zip-preview';
|
||||
import { formatFullDateTime } from '../../lib/email-date';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
export interface PreviewItem {
|
||||
@@ -30,6 +37,8 @@ export interface PreviewItem {
|
||||
html?: string;
|
||||
/** Parsed embedded message for .eml previews. */
|
||||
eml?: EmlPreview;
|
||||
/** A ZIP's directory, for archive previews. */
|
||||
archive?: ZipArchive;
|
||||
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
|
||||
notice?: string;
|
||||
}
|
||||
@@ -131,9 +140,11 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video
|
||||
* and audio (the WebView's player), text, Markdown and HTML documents and
|
||||
* embedded messages. Files that don't decode, and types without a preview,
|
||||
* offer another app instead. Used by every place that opens a file.
|
||||
* and audio (the WebView's player), text, Markdown and HTML documents,
|
||||
* embedded messages and ZIP archives. The files inside an archive or a
|
||||
* message open in a preview of their own on top. Files that don't decode,
|
||||
* and types without a preview, offer another app instead. Used by every
|
||||
* place that opens a file.
|
||||
*/
|
||||
export function AttachmentPreviewModal({
|
||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
|
||||
@@ -141,7 +152,45 @@ export function AttachmentPreviewModal({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { width } = useWindowDimensions();
|
||||
// Files inside this one (archive entries, an .eml's attachments).
|
||||
const nested = useFilePreview();
|
||||
const { open: openNested } = nested;
|
||||
const openPart = React.useCallback(async (part: PreviewPart) => {
|
||||
try {
|
||||
const bytes = await part.read();
|
||||
await openNested({
|
||||
from: 'bytes',
|
||||
bytes,
|
||||
name: part.name,
|
||||
type: part.type || handoffMimeType(part.name, ''),
|
||||
size: bytes.byteLength,
|
||||
});
|
||||
} catch (e) {
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [openNested, t]);
|
||||
const archive = item?.kind === 'archive' ? item.archive : undefined;
|
||||
const archiveParts = React.useMemo<PreviewPart[]>(() => {
|
||||
if (!archive) return [];
|
||||
return archive.entries.map((entry) => ({
|
||||
key: entry.path,
|
||||
name: entry.name,
|
||||
folder: entry.folder,
|
||||
size: entry.size,
|
||||
read: () => archive.read(entry.path),
|
||||
}));
|
||||
}, [archive]);
|
||||
const emlAttachments = item?.kind === 'eml' ? item.eml?.attachments : undefined;
|
||||
const emlParts = React.useMemo<PreviewPart[]>(() => (emlAttachments ?? []).map((att, i) => ({
|
||||
key: `${i}-${att.name}`,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
read: async () => att.bytes,
|
||||
})), [emlAttachments]);
|
||||
// Which viewers failed for the open file, keyed by its URI so the next one
|
||||
// starts clean: React Native's Image falls back to the WebView viewer, and
|
||||
// a WebView that can't decode the file falls back to another app.
|
||||
@@ -196,10 +245,11 @@ export function AttachmentPreviewModal({
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDocument = (header?: React.ReactNode) => (
|
||||
const renderDocument = (header?: React.ReactNode, footer?: React.ReactNode) => (
|
||||
<ScrollView style={styles.flex}>
|
||||
{header}
|
||||
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
|
||||
{footer}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -303,12 +353,30 @@ export function AttachmentPreviewModal({
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{formatFullDateTime(item.eml.date, timeFormat, locale) || item.eml.date}</Text> : null}
|
||||
</View>,
|
||||
emlParts.length > 0 ? (
|
||||
<PreviewPartList
|
||||
inline
|
||||
parts={emlParts}
|
||||
onOpen={openPart}
|
||||
header={<ArchiveSummary count={emlParts.length} truncated={false} />}
|
||||
/>
|
||||
) : null,
|
||||
);
|
||||
case 'archive':
|
||||
if (!item.archive) break;
|
||||
return (
|
||||
<PreviewPartList
|
||||
parts={archiveParts}
|
||||
onOpen={openPart}
|
||||
header={<ArchiveSummary count={archiveParts.length} truncated={item.archive.truncated} />}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
break;
|
||||
}
|
||||
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
};
|
||||
|
||||
const content = (
|
||||
@@ -359,6 +427,7 @@ export function AttachmentPreviewModal({
|
||||
</Pressable>
|
||||
</View>
|
||||
{renderBody()}
|
||||
{nested.modalProps.item || nested.modalProps.loading ? <AttachmentPreviewModal {...nested.modalProps} /> : null}
|
||||
</SafeAreaView>
|
||||
);
|
||||
if (docked) return item || loading ? content : null;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo,
|
||||
} from '../icons';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
import { filePreviewKind, handoffMimeType } from '../../lib/file-preview';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/** A file inside a file: an archive entry or an attachment of an .eml. */
|
||||
export interface PreviewPart {
|
||||
key: string;
|
||||
name: string;
|
||||
/** Folder inside an archive, '' at the top. */
|
||||
folder?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
read: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
function iconFor(part: PreviewPart) {
|
||||
const type = part.type || handoffMimeType(part.name, '');
|
||||
if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet;
|
||||
if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive;
|
||||
// Platform-independent: the icon names the format, not what plays here.
|
||||
switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) {
|
||||
case 'image': return FileImage;
|
||||
case 'video': return FileVideo;
|
||||
case 'audio': return FileAudio;
|
||||
case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon;
|
||||
default: return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
parts: PreviewPart[];
|
||||
/** Opens a part; the row shows a spinner until the promise settles. */
|
||||
onOpen: (part: PreviewPart) => Promise<void>;
|
||||
/** Above the rows (an .eml's body, an archive's summary). */
|
||||
header?: React.ReactElement;
|
||||
/** Renders as a plain column inside a parent ScrollView instead of a list of its own. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files inside an archive or an embedded message, each opening in the
|
||||
* file preview like any attachment.
|
||||
*/
|
||||
export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
|
||||
const open = (part: PreviewPart) => {
|
||||
if (busy) return;
|
||||
setBusy(part.key);
|
||||
onOpen(part).finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const renderRow = (part: PreviewPart) => {
|
||||
const Icon = iconFor(part);
|
||||
return (
|
||||
<Pressable
|
||||
key={part.key}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => open(part)}
|
||||
disabled={!!busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={part.name}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
{busy === part.key ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={20} color={c.textMuted} />}
|
||||
</View>
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.name} numberOfLines={1}>{part.name}</Text>
|
||||
{part.folder ? <Text style={styles.folder} numberOfLines={1}>{part.folder}</Text> : null}
|
||||
</View>
|
||||
{part.size != null ? <Text style={styles.size}>{formatSize(part.size) || '0 B'}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<View style={styles.inline}>
|
||||
{header}
|
||||
{parts.map(renderRow)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={parts}
|
||||
keyExtractor={(p) => p.key}
|
||||
renderItem={({ item }) => renderRow(item)}
|
||||
ListHeaderComponent={header}
|
||||
initialNumToRender={30}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The summary line above an archive's entries. */
|
||||
export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<Text style={styles.summary}>
|
||||
{truncated
|
||||
? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count })
|
||||
: t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
list: { flex: 1 },
|
||||
inline: { paddingBottom: spacing.lg },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 52,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
icon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.body, color: c.text },
|
||||
folder: { ...typography.caption, color: c.textMuted },
|
||||
size: { ...typography.caption, color: c.textMuted },
|
||||
summary: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,16 +60,18 @@ export interface EmlPreview {
|
||||
date?: string;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
/** The message's own attachments (not the images its body embeds). */
|
||||
attachments: ExtractedAttachment[];
|
||||
}
|
||||
|
||||
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
|
||||
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
|
||||
const parsed = await parseMessage(bytes);
|
||||
const { html, text } = unwrapParsed(parsed);
|
||||
const { html, text, attachments } = unwrapParsed(parsed);
|
||||
const from = parsed.from && 'address' in parsed.from
|
||||
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
|
||||
: parsed.from?.name;
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text };
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text, attachments };
|
||||
}
|
||||
|
||||
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PreviewKind } from '../../lib/file-preview';
|
||||
import { markdownToHtml } from '../../lib/markdown-html';
|
||||
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
|
||||
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
|
||||
import { openZip, ZipEncryptedError } from '../../lib/zip-preview';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { PreviewItem } from './AttachmentPreviewModal';
|
||||
@@ -79,6 +80,8 @@ interface Notices {
|
||||
tooLarge: string;
|
||||
pdfProtected: string;
|
||||
pdfUnreadable: string;
|
||||
zipProtected: string;
|
||||
zipUnreadable: string;
|
||||
}
|
||||
|
||||
async function buildItem(
|
||||
@@ -100,6 +103,12 @@ async function buildItem(
|
||||
return { ...base, kind, html: await local.text() };
|
||||
case 'eml':
|
||||
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
case 'archive':
|
||||
try {
|
||||
return { ...base, kind, archive: await openZip(await local.bytes()) };
|
||||
} catch (e) {
|
||||
return { ...base, kind: 'none', notice: e instanceof ZipEncryptedError ? notices.zipProtected : notices.zipUnreadable };
|
||||
}
|
||||
case 'image':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
|
||||
case 'video':
|
||||
@@ -208,6 +217,11 @@ export function useFilePreview(opts: {
|
||||
'This PDF is password-protected. Open it in another app to enter the password.',
|
||||
),
|
||||
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
|
||||
zipProtected: t(
|
||||
'email_viewer.preview.archive_protected',
|
||||
'This archive is password-protected. Open it in another app to enter the password.',
|
||||
),
|
||||
zipUnreadable: t('email_viewer.preview.archive_unreadable', "This archive can't be opened here."),
|
||||
}, colors.current);
|
||||
if (current()) setItem(next);
|
||||
} catch (e) {
|
||||
|
||||
@@ -93,8 +93,16 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('leaves archives, office documents and binaries to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
|
||||
it('lists the files in a ZIP, but not in the formats built on ZIP', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'photos', type: 'application/x-zip-compressed' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'Archive.ZIP', type: 'application/octet-stream' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'app.apk', type: 'application/vnd.android.package-archive' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves other archives, office documents and binaries to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'backup.7z', type: 'application/x-7z-compressed' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'logs.tar.gz', type: 'application/gzip' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview';
|
||||
|
||||
async function makeZip(files: Record<string, string>): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
for (const [path, text] of Object.entries(files)) zip.file(path, text);
|
||||
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
}
|
||||
|
||||
// Set the "encrypted" bit in every local and central header, as a
|
||||
// password-protected archive has it.
|
||||
function markEncrypted(bytes: Uint8Array): Uint8Array {
|
||||
const out = bytes.slice();
|
||||
const view = new DataView(out.buffer);
|
||||
for (let i = 0; i + 4 <= out.length; i++) {
|
||||
const sig = view.getUint32(i, true);
|
||||
if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true);
|
||||
if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('openZip', () => {
|
||||
it('lists the files by folder and name, without folders or OS metadata', async () => {
|
||||
const archive = await openZip(await makeZip({
|
||||
'b.txt': 'b',
|
||||
'docs/z.md': '# z',
|
||||
'a.txt': 'a',
|
||||
'docs/inner/c.csv': '1,2',
|
||||
'__MACOSX/docs/._z.md': 'junk',
|
||||
'.DS_Store': 'junk',
|
||||
'docs/Thumbs.db': 'junk',
|
||||
}));
|
||||
expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']);
|
||||
expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 });
|
||||
expect(archive.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an entry from the loaded archive', async () => {
|
||||
const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' }));
|
||||
expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße');
|
||||
await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/);
|
||||
});
|
||||
|
||||
it('stops listing at the cap', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = '';
|
||||
const archive = await openZip(await makeZip(files));
|
||||
expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES);
|
||||
expect(archive.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('says when an archive is password-protected', async () => {
|
||||
const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' }));
|
||||
await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
|
||||
it('rejects data that is not a ZIP', async () => {
|
||||
await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isZipArchive', () => {
|
||||
it('takes ZIPs by type or name, not the formats built on ZIP', () => {
|
||||
expect(isZipArchive('a.zip', 'application/zip')).toBe(true);
|
||||
expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true);
|
||||
expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true);
|
||||
expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
|
||||
expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
// everything else goes to the OS "Open with" chooser.
|
||||
|
||||
export type PreviewKind =
|
||||
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'none';
|
||||
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'archive' | 'none';
|
||||
|
||||
export interface FilePreviewOptions {
|
||||
/**
|
||||
@@ -26,14 +26,16 @@ interface PreviewableFile {
|
||||
* Largest file each preview kind loads. Text is rendered in one selectable
|
||||
* `<Text>`, which gets slow to lay out past a few hundred KB; HTML and
|
||||
* Markdown go through the mail sanitiser in JS; parsing an .eml and decoding
|
||||
* an image hold the whole file in memory. PDFs are paged by their viewer and
|
||||
* media stream from the downloaded copy. Larger files open in another app.
|
||||
* an image hold the whole file in memory, and so does listing a ZIP. PDFs
|
||||
* are paged by their viewer and media stream from the downloaded copy.
|
||||
* Larger files open in another app.
|
||||
*/
|
||||
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
|
||||
text: 512 * 1024,
|
||||
markdown: 1024 * 1024,
|
||||
html: 5 * 1024 * 1024,
|
||||
eml: 10 * 1024 * 1024,
|
||||
archive: 50 * 1024 * 1024,
|
||||
image: 25 * 1024 * 1024,
|
||||
pdf: 100 * 1024 * 1024,
|
||||
audio: 500 * 1024 * 1024,
|
||||
@@ -94,6 +96,7 @@ const MEDIA: Record<'video' | 'audio', Record<FilePreviewOptions['platform'], {
|
||||
},
|
||||
};
|
||||
|
||||
const ZIP_MIME = new Set(['application/zip', 'application/x-zip-compressed', 'application/x-zip']);
|
||||
const HTML_MIME = new Set(['text/html', 'application/xhtml+xml']);
|
||||
const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'xht']);
|
||||
const MARKDOWN_MIME = new Set(['text/markdown', 'text/x-markdown']);
|
||||
@@ -128,6 +131,8 @@ export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, op
|
||||
const media = /^(image|audio|video)\//.test(mime);
|
||||
|
||||
if (mime === 'message/rfc822' || ext === 'eml') return 'eml';
|
||||
// ZIPs list their files; formats built on ZIP (docx, apk) are not ZIPs here.
|
||||
if (ZIP_MIME.has(mime) || ((!mime || mime.startsWith('application/x-')) && ext === 'zip')) return 'archive';
|
||||
if (HTML_MIME.has(mime) || HTML_EXT.has(ext)) return 'html';
|
||||
if (MARKDOWN_MIME.has(mime) || MARKDOWN_EXT.has(ext)) return 'markdown';
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// ZIP archives in the file preview: the entries as a list, and one entry's
|
||||
// bytes when it is opened. Nothing is extracted to disk under the archive's
|
||||
// paths; an opened entry goes through the same preview as any attachment.
|
||||
|
||||
import type JSZip from 'jszip';
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Full path inside the archive, as stored. */
|
||||
path: string;
|
||||
/** Last path segment. */
|
||||
name: string;
|
||||
/** Folder path, '' at the top. */
|
||||
folder: string;
|
||||
/** Uncompressed size, when the archive records it. */
|
||||
size?: number;
|
||||
modified?: Date;
|
||||
}
|
||||
|
||||
export interface ZipArchive {
|
||||
entries: ZipEntry[];
|
||||
/** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */
|
||||
truncated: boolean;
|
||||
/** One entry's bytes. */
|
||||
read(path: string): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/** The archive is password-protected; JSZip can't decrypt it. */
|
||||
export class ZipEncryptedError extends Error {}
|
||||
|
||||
// Metadata the archives of macOS and Windows carry and nobody wants to open.
|
||||
const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i;
|
||||
|
||||
/** Most entries listed; a larger archive lists the first ones. */
|
||||
export const MAX_ZIP_ENTRIES = 2000;
|
||||
|
||||
// JSZip keeps the directory's sizes here without exposing them.
|
||||
interface ZipObjectInternals {
|
||||
_data?: { uncompressedSize?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an archive's directory: its files (not folders), sorted by folder and
|
||||
* then name, and a reader for their contents.
|
||||
*/
|
||||
export async function openZip(bytes: Uint8Array): Promise<ZipArchive> {
|
||||
const { default: Zip } = await import('jszip');
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await Zip.loadAsync(bytes, { createFolders: false });
|
||||
} catch (e) {
|
||||
if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted');
|
||||
throw e;
|
||||
}
|
||||
const entries: ZipEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const file of Object.values(zip.files)) {
|
||||
if (file.dir || JUNK.test(file.name)) continue;
|
||||
if (entries.length >= MAX_ZIP_ENTRIES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const path = file.name;
|
||||
const slash = path.lastIndexOf('/');
|
||||
const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize;
|
||||
entries.push({
|
||||
path,
|
||||
name: slash >= 0 ? path.slice(slash + 1) : path,
|
||||
folder: slash >= 0 ? path.slice(0, slash) : '',
|
||||
size: typeof size === 'number' && size >= 0 ? size : undefined,
|
||||
modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined,
|
||||
});
|
||||
}
|
||||
entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name));
|
||||
return {
|
||||
entries,
|
||||
truncated,
|
||||
read: async (path) => {
|
||||
const file = zip.file(path);
|
||||
if (!file) throw new Error(`${path} is not in the archive`);
|
||||
return file.async('uint8array');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */
|
||||
export function isZipArchive(name: string, type?: string | null): boolean {
|
||||
const mime = (type || '').split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true;
|
||||
return /\.zip$/i.test(name);
|
||||
}
|
||||
Reference in New Issue
Block a user