1 parent
99ee3385ac
commit
53f4812adc
14 files changed
+730
-192
No files matched your search
+489
-1
@@ -791,7 +791,495 @@
|
||||
"Rust server with an embedded HTML, CSS and JavaScript interface": "Serwer w Rust z wbudowanym interfejsem HTML, CSS i JavaScript",
|
||||
"RustBox is free software under the GNU Affero General Public License v3: you may use, modify and share it, also in a company. If you run a modified version as a network service, you must offer its source code to its users.": "RustBox jest wolnym oprogramowaniem na licencji GNU Affero General Public License v3: możesz go używać, zmieniać i udostępniać, także w firmie. Jeśli uruchamiasz zmodyfikowaną wersję jako usługę sieciową, musisz udostępnić jej kod źródłowy jej użytkownikom.",
|
||||
"Companies that prefer different terms — for example to keep their changes private — can obtain a paid commercial license. Contacting the author is voluntary: it is never required for use that follows the AGPL.": "Firmy, które wolą inne warunki — na przykład zachować swoje zmiany dla siebie — mogą uzyskać płatną licencję komercyjną. Kontakt z autorem jest dobrowolny: nie jest wymagany przy korzystaniu zgodnym z AGPL.",
|
||||
"The first version of RustBox was created as a fork of the Oxi project (MIT License).": "Pierwsza wersja RustBox powstała jako fork projektu Oxi (licencja MIT)."
|
||||
"The first version of RustBox was created as a fork of the Oxi project (MIT License).": "Pierwsza wersja RustBox powstała jako fork projektu Oxi (licencja MIT).",
|
||||
"Connected": "Połączono",
|
||||
"Connecting…": "Łączenie…",
|
||||
"Reconnecting": "Ponowne łączenie",
|
||||
"Offline": "Brak połączenia",
|
||||
"Connection error": "Błąd połączenia",
|
||||
"Checking": "Sprawdzanie",
|
||||
"Unavailable": "Niedostępne",
|
||||
"Partial": "Częściowo",
|
||||
"never": "nigdy",
|
||||
"yes": "tak",
|
||||
"no": "nie",
|
||||
"Dismiss notification": "Zamknij powiadomienie",
|
||||
"Action failed": "Nie udało się wykonać akcji",
|
||||
"RustBox cannot reach the server. Read-only cached content may still be visible.": "RustBox nie może połączyć się z serwerem. Nadal może być widoczna buforowana zawartość tylko do odczytu.",
|
||||
"You are offline. RustBox will reconnect when the network returns.": "Brak połączenia z siecią. RustBox połączy się ponownie, gdy sieć będzie dostępna.",
|
||||
"RustBox server is temporarily unreachable": "Serwer RustBox jest chwilowo niedostępny",
|
||||
"You are offline": "Brak połączenia z siecią",
|
||||
"Upload failed: network error": "Wysyłanie nie powiodło się: błąd sieci",
|
||||
"Upload cancelled": "Anulowano wysyłanie",
|
||||
"Confirm action": "Potwierdź działanie",
|
||||
"Snooze until…": "Odłóż do…",
|
||||
"Snoozed messages": "Odłożone wiadomości",
|
||||
"No snoozed messages.": "Brak odłożonych wiadomości.",
|
||||
"Unsnooze now": "Przywróć teraz",
|
||||
"Message unsnoozed": "Przywrócono wiadomość",
|
||||
"Later today ({time})": "Później dzisiaj ({time})",
|
||||
"Tomorrow ({time})": "Jutro ({time})",
|
||||
"This weekend ({time})": "W ten weekend ({time})",
|
||||
"Next week ({time})": "W przyszłym tygodniu ({time})",
|
||||
"Wakes {time}": "Wraca {time}",
|
||||
"Snoozed until {time}": "Odłożono do {time}",
|
||||
"(no subject)": "(brak tematu)",
|
||||
"Mail synchronization": "Synchronizacja poczty",
|
||||
"Switch account": "Przełącz konto",
|
||||
"Resize folder pane": "Zmień szerokość panelu folderów",
|
||||
"Sort messages": "Sortuj wiadomości",
|
||||
"Newest": "Najnowsze",
|
||||
"Oldest": "Najstarsze",
|
||||
"Smallest": "Najmniejsze",
|
||||
"Largest": "Największe",
|
||||
"0 selected": "0 zaznaczonych",
|
||||
"Move selected": "Przenieś zaznaczone",
|
||||
"Copy selected": "Kopiuj zaznaczone",
|
||||
"Label selected": "Nadaj etykietę zaznaczonym",
|
||||
"Done": "Gotowe",
|
||||
"Load more": "Wczytaj więcej",
|
||||
"Select messages": "Zaznacz wiadomości",
|
||||
"Actions for selected messages": "Akcje dla zaznaczonych wiadomości",
|
||||
"First page": "Pierwsza strona",
|
||||
"Last page": "Ostatnia strona",
|
||||
"Resize message list": "Zmień szerokość listy wiadomości",
|
||||
"No message selected": "Nie wybrano wiadomości",
|
||||
"Select a message from the list.": "Wybierz wiadomość z listy.",
|
||||
"Thread": "Wątek",
|
||||
"Remote images are blocked.": "Zdalne obrazy są zablokowane.",
|
||||
"Load images": "Wczytaj obrazy",
|
||||
"Trust sender": "Zaufaj nadawcy",
|
||||
"Verify / decrypt": "Zweryfikuj / odszyfruj",
|
||||
"Find signer key": "Znajdź klucz podpisującego",
|
||||
"Back": "Wstecz",
|
||||
"Previous message": "Poprzednia wiadomość",
|
||||
"Next message": "Następna wiadomość",
|
||||
"Open message full screen": "Otwórz wiadomość na pełnym ekranie",
|
||||
"Exit message full screen": "Wyłącz pełny ekran wiadomości",
|
||||
"Mark read or unread": "Oznacz jako przeczytaną lub nieprzeczytaną",
|
||||
"Toggle thread": "Rozwiń lub zwiń wątek",
|
||||
"Select message": "Zaznacz wiadomość",
|
||||
"Has attachments": "Ma załączniki",
|
||||
"No matching messages.": "Brak pasujących wiadomości.",
|
||||
"No messages in this folder.": "Brak wiadomości w tym folderze.",
|
||||
"Searching…": "Wyszukiwanie…",
|
||||
"{count} message": "{count} wiadomość",
|
||||
"{count} messages": "{count} wiadomości",
|
||||
"{count} results": "{count} wyników",
|
||||
"{count} result": "{count} wynik",
|
||||
"{count} selected": "{count} zaznaczona",
|
||||
"{count} selected_plural": "{count} zaznaczonych",
|
||||
"{count} messages · cached view": "{count} wiadomości · widok z pamięci podręcznej",
|
||||
"No next message": "Brak następnej wiadomości",
|
||||
"No previous message": "Brak poprzedniej wiadomości",
|
||||
"to me": "do mnie",
|
||||
"Inline image": "Obraz w treści",
|
||||
"Inline": "W treści",
|
||||
"Left": "Do lewej",
|
||||
"Center": "Do środka",
|
||||
"More…": "Więcej…",
|
||||
"Keep as attachment": "Zostaw jako załącznik",
|
||||
"Notifications from accounts": "Powiadomienia z kont",
|
||||
"Choose which signed-in accounts can show combined new-mail notifications in this browser.": "Wybierz, z których zalogowanych kont ta przeglądarka może pokazywać wspólne powiadomienia o nowej poczcie.",
|
||||
"Reset settings": "Resetuj ustawienia",
|
||||
"Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.": "Przywróć ustawienia wyglądu, skrzynki, powiadomień, folderów, kalendarza i OpenPGP. Wiadomości, tożsamości, klucze i szablony pozostaną bez zmian.",
|
||||
"Reset all settings": "Resetuj wszystkie ustawienia",
|
||||
"Show account color": "Pokaż kolor konta",
|
||||
"Compose size limits": "Limity rozmiaru wiadomości",
|
||||
"Text-only signature used in plain-text messages": "Podpis tekstowy używany w wiadomościach w zwykłym tekście",
|
||||
"Signature formatting": "Formatowanie podpisu",
|
||||
"Import image": "Importuj obraz",
|
||||
"Paste or edit HTML source": "Wklej lub edytuj kod HTML",
|
||||
"HTML signature editor": "Edytor podpisu HTML",
|
||||
"HTML source": "Kod HTML",
|
||||
"Refresh preview": "Odśwież podgląd",
|
||||
"Scripts, forms and unsafe attributes are removed before the signature is used.": "Skrypty, formularze i niebezpieczne atrybuty są usuwane przed użyciem podpisu.",
|
||||
"Visual editor": "Edytor wizualny",
|
||||
"You can add images and adjust the rendered signature.": "Możesz dodawać obrazy i dostosowywać wygląd podpisu.",
|
||||
"Use this HTML": "Użyj tego HTML",
|
||||
"Paste a complete HTML signature or an HTML fragment here": "Wklej tutaj pełny podpis HTML lub fragment HTML",
|
||||
"HTML signature formatting": "Formatowanie podpisu HTML",
|
||||
"Rendered HTML signature": "Wyrenderowany podpis HTML",
|
||||
"Search contacts": "Szukaj kontaktów",
|
||||
"One address per line": "Jeden adres w wierszu",
|
||||
"One number per line": "Jeden numer w wierszu",
|
||||
"Calendar view": "Widok kalendarza",
|
||||
"No OpenPGP keys yet": "Brak kluczy OpenPGP",
|
||||
"Export public": "Eksportuj publiczny",
|
||||
"Test passphrase": "Sprawdź hasło",
|
||||
"Change passphrase": "Zmień hasło",
|
||||
"Export private": "Eksportuj prywatny",
|
||||
"No identities yet": "Brak tożsamości",
|
||||
"No named rules found. Rules using # rule:[name] are parsed automatically.": "Nie znaleziono nazwanych reguł. Reguły używające # rule:[name] są rozpoznawane automatycznie.",
|
||||
"No scripts": "Brak skryptów",
|
||||
"No saved searches": "Brak zapisanych wyszukiwań",
|
||||
"Run": "Uruchom",
|
||||
"Folder properties": "Właściwości folderu",
|
||||
"Create filter from message": "Utwórz filtr z wiadomości",
|
||||
"Stop processing after this rule": "Zatrzymaj przetwarzanie po tej regule",
|
||||
"Also mark matching mail as read": "Oznacz pasującą pocztę także jako przeczytaną",
|
||||
"Activate selected script after saving": "Aktywuj wybrany skrypt po zapisaniu",
|
||||
"Advanced editor": "Edytor zaawansowany",
|
||||
"No duplicate contacts found": "Nie znaleziono zduplikowanych kontaktów",
|
||||
"Keep this": "Zachowaj ten",
|
||||
"No groups": "Brak grup",
|
||||
"No contacts": "Brak kontaktów",
|
||||
"No events in this range": "Brak wydarzeń w tym zakresie",
|
||||
"Notify": "Powiadamiaj",
|
||||
"No active accounts.": "Brak aktywnych kont.",
|
||||
"Loading sessions…": "Ładowanie sesji…",
|
||||
"No active sessions.": "Brak aktywnych sesji.",
|
||||
"Current": "Bieżące",
|
||||
"Switch": "Przełącz",
|
||||
"Unknown device": "Nieznane urządzenie",
|
||||
"Attachment preview": "Podgląd załącznika",
|
||||
"Close preview": "Zamknij podgląd",
|
||||
"No capabilities reported": "Brak zgłoszonych możliwości",
|
||||
"Authenticated connection · TLS enabled": "Uwierzytelnione połączenie · TLS włączony",
|
||||
"Authenticated connection · plaintext transport": "Uwierzytelnione połączenie · transmisja nieszyfrowana",
|
||||
"SMTP unavailable": "SMTP niedostępny",
|
||||
"body cache enabled": "bufor treści włączony",
|
||||
"body cache disabled": "bufor treści wyłączony",
|
||||
"{folders} cached folders · {messages} cached messages · folder list {folderSync} · messages {messageSync}{pending} · {idleTasks} IDLE task(s) / {subscribers} subscriber(s)": "foldery w pamięci podręcznej: {folders} · wiadomości w pamięci podręcznej: {messages} · lista folderów: {folderSync} · wiadomości: {messageSync}{pending} · zadania IDLE: {idleTasks} / subskrybenci: {subscribers}",
|
||||
" · {count} folders awaiting message sync": " · foldery oczekujące na synchronizację wiadomości: {count}",
|
||||
"{indexed} indexed · {messages} cached messages · {bodies} cached bodies · {bodyCache}{excluded}": "zindeksowane: {indexed} · wiadomości w pamięci podręcznej: {messages} · treści w pamięci podręcznej: {bodies} · {bodyCache}{excluded}",
|
||||
" · {count} excluded": " · pominięte: {count}",
|
||||
"OpenPGP trust updated": "Zaktualizowano poziom zaufania OpenPGP",
|
||||
"OpenPGP private key unlocked successfully": "Pomyślnie odblokowano prywatny klucz OpenPGP",
|
||||
"OpenPGP passphrase changed": "Zmieniono hasło klucza OpenPGP",
|
||||
"OpenPGP key deleted": "Usunięto klucz OpenPGP",
|
||||
"OpenPGP policy saved": "Zapisano politykę OpenPGP",
|
||||
"Paste or choose an OpenPGP key first": "Najpierw wklej lub wybierz klucz OpenPGP",
|
||||
"The selected OpenPGP key file is empty": "Wybrany plik klucza OpenPGP jest pusty",
|
||||
"All recipient OpenPGP keys are already available": "Klucze OpenPGP wszystkich odbiorców są już dostępne",
|
||||
"HTML signature is too large. Reduce imported image sizes.": "Podpis HTML jest zbyt duży. Zmniejsz rozmiary importowanych obrazów.",
|
||||
"Identity saved": "Zapisano tożsamość",
|
||||
"Identity deleted": "Usunięto tożsamość",
|
||||
"Choose a PNG, JPEG, GIF or WebP image": "Wybierz obraz PNG, JPEG, GIF lub WebP",
|
||||
"Signature image must be 2 MB or smaller": "Obraz podpisu może mieć maksymalnie 2 MB",
|
||||
"Could not import signature image": "Nie udało się zaimportować obrazu podpisu",
|
||||
"Folder settings saved": "Zapisano ustawienia folderów",
|
||||
"Notification settings saved": "Zapisano ustawienia powiadomień",
|
||||
"Notifications are not supported by this browser": "Ta przeglądarka nie obsługuje powiadomień",
|
||||
"Browser notifications are not allowed": "Powiadomienia przeglądarki są zablokowane",
|
||||
"Test notification sent": "Wysłano powiadomienie testowe",
|
||||
"This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.": "Ta reguła używa składni Sieve, której edytor wizualny nie może bezpiecznie zmienić. Edytuj ją w zaawansowanym źródle Sieve.",
|
||||
"No Sieve scripts to back up": "Brak skryptów Sieve do utworzenia kopii zapasowej",
|
||||
"Script name is required": "Nazwa skryptu jest wymagana",
|
||||
"Select a script first": "Najpierw wybierz skrypt",
|
||||
"Sieve script deactivated": "Dezaktywowano skrypt Sieve",
|
||||
"Sieve backup is too large": "Kopia zapasowa Sieve jest zbyt duża",
|
||||
"This is not a supported RustBox Sieve backup": "To nie jest obsługiwana kopia zapasowa Sieve RustBox",
|
||||
"Backup contains an invalid script name": "Kopia zapasowa zawiera nieprawidłową nazwę skryptu",
|
||||
"Backup contains too many scripts": "Kopia zapasowa zawiera zbyt wiele skryptów",
|
||||
"Backup marks more than one Sieve script as active": "Kopia zapasowa oznacza więcej niż jeden skrypt Sieve jako aktywny",
|
||||
"validation failed": "walidacja nie powiodła się",
|
||||
"Move undone": "Cofnięto przeniesienie",
|
||||
"Configure an Archive folder in Settings → Folders": "Skonfiguruj folder Archiwum w Ustawienia → Foldery",
|
||||
"Configure a Junk folder in Settings → Folders": "Skonfiguruj folder Spam w Ustawienia → Foldery",
|
||||
"Message deleted": "Usunięto wiadomość",
|
||||
"Choose at least one search criterion": "Wybierz co najmniej jedno kryterium wyszukiwania",
|
||||
"Saved search deleted": "Usunięto zapisane wyszukiwanie",
|
||||
"Search saved": "Zapisano wyszukiwanie",
|
||||
"Folder created": "Utworzono folder",
|
||||
"Folder renamed": "Zmieniono nazwę folderu",
|
||||
"Folder deleted": "Usunięto folder",
|
||||
"Folder list preference saved": "Zapisano ustawienie listy folderów",
|
||||
"Subfolder created": "Utworzono podfolder",
|
||||
".eml exceeds the 50 MB import limit": "Plik .eml przekracza limit importu 50 MB",
|
||||
"All messages marked read": "Oznaczono wszystkie wiadomości jako przeczytane",
|
||||
"Folder emptied": "Opróżniono folder",
|
||||
"This message does not provide List-Unsubscribe": "Ta wiadomość nie zawiera nagłówka List-Unsubscribe",
|
||||
"Unsubscribe request sent": "Wysłano żądanie rezygnacji z subskrypcji",
|
||||
"No supported HTTPS or mailto unsubscribe target": "Brak obsługiwanego celu HTTPS lub mailto do rezygnacji z subskrypcji",
|
||||
"Message source copied": "Skopiowano źródło wiadomości",
|
||||
"Could not copy source": "Nie udało się skopiować źródła",
|
||||
"Select a message before printing": "Wybierz wiadomość przed drukowaniem",
|
||||
"Print dialog opened": "Otwarto okno drukowania",
|
||||
"Could not open the print dialog": "Nie udało się otworzyć okna drukowania",
|
||||
"Message-ID is not available": "Message-ID jest niedostępny",
|
||||
"Message-ID copied": "Skopiowano Message-ID",
|
||||
"Could not copy Message-ID": "Nie udało się skopiować Message-ID",
|
||||
"Sender address copied": "Skopiowano adres nadawcy",
|
||||
"Could not copy address": "Nie udało się skopiować adresu",
|
||||
"Downloading .eml": "Pobieranie .eml",
|
||||
"Headers copied": "Skopiowano nagłówki",
|
||||
"Could not copy headers": "Nie udało się skopiować nagłówków",
|
||||
"Switch to HTML mode to embed attachments in the message": "Przełącz na tryb HTML, aby osadzać załączniki w wiadomości",
|
||||
"Saved response deleted": "Usunięto zapisaną odpowiedź",
|
||||
"Select an inline image first": "Najpierw wybierz obraz w treści",
|
||||
"The inline image attachment is no longer available": "Załącznik obrazu w treści nie jest już dostępny",
|
||||
"Save the current draft before recovering another one": "Zapisz bieżący szkic przed odzyskaniem innego",
|
||||
"No recoverable drafts found": "Nie znaleziono szkiców do odzyskania",
|
||||
"Browser recovery snapshot is no longer available": "Kopia odzyskiwania w przeglądarce nie jest już dostępna",
|
||||
"Recovered unsaved browser draft": "Odzyskano niezapisany szkic z przeglądarki",
|
||||
"Draft recovered": "Odzyskano szkic",
|
||||
"Draft kept in this browser for recovery": "Szkic zachowano w tej przeglądarce do odzyskania",
|
||||
"Sending…": "Wysyłanie…",
|
||||
"Send cancelled": "Anulowano wysyłanie",
|
||||
"Message sent": "Wiadomość wysłana",
|
||||
"Wait for the attachment upload to finish": "Poczekaj na zakończenie wysyłania załącznika",
|
||||
"Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.": "OpenPGP inline nie obsługuje załączników. Wyłącz tryb inline, aby użyć PGP/MIME.",
|
||||
"Add at least one To/Cc recipient before attaching recipient OpenPGP keys": "Dodaj co najmniej jednego odbiorcę Do/DW przed dołączeniem kluczy OpenPGP odbiorców",
|
||||
"No OpenPGP public key is available for any To/Cc recipient": "Brak publicznego klucza OpenPGP dla odbiorców Do/DW",
|
||||
"Calendar exported": "Wyeksportowano kalendarz",
|
||||
"vCard file is too large": "Plik vCard jest zbyt duży",
|
||||
"Contacts merged": "Scalono kontakty",
|
||||
"Group created": "Utworzono grupę",
|
||||
"Group renamed": "Zmieniono nazwę grupy",
|
||||
"Group deleted": "Usunięto grupę",
|
||||
"Contact photo exceeds the 2 MB limit": "Zdjęcie kontaktu przekracza limit 2 MB",
|
||||
"Contact photo saved": "Zapisano zdjęcie kontaktu",
|
||||
"Contact photo removed": "Usunięto zdjęcie kontaktu",
|
||||
"Contact saved": "Zapisano kontakt",
|
||||
"Contact deleted": "Usunięto kontakt",
|
||||
"Switch to HTML mode to paste inline images": "Przełącz na tryb HTML, aby wklejać obrazy w treści",
|
||||
"Message sort updated": "Zmieniono sortowanie wiadomości",
|
||||
"Select signature text before inserting a link": "Zaznacz tekst podpisu przed wstawieniem linku",
|
||||
"ICS file is too large": "Plik ICS jest zbyt duży",
|
||||
"Start and end are required": "Data rozpoczęcia i zakończenia są wymagane",
|
||||
"Event saved": "Zapisano wydarzenie",
|
||||
"Event deleted": "Usunięto wydarzenie",
|
||||
"Label saved": "Zapisano etykietę",
|
||||
"Label deleted": "Usunięto etykietę",
|
||||
"Create a label first": "Najpierw utwórz etykietę",
|
||||
"Mailbox settings saved": "Zapisano ustawienia skrzynki",
|
||||
"Enable IMAP keyword synchronization first": "Najpierw włącz synchronizację słów kluczowych IMAP",
|
||||
"Appearance reset": "Przywrócono ustawienia wyglądu",
|
||||
"Session revoked": "Unieważniono sesję",
|
||||
"Multiple accounts are disabled by the RustBox administrator": "Administrator RustBox wyłączył obsługę wielu kont",
|
||||
"Account added": "Dodano konto",
|
||||
"OpenPGP signature is cryptographically valid, but this key is marked untrusted": "Podpis OpenPGP jest kryptograficznie prawidłowy, ale ten klucz jest oznaczony jako niezaufany",
|
||||
"OpenPGP signature verified": "Zweryfikowano podpis OpenPGP",
|
||||
"OpenPGP message decrypted": "Odszyfrowano wiadomość OpenPGP",
|
||||
"OpenPGP signature could not be verified": "Nie udało się zweryfikować podpisu OpenPGP",
|
||||
"Unable to decode decrypted attachment": "Nie udało się zdekodować odszyfrowanego załącznika",
|
||||
"This message has no attachments": "Ta wiadomość nie ma załączników",
|
||||
"Too many attachments for a browser ZIP": "Zbyt wiele załączników, aby utworzyć ZIP w przeglądarce",
|
||||
"OpenPGP decrypted + verified": "OpenPGP odszyfrowane i zweryfikowane",
|
||||
"OpenPGP decrypted": "OpenPGP odszyfrowane",
|
||||
"OpenPGP signature not verified": "Podpis OpenPGP niezweryfikowany",
|
||||
"No matching public key verified this signature.": "Żaden pasujący klucz publiczny nie zweryfikował tego podpisu.",
|
||||
"Reprocess": "Przetwórz ponownie",
|
||||
"Unable to open message": "Nie udało się otworzyć wiadomości",
|
||||
"Generated OpenPGP key {key}": "Wygenerowano klucz OpenPGP {key}",
|
||||
"Imported OpenPGP key {key}": "Zaimportowano klucz OpenPGP {key}",
|
||||
"OpenPGP key discovered via {source}": "Znaleziono klucz OpenPGP przez {source}",
|
||||
"No OpenPGP key found for {email}": "Nie znaleziono klucza OpenPGP dla {email}",
|
||||
"Notification permission: {permission}": "Uprawnienie do powiadomień: {permission}",
|
||||
"IMAP reconnected · {count} capabilities": "Połączono ponownie z IMAP · możliwości: {count}",
|
||||
"Search index rebuilt: {count} messages": "Przebudowano indeks wyszukiwania: {count} wiadomości",
|
||||
"Backed up {count} Sieve script": "Utworzono kopię zapasową {count} skryptu Sieve",
|
||||
"Backed up {count} Sieve scripts": "Utworzono kopię zapasową {count} skryptów Sieve",
|
||||
"Restored {count} Sieve script": "Przywrócono {count} skrypt Sieve",
|
||||
"Restored {count} Sieve scripts": "Przywrócono {count} skryptów Sieve",
|
||||
"Sieve script “{name}” saved": "Zapisano skrypt Sieve „{name}”",
|
||||
"Sieve script “{name}” activated": "Aktywowano skrypt Sieve „{name}”",
|
||||
"Sieve script “{name}” deleted": "Usunięto skrypt Sieve „{name}”",
|
||||
"Undo completed: {count} message": "Cofnięto operację dla {count} wiadomości",
|
||||
"Undo completed: {count} messages": "Cofnięto operację dla {count} wiadomości",
|
||||
"Moved to {folder}": "Przeniesiono do {folder}",
|
||||
"Copied to {folder}": "Skopiowano do {folder}",
|
||||
"{action}: {count} message": "{action}: {count} wiadomość",
|
||||
"{action}: {count} messages": "{action}: {count} wiadomości",
|
||||
"{done}/{total} completed: {error}": "Ukończono {done}/{total}: {error}",
|
||||
"Imported {file}": "Zaimportowano {file}",
|
||||
"{address} is already in contacts": "{address} jest już w kontaktach",
|
||||
"{address} added to contacts": "Dodano {address} do kontaktów",
|
||||
"{email} is now trusted; remote images will load automatically": "{email} jest teraz zaufanym nadawcą; zdalne obrazy będą wczytywane automatycznie",
|
||||
"{email} removed from trusted senders": "Usunięto {email} z zaufanych nadawców",
|
||||
"ManageSieve unavailable: {error}": "ManageSieve niedostępny: {error}",
|
||||
"Could not send: {error}": "Nie udało się wysłać: {error}",
|
||||
"Missing OpenPGP key for {emails}": "Brak klucza OpenPGP dla: {emails}",
|
||||
"OpenPGP key marked untrusted for {emails}": "Klucz OpenPGP oznaczony jako niezaufany dla: {emails}",
|
||||
"OpenPGP policy requires trusted keys: {emails}": "Polityka OpenPGP wymaga zaufanych kluczy dla: {emails}",
|
||||
"vCard import: {created} created, {updated} updated, {skipped} skipped": "Import vCard: utworzono {created}, zaktualizowano {updated}, pominięto {skipped}",
|
||||
"ICS import: {created} created, {skipped} skipped": "Import ICS: utworzono {created}, pominięto {skipped}",
|
||||
"Selected all {count} search result": "Zaznaczono cały {count} wynik wyszukiwania",
|
||||
"Selected all {count} search results": "Zaznaczono wszystkie wyniki wyszukiwania: {count}",
|
||||
"Label “{name}” added": "Dodano etykietę „{name}”",
|
||||
"Labels reconciled: +{added}, −{removed}": "Zsynchronizowano etykiety: +{added}, −{removed}",
|
||||
"Revoked {count} session(s)": "Unieważniono sesje: {count}",
|
||||
"Downloading {filename}": "Pobieranie {filename}",
|
||||
"Preparing ZIP with {count} attachment…": "Przygotowywanie ZIP z {count} załącznikiem…",
|
||||
"Preparing ZIP with {count} attachments…": "Przygotowywanie ZIP z {count} załącznikami…",
|
||||
"Downloaded {count} attachment as ZIP": "Pobrano {count} załącznik jako ZIP",
|
||||
"Downloaded {count} attachments as ZIP": "Pobrano {count} załączników jako ZIP",
|
||||
"Calendar: {count} event(s) imported": "Kalendarz: zaimportowano wydarzenia: {count}",
|
||||
"Contacts: {created} created, {updated} updated": "Kontakty: utworzono {created}, zaktualizowano {updated}",
|
||||
"Archived": "Zarchiwizowano",
|
||||
"Marked read": "Oznaczono jako przeczytane",
|
||||
"Marked unread": "Oznaczono jako nieprzeczytane",
|
||||
"Unflagged": "Usunięto flagę",
|
||||
"Deleted": "Usunięto",
|
||||
"Moved": "Przeniesiono",
|
||||
"Copied": "Skopiowano",
|
||||
"Updated": "Zaktualizowano",
|
||||
"Rule order changed — save to apply": "Zmieniono kolejność reguł — zapisz, aby zastosować",
|
||||
"Incomplete": "Niekompletna",
|
||||
"Autoresponder appended": "Dodano autoresponder",
|
||||
"Loaded": "Wczytano",
|
||||
"Quick-filter values loaded. Review the rule, add it, validate, then save.": "Wczytano wartości szybkiego filtra. Sprawdź regułę, dodaj ją, zweryfikuj i zapisz.",
|
||||
"Reconciling…": "Synchronizowanie…",
|
||||
"Discovered {count} OpenPGP key": "Znaleziono {count} klucz OpenPGP",
|
||||
"Discovered {count} OpenPGP keys": "Znaleziono {count} kluczy OpenPGP",
|
||||
"No missing OpenPGP keys were discovered": "Nie znaleziono brakujących kluczy OpenPGP",
|
||||
"Mailbox storage is {percent}% full": "Pamięć skrzynki jest zapełniona w {percent}%",
|
||||
"Mailbox storage is {percent}% used": "Wykorzystano {percent}% pamięci skrzynki",
|
||||
"Flag removed": "Usunięto flagę",
|
||||
"Message flagged": "Oznaczono wiadomość flagą",
|
||||
"Folder subscribed": "Subskrybowano folder",
|
||||
"Folder unsubscribed": "Anulowano subskrypcję folderu",
|
||||
"Could not embed {filename}": "Nie udało się osadzić {filename}",
|
||||
"Saved response updated": "Zaktualizowano zapisaną odpowiedź",
|
||||
"Saved response created": "Utworzono zapisaną odpowiedź",
|
||||
"Image cropped": "Przycięto obraz",
|
||||
"Image layout updated": "Zaktualizowano układ obrazu",
|
||||
"Too late to cancel": "Jest już za późno na anulowanie",
|
||||
"unknown error": "nieznany błąd",
|
||||
"Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.": "Usunąć ochronę hasłem z tego klucza prywatnego? Plik klucza nadal będzie chroniony uprawnieniami systemu plików serwera, ale osoba, która go uzyska, będzie mogła użyć go od razu.",
|
||||
"Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.": "Wyeksportować prywatny klucz OpenPGP? Każdy, kto uzyska ten plik i jego hasło, może użyć Twojej tożsamości.",
|
||||
"Delete this identity?": "Usunąć tę tożsamość?",
|
||||
"Test OpenPGP passphrase": "Sprawdź hasło OpenPGP",
|
||||
"Current OpenPGP passphrase": "Bieżące hasło OpenPGP",
|
||||
"New OpenPGP passphrase": "Nowe hasło OpenPGP",
|
||||
"Remove OpenPGP passphrase": "Usuń hasło OpenPGP",
|
||||
"Export private key": "Eksportuj klucz prywatny",
|
||||
"Delete OpenPGP key": "Usuń klucz OpenPGP",
|
||||
"Delete identity": "Usuń tożsamość",
|
||||
"Enter the passphrase for this private key. It is used only for this test and is not stored.": "Wprowadź hasło tego klucza prywatnego. Jest używane tylko do tego testu i nie jest zapisywane.",
|
||||
"Enter the current passphrase. Leave it empty if the key is currently unprotected.": "Wprowadź bieżące hasło. Pozostaw puste, jeśli klucz nie jest obecnie chroniony hasłem.",
|
||||
"Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.": "Wprowadź nowe hasło. Pozostaw puste tylko wtedy, gdy celowo chcesz usunąć ochronę hasłem.",
|
||||
"Test": "Testuj",
|
||||
"Change": "Zmień",
|
||||
"Remove passphrase": "Usuń hasło",
|
||||
"Export": "Eksportuj",
|
||||
"Rebuild the search index from the local message cache?": "Przebudować indeks wyszukiwania z lokalnej pamięci podręcznej wiadomości?",
|
||||
"Rebuild search index": "Przebuduj indeks wyszukiwania",
|
||||
"Rebuild": "Przebuduj",
|
||||
"Delete filter rule": "Usuń regułę filtra",
|
||||
"Restore Sieve backup": "Przywróć kopię Sieve",
|
||||
"Overwrite active filter script": "Nadpisz aktywny skrypt filtrów",
|
||||
"Delete Sieve script": "Usuń skrypt Sieve",
|
||||
"Overwrite": "Nadpisz",
|
||||
"Permanently delete this message?": "Trwale usunąć tę wiadomość?",
|
||||
"Unsubscribe from this mailing list?": "Zrezygnować z tej listy mailingowej?",
|
||||
"This mailing list does not support one-click unsubscribe. Open its external unsubscribe page?": "Ta lista mailingowa nie obsługuje rezygnacji jednym kliknięciem. Otworzyć zewnętrzną stronę rezygnacji?",
|
||||
"Rename folder": "Zmień nazwę folderu",
|
||||
"Create subfolder": "Utwórz podfolder",
|
||||
"Flagged messages on top": "Wiadomości z flagą na górze",
|
||||
"Delete message permanently": "Trwale usuń wiadomość",
|
||||
"Delete selected messages": "Usuń zaznaczone wiadomości",
|
||||
"Delete saved search": "Usuń zapisane wyszukiwanie",
|
||||
"Delete folder": "Usuń folder",
|
||||
"Empty folder": "Opróżnij folder",
|
||||
"One-click unsubscribe": "Rezygnacja jednym kliknięciem",
|
||||
"Open unsubscribe page": "Otwórz stronę rezygnacji",
|
||||
"Name this search so you can run it again later.": "Nadaj nazwę temu wyszukiwaniu, aby móc uruchomić je ponownie.",
|
||||
"Delete permanently": "Usuń trwale",
|
||||
"Unsubscribe": "Zrezygnuj",
|
||||
"Open page": "Otwórz stronę",
|
||||
"Search name": "Nazwa wyszukiwania",
|
||||
"Subfolder name": "Nazwa podfolderu",
|
||||
"The message mentions an attachment but no file is attached. Send anyway?": "Treść wiadomości sugeruje załącznik, ale żaden plik nie został dołączony. Wysłać mimo to?",
|
||||
"Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?": "OpenPGP inline wysyła wersję tekstową i usuwa formatowanie HTML/Markdown. Kontynuować?",
|
||||
"Response format": "Format odpowiedzi",
|
||||
"Delete saved response": "Usuń zapisaną odpowiedź",
|
||||
"Missing attachment": "Brak załącznika",
|
||||
"Name shown in the composer response menu.": "Nazwa wyświetlana w menu zapisanych odpowiedzi edytora.",
|
||||
"Recover": "Odzyskaj",
|
||||
"Send anyway": "Wyślij mimo to",
|
||||
"Response name": "Nazwa odpowiedzi",
|
||||
"Delete this contact?": "Usunąć ten kontakt?",
|
||||
"New contact group": "Nowa grupa kontaktów",
|
||||
"Rename contact group": "Zmień nazwę grupy kontaktów",
|
||||
"Merge duplicate contacts": "Scal zduplikowane kontakty",
|
||||
"Delete contact group": "Usuń grupę kontaktów",
|
||||
"Delete contact": "Usuń kontakt",
|
||||
"Merge": "Scal",
|
||||
"Group name": "Nazwa grupy",
|
||||
"Switching away from HTML can hide or discard formatting when the message is sent. Continue?": "Przełączenie z HTML może ukryć lub usunąć formatowanie podczas wysyłania wiadomości. Kontynuować?",
|
||||
"Insert signature link": "Wstaw link w podpisie",
|
||||
"Change compose format": "Zmień format wiadomości",
|
||||
"Switch format": "Zmień format",
|
||||
"Insert": "Wstaw",
|
||||
"Delete this event?": "Usunąć to wydarzenie?",
|
||||
"Delete event": "Usuń wydarzenie",
|
||||
"Delete this label? Messages will not be deleted.": "Usunąć tę etykietę? Wiadomości nie zostaną usunięte.",
|
||||
"Delete label": "Usuń etykietę",
|
||||
"Choose label": "Wybierz etykietę",
|
||||
"Revoke this RustBox session?": "Unieważnić tę sesję RustBox?",
|
||||
"Revoke every other active RustBox session for this account?": "Unieważnić wszystkie pozostałe aktywne sesje RustBox tego konta?",
|
||||
"Revoke session": "Unieważnij sesję",
|
||||
"Remove account": "Usuń konto",
|
||||
"Revoke": "Unieważnij",
|
||||
"Revoke sessions": "Unieważnij sesje",
|
||||
"Decrypt OpenPGP message": "Odszyfruj wiadomość OpenPGP",
|
||||
"Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.": "Jeśli klucz jest chroniony, wprowadź hasło klucza prywatnego. Jest używane tylko dla tego żądania i nie jest zapisywane.",
|
||||
"Decrypt": "Odszyfruj",
|
||||
"Inline image actions": "Akcje obrazu w treści",
|
||||
"Colors plus the style's layout: density, row height, pane widths and type size.": "Kolory oraz układ stylu: gęstość, wysokość wierszy, szerokości paneli i rozmiar tekstu.",
|
||||
"Maximum attachment size:": "Maksymalny rozmiar załącznika:",
|
||||
"Maximum message size:": "Maksymalny rozmiar wiadomości:",
|
||||
"Account color": "Kolor konta",
|
||||
"Sage": "Szałwiowy",
|
||||
"Blue": "Niebieski",
|
||||
"Teal": "Morski",
|
||||
"Cyan": "Cyjan",
|
||||
"Indigo": "Indygo",
|
||||
"Slate": "Łupkowy",
|
||||
"Amber": "Bursztynowy",
|
||||
"Orange": "Pomarańczowy",
|
||||
"Violet": "Fioletowy",
|
||||
"Rose": "Różany",
|
||||
"Red": "Czerwony",
|
||||
"Emerald": "Szmaragdowy",
|
||||
"Lime": "Limonkowy",
|
||||
"Sky": "Błękitny",
|
||||
"Navy": "Granatowy",
|
||||
"Plum": "Śliwkowy",
|
||||
"Magenta": "Magenta",
|
||||
"Copper": "Miedziany",
|
||||
"Sand": "Piaskowy",
|
||||
"Delete OpenPGP key {key}?": "Usunąć klucz OpenPGP {key}?",
|
||||
"Delete Sieve rule “{name}”?": "Usunąć regułę Sieve „{name}”?",
|
||||
"“{name}” is the active Sieve script. Validate and overwrite it?": "„{name}” jest aktywnym skryptem Sieve. Zweryfikować i nadpisać go?",
|
||||
"Delete Sieve script “{name}”?": "Usunąć skrypt Sieve „{name}”?",
|
||||
"Permanently delete {count} selected messages?": "Trwale usunąć zaznaczone wiadomości: {count}?",
|
||||
"Delete saved search “{name}”?": "Usunąć zapisane wyszukiwanie „{name}”?",
|
||||
"Delete folder “{name}”?": "Usunąć folder „{name}”?",
|
||||
"Folder: {folder}": "Folder: {folder}",
|
||||
"Parent folder: {folder}": "Folder nadrzędny: {folder}",
|
||||
"Permanently remove every message from “{name}”?": "Trwale usunąć wszystkie wiadomości z „{name}”?",
|
||||
"Delete saved response “{name}”?": "Usunąć zapisaną odpowiedź „{name}”?",
|
||||
"Merge {count} duplicate contact(s) into {contact}?": "Scalić zduplikowane kontakty ({count}) z {contact}?",
|
||||
"Delete group “{name}”? Contacts will be kept.": "Usunąć grupę „{name}”? Kontakty zostaną zachowane.",
|
||||
"Remove {email} from this browser session?": "Usunąć {email} z tej sesji przeglądarki?",
|
||||
"Sender address": "Adres nadawcy",
|
||||
"Sender domain": "Domena nadawcy",
|
||||
"Flag message": "Oznacz wiadomość flagą",
|
||||
"Destination folder": "Folder docelowy",
|
||||
"Comparison": "Porównanie",
|
||||
"active": "aktywny",
|
||||
"new": "nowy",
|
||||
"Download all as ZIP": "Pobierz wszystkie jako ZIP",
|
||||
"Thread ({count})": "Wątek ({count})",
|
||||
"account": "konto",
|
||||
"Automatic IMAP Junk/Spam": "Automatyczny IMAP Junk/Spam",
|
||||
"{folder} (not available in this account)": "{folder} (niedostępny na tym koncie)",
|
||||
"Label: {name}": "Etykieta: {name}",
|
||||
"enabled": "włączona",
|
||||
"disabled": "wyłączona",
|
||||
"{name} {state} — save to apply": "{name} {state} — zapisz, aby zastosować",
|
||||
"Rule “{name}” removed — save to apply": "Usunięto regułę „{name}” — zapisz, aby zastosować",
|
||||
"ACTIVE": "AKTYWNY",
|
||||
"{count} key · {privateCount} private": "{count} klucz · prywatne: {privateCount}",
|
||||
"{count} keys · {privateCount} private": "klucze: {count} · prywatne: {privateCount}",
|
||||
"No email": "Brak e-maila",
|
||||
"private + public": "prywatny + publiczny",
|
||||
"public": "publiczny",
|
||||
"import": "import",
|
||||
"default": "domyślna"
|
||||
},
|
||||
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
|
||||
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||
|
||||
+15
-14
@@ -130,13 +130,14 @@
|
||||
aria-label="Resize folder pane"></div>
|
||||
</aside>
|
||||
|
||||
<section class="message-pane">
|
||||
<section class="message-pane" data-i18n-scope>
|
||||
<div class="pane-toolbar">
|
||||
<div class="pane-title"><button id="mobile-folders" class="icon-button mobile-only" type="button"
|
||||
aria-label="Folders"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-menu"></use>
|
||||
</svg></button>
|
||||
<div><strong id="folder-title">Inbox</strong><span id="message-count">0 messages</span></div>
|
||||
<div><strong id="folder-title" data-i18n-skip>Inbox</strong><span id="message-count" data-i18n-skip>0
|
||||
messages</span></div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="list-sort-control"><span class="sr-only">Sort messages</span><select id="message-sort"
|
||||
@@ -184,7 +185,7 @@
|
||||
<button id="bulk-delete" class="button quiet danger" type="button">Delete</button>
|
||||
<button id="selection-done" class="button quiet" type="button">Done</button>
|
||||
</div>
|
||||
<div id="message-list" class="message-list" aria-live="polite"></div>
|
||||
<div id="message-list" class="message-list" aria-live="polite" data-i18n-skip></div>
|
||||
<div id="message-pagination" class="message-pagination hidden" aria-label="Mailbox pages">
|
||||
<button id="page-first" class="icon-button pagination-arrow pagination-jump" type="button" title="First page"
|
||||
aria-label="First page"><span aria-hidden="true">«</span></button>
|
||||
@@ -207,7 +208,7 @@
|
||||
aria-label="Resize message list"></div>
|
||||
</section>
|
||||
|
||||
<section id="reader" class="reader-pane">
|
||||
<section id="reader" class="reader-pane" data-i18n-scope>
|
||||
<div id="reader-empty" class="reader-empty">
|
||||
<svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail-open"></use>
|
||||
@@ -287,18 +288,18 @@
|
||||
</svg></button>
|
||||
</div>
|
||||
<header class="message-header">
|
||||
<h1 id="reader-subject"></h1>
|
||||
<h1 id="reader-subject" data-i18n-skip></h1>
|
||||
<div class="message-meta">
|
||||
<div class="avatar" id="reader-avatar">?</div>
|
||||
<div class="sender">
|
||||
<div class="sender-line"><strong id="reader-from"></strong><button id="reader-add-sender-contact"
|
||||
class="address-contact-button" type="button" title="Add sender to contacts"
|
||||
aria-label="Add sender to contacts"><svg>
|
||||
<div class="sender-line"><strong id="reader-from" data-i18n-skip></strong><button
|
||||
id="reader-add-sender-contact" class="address-contact-button" type="button"
|
||||
title="Add sender to contacts" aria-label="Add sender to contacts"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||
</svg></button></div>
|
||||
<div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false"
|
||||
title="Show full addresses"></div>
|
||||
</div><time id="reader-date"></time>
|
||||
<div id="reader-to" class="reader-recipients" data-i18n-skip role="button" tabindex="0"
|
||||
aria-expanded="false" title="Show full addresses"></div>
|
||||
</div><time id="reader-date" data-i18n-skip></time>
|
||||
</div>
|
||||
</header>
|
||||
<section id="thread-section" class="thread-section hidden">
|
||||
@@ -306,9 +307,9 @@
|
||||
class="icon-button small" type="button" aria-label="Toggle thread"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
|
||||
</svg></button></div>
|
||||
<div id="thread-list" class="thread-list"></div>
|
||||
<div id="thread-list" class="thread-list" data-i18n-skip></div>
|
||||
</section>
|
||||
<div id="attachment-list" class="attachment-list hidden"></div>
|
||||
<div id="attachment-list" class="attachment-list hidden" data-i18n-skip></div>
|
||||
<div id="remote-image-bar" class="remote-image-bar hidden"><span>Remote images are blocked.</span>
|
||||
<div class="inline-actions"><button id="remote-image-load" class="button quiet" type="button">Load
|
||||
images</button><button id="remote-image-trust" class="button quiet" type="button">Trust sender</button>
|
||||
@@ -320,7 +321,7 @@
|
||||
/ decrypt</button><button id="pgp-reader-discover" class="button quiet small hidden" type="button">Find
|
||||
signer key</button></div>
|
||||
</div>
|
||||
<div id="message-body" class="message-body"></div>
|
||||
<div id="message-body" class="message-body" data-i18n-skip></div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+10
-10
@@ -5,12 +5,12 @@ function setConnectivityState(online, detail = "") {
|
||||
const banner = $("#offline-banner");
|
||||
if (banner) {
|
||||
banner.classList.toggle("hidden", state.networkOnline);
|
||||
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
|
||||
banner.textContent = trText(detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns."));
|
||||
}
|
||||
const label = $("#connection-label");
|
||||
if (!label) return;
|
||||
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
|
||||
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
|
||||
if (!state.networkOnline) label.textContent = trText(navigator.onLine ? "Reconnecting" : "Offline");
|
||||
else if (["Offline", "Reconnecting", "Connection error"].some((value) => [value, trText(value)].includes(label.textContent))) label.textContent = trText("Connected");
|
||||
}
|
||||
|
||||
function listStateStorageKey(folder = state.folder) {
|
||||
@@ -182,17 +182,17 @@ function toast(message, kind = "info", duration = null, action = null) {
|
||||
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
|
||||
const icon = el("span", "toast-icon");
|
||||
icon.innerHTML = svgIcon(iconName);
|
||||
const text = el("span", "toast-message", message);
|
||||
const text = el("span", "toast-message", trText(String(message ?? "")));
|
||||
let actionButton = null;
|
||||
if (action?.label && typeof action.onClick === "function") {
|
||||
actionButton = el("button", "toast-action", action.label);
|
||||
actionButton = el("button", "toast-action", trText(action.label));
|
||||
actionButton.type = "button";
|
||||
}
|
||||
const countdown = el("span", "toast-countdown");
|
||||
countdown.setAttribute("aria-hidden", "true");
|
||||
const close = el("button", "toast-close");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.setAttribute("aria-label", trText("Dismiss notification"));
|
||||
close.innerHTML = svgIcon("x");
|
||||
const controls = el("span", "toast-controls");
|
||||
if (actionButton) controls.append(actionButton);
|
||||
@@ -275,19 +275,19 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
|
||||
const form = el("form", "dialog-card app-prompt-card");
|
||||
form.method = "dialog";
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", title));
|
||||
header.append(el("strong", "", trText(title)));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", trText(cancelLabel));
|
||||
close.innerHTML = svgIcon("x");
|
||||
header.append(close);
|
||||
const body = el("div", "app-prompt-body");
|
||||
if (message) body.append(el("p", "app-prompt-message", message));
|
||||
if (message) body.append(el("p", "app-prompt-message", trText(message)));
|
||||
let control = null;
|
||||
if (Array.isArray(selectOptions)) {
|
||||
control = document.createElement("select");
|
||||
control.className = "app-prompt-control";
|
||||
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
|
||||
for (const option of selectOptions) control.add(new Option(trText(option.label ?? option.value), option.value));
|
||||
if (input?.value !== undefined) control.value = String(input.value);
|
||||
body.append(control);
|
||||
} else if (input) {
|
||||
@@ -295,7 +295,7 @@ function appDialog({ title = "RustBox", message = "", input = null, selectOption
|
||||
control.className = "app-prompt-control";
|
||||
if (!input.multiline) control.type = input.type || "text";
|
||||
control.value = input.value ?? "";
|
||||
if (input.placeholder) control.placeholder = input.placeholder;
|
||||
if (input.placeholder) control.placeholder = trText(input.placeholder);
|
||||
if (input.required) control.required = true;
|
||||
body.append(control);
|
||||
}
|
||||
|
||||
@@ -299,7 +299,46 @@ function tr(key, fallback = key) {
|
||||
}
|
||||
|
||||
function trText(text) {
|
||||
return state.translations?._text?.[text] || text;
|
||||
const source = String(text ?? "");
|
||||
const map = state.translations?._text || {};
|
||||
if (map[source]) return map[source];
|
||||
if (!source || !Object.keys(map).length) return source;
|
||||
if (trText._map !== map) {
|
||||
trText._map = map;
|
||||
trText._templates = Object.entries(map)
|
||||
.filter(([template]) => /\{\w+\}/.test(template))
|
||||
.map(([template, translated]) => {
|
||||
const names = [];
|
||||
const pattern = template
|
||||
.split(/(\{\w+\})/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const match = /^\{(\w+)\}$/.exec(part);
|
||||
if (match) { names.push(match[1]); return "(.+?)"; }
|
||||
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
})
|
||||
.join("");
|
||||
return {
|
||||
regex: new RegExp(`^${pattern}$`, "s"),
|
||||
names,
|
||||
translated,
|
||||
staticLength: template.replace(/\{\w+\}/g, "").length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.staticLength - a.staticLength);
|
||||
}
|
||||
for (const entry of trText._templates || []) {
|
||||
const match = entry.regex.exec(source);
|
||||
if (!match) continue;
|
||||
let translated = entry.translated;
|
||||
entry.names.forEach((name, index) => { translated = translated.replaceAll(`{${name}}`, match[index + 1]); });
|
||||
return translated;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function fmtText(template, values = {}) {
|
||||
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
|
||||
}
|
||||
|
||||
function applyPreferences() {
|
||||
@@ -612,7 +651,7 @@ async function reconcileImapLabels() {
|
||||
return;
|
||||
}
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = "Reconciling…";
|
||||
if (status) status.textContent = trText("Reconciling…");
|
||||
try {
|
||||
const result = await request("/tags/reconcile", { method: "POST", body: "{}" });
|
||||
const added = Number(result.labels_added || 0);
|
||||
@@ -934,7 +973,7 @@ async function loadAuthPolicy() {
|
||||
});
|
||||
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => {
|
||||
const label = $(selector);
|
||||
if (label) label.textContent = "Keep me signed in";
|
||||
if (label) label.textContent = trText("Keep me signed in");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1029,7 +1068,7 @@ function renderAccountNotificationSettings() {
|
||||
saveNotificationAccountSelection();
|
||||
if (input.checked && typeof loadNotificationPreferencesForAccount === "function") void loadNotificationPreferencesForAccount(account.id);
|
||||
});
|
||||
label.append(input, el("span", "", "Notify"));
|
||||
label.append(input, el("span", "", trText("Notify")));
|
||||
row.append(details, label);
|
||||
list.append(row);
|
||||
}
|
||||
@@ -1093,7 +1132,7 @@ function renderAccountSettings() {
|
||||
? state.accounts
|
||||
: state.accounts.filter((account) => account.id === state.accountId);
|
||||
if (!accounts.length) {
|
||||
list.append(el("div", "empty-list", "No active accounts."));
|
||||
list.append(el("div", "empty-list", trText("No active accounts.")));
|
||||
renderAccountSwitcher();
|
||||
renderAccountNotificationSettings();
|
||||
return;
|
||||
@@ -1107,7 +1146,7 @@ function renderAccountSettings() {
|
||||
use.addEventListener("click", () => switchAccount(account.id));
|
||||
row.append(email, use);
|
||||
if (multiAccountEnabled()) {
|
||||
const remove = el("button", "button quiet danger", "Remove");
|
||||
const remove = el("button", "button quiet danger", trText("Remove"));
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => removeBrowserAccount(account.id));
|
||||
row.append(remove);
|
||||
@@ -1121,13 +1160,13 @@ function renderAccountSettings() {
|
||||
async function loadActiveSessions() {
|
||||
const list = $("#account-session-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren(el("div", "empty-list", "Loading sessions…"));
|
||||
list.replaceChildren(el("div", "empty-list", trText("Loading sessions…")));
|
||||
try {
|
||||
const data = await request("/auth/sessions", { method: "GET" });
|
||||
const sessions = data.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (!sessions.length) {
|
||||
list.append(el("div", "empty-list", "No active sessions."));
|
||||
list.append(el("div", "empty-list", trText("No active sessions.")));
|
||||
return;
|
||||
}
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -137,7 +137,7 @@ function setFolderCounts(folderName, totalCount, unreadCount) {
|
||||
if (folderName === state.folder && !state.searchContext) {
|
||||
const total = Math.max(0, Number(folder.total_count) || 0);
|
||||
const count = $("#message-count");
|
||||
if (count) count.textContent = `${total} ${total === 1 ? "message" : "messages"}`;
|
||||
if (count) count.textContent = fmtText(total === 1 ? "{count} message" : "{count} messages", { count: total });
|
||||
}
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
@@ -214,7 +214,7 @@ function updateUnreadAppBadge() {
|
||||
}
|
||||
|
||||
async function loadFolders(restoreView = false) {
|
||||
$("#connection-label").textContent = "Connecting";
|
||||
$("#connection-label").textContent = trText("Connecting");
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
@@ -231,7 +231,7 @@ async function loadFolders(restoreView = false) {
|
||||
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
|
||||
}
|
||||
renderFolders();
|
||||
$("#connection-label").textContent = "Connected";
|
||||
$("#connection-label").textContent = trText("Connected");
|
||||
setTimeout(() => { void refreshFolderListOnly({ forceServer: true }); }, 0);
|
||||
if (restoreView) {
|
||||
const restoredSearch = readActiveSearchContext();
|
||||
@@ -246,10 +246,10 @@ async function loadFolders(restoreView = false) {
|
||||
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true, { restoreState: restoreView });
|
||||
} catch (error) {
|
||||
$("#connection-label").textContent = "Connection error";
|
||||
$("#connection-label").textContent = trText("Connection error");
|
||||
if (error.status === 401) return showLogin();
|
||||
toast(error.message, "error");
|
||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(trText(error.message))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ function renderMoveOptions() {
|
||||
for (const [selector, placeholder] of selects) {
|
||||
const select = $(selector);
|
||||
if (!select) continue;
|
||||
select.replaceChildren(new Option(placeholder, ""));
|
||||
select.replaceChildren(new Option(trText(placeholder), ""));
|
||||
for (const folder of orderedFolders()) {
|
||||
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
|
||||
select.add(new Option(folder.name, folder.name));
|
||||
@@ -340,7 +340,7 @@ function renderBulkToolbar() {
|
||||
const selected = selectedMessages();
|
||||
const toolbar = $("#bulk-toolbar");
|
||||
toolbar.classList.toggle("hidden", !state.selectionMode);
|
||||
$("#bulk-count").textContent = `${selected.length} selected`;
|
||||
$("#bulk-count").textContent = fmtText(selected.length === 1 ? "{count} selected" : "{count} selected_plural", { count: selected.length });
|
||||
const selectionUniverse = state.searchAllResults || state.messages;
|
||||
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
|
||||
$("#bulk-select-all").checked = allLoaded;
|
||||
@@ -434,7 +434,7 @@ async function selectAllSearchResults() {
|
||||
function renderTagOptions() {
|
||||
const select = $("#bulk-tag-select");
|
||||
if (!select) return;
|
||||
select.replaceChildren(new Option("Add label…", ""));
|
||||
select.replaceChildren(new Option(trText("Add label…"), ""));
|
||||
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
|
||||
}
|
||||
|
||||
@@ -470,8 +470,8 @@ async function loadTagMessages(tag) {
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
renderMessages();
|
||||
$("#folder-title").textContent = `Label: ${tag.name}`;
|
||||
$("#message-count").textContent = `${state.total} results`;
|
||||
$("#folder-title").textContent = fmtText("Label: {name}", { name: tag.name });
|
||||
$("#message-count").textContent = fmtText(state.total === 1 ? "{count} result" : "{count} results", { count: state.total });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
} else if (state.imapSyncFolder === state.folder) {
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
}
|
||||
$("#connection-label").textContent = "Connected";
|
||||
$("#connection-label").textContent = trText("Connected");
|
||||
renderMessages();
|
||||
void applySnoozeHiding();
|
||||
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
|
||||
@@ -120,9 +120,9 @@ async function loadMessages(reset = false, options = {}) {
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
$("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error";
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
$("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable";
|
||||
$("#connection-label").textContent = trText(error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error");
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(trText(error.message))}</div>`;
|
||||
$("#message-count").textContent = state.messages.length ? fmtText("{count} messages · cached view", { count: state.total }) : trText("Unavailable");
|
||||
updateMessageLoadControls();
|
||||
return false;
|
||||
} finally {
|
||||
@@ -330,12 +330,12 @@ function renderMessages() {
|
||||
$("#folder-title").textContent = state.folder;
|
||||
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
|
||||
const displayTotal = !state.searchContext && Number.isFinite(folderTotal) ? folderTotal : state.total;
|
||||
$("#message-count").textContent = `${displayTotal} ${displayTotal === 1 ? "message" : "messages"}`;
|
||||
$("#message-count").textContent = fmtText(displayTotal === 1 ? "{count} message" : "{count} messages", { count: displayTotal });
|
||||
const list = $("#message-list");
|
||||
const highlightTerms = activeSearchHighlightTerms();
|
||||
list.classList.toggle("selection-mode", state.selectionMode);
|
||||
list.replaceChildren();
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(trText(state.searchContext ? "No matching messages." : "No messages in this folder."))}</div>`;
|
||||
for (const message of state.messages) {
|
||||
const folder = message.folder || state.folder;
|
||||
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
|
||||
@@ -354,7 +354,7 @@ function renderMessages() {
|
||||
checkbox.className = "message-select";
|
||||
checkbox.checked = checked;
|
||||
checkbox.disabled = !state.selectionMode;
|
||||
checkbox.setAttribute("aria-label", "Select message");
|
||||
checkbox.setAttribute("aria-label", trText("Select message"));
|
||||
checkbox.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
@@ -371,7 +371,7 @@ function renderMessages() {
|
||||
if (message.has_attachments) {
|
||||
const attachmentIcon = el("span", "message-static-icon attachment-indicator");
|
||||
attachmentIcon.innerHTML = svgIcon("paperclip");
|
||||
attachmentIcon.title = "Has attachments";
|
||||
attachmentIcon.title = trText("Has attachments");
|
||||
icons.append(attachmentIcon);
|
||||
}
|
||||
const quick = el("span", "message-quick-actions");
|
||||
@@ -397,7 +397,7 @@ function renderMessages() {
|
||||
});
|
||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
fullscreenButton.type = "button";
|
||||
fullscreenButton.title = "Open full screen";
|
||||
fullscreenButton.title = trText("Open full screen");
|
||||
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||
fullscreenButton.innerHTML = svgIcon("external-link");
|
||||
fullscreenButton.addEventListener("click", async (event) => {
|
||||
@@ -538,8 +538,8 @@ function setReaderFullscreen(enabled, options = {}) {
|
||||
reader.classList.add("reader-fullscreen");
|
||||
document.body.classList.add("reader-fullscreen-open");
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
button.title = "Exit full screen";
|
||||
button.setAttribute("aria-label", "Exit message full screen");
|
||||
button.title = trText("Exit full screen");
|
||||
button.setAttribute("aria-label", trText("Exit message full screen"));
|
||||
if (options.pushHistory !== false && state.currentDetail) {
|
||||
history.pushState({ rustboxMailFullscreen: true, folder: state.currentDetail.folder, uid: state.currentDetail.uid }, "", readerFullscreenUrl());
|
||||
state.readerFullscreenHistory = true;
|
||||
@@ -548,8 +548,8 @@ function setReaderFullscreen(enabled, options = {}) {
|
||||
reader.classList.remove("reader-fullscreen");
|
||||
document.body.classList.remove("reader-fullscreen-open");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.title = "Open full screen";
|
||||
button.setAttribute("aria-label", "Open message full screen");
|
||||
button.title = trText("Open full screen");
|
||||
button.setAttribute("aria-label", trText("Open message full screen"));
|
||||
state.readerFullscreenHistory = false;
|
||||
if (options.keepUrl !== true && new URL(location.href).searchParams.get("mail_fullscreen") === "1") {
|
||||
const url = new URL(location.href);
|
||||
@@ -617,7 +617,7 @@ async function openMessage(folder, uid) {
|
||||
updateMessageNavigation();
|
||||
$("#reader-empty").classList.add("hidden");
|
||||
$("#reader-content").classList.remove("hidden");
|
||||
$("#reader-subject").textContent = "Loading…";
|
||||
$("#reader-subject").textContent = trText("Loading…");
|
||||
$("#message-body").replaceChildren();
|
||||
$("#reader").classList.add("mobile-open");
|
||||
try {
|
||||
@@ -631,7 +631,7 @@ async function openMessage(folder, uid) {
|
||||
renderMoveOptions();
|
||||
scheduleMarkRead(detail);
|
||||
} catch (error) {
|
||||
$("#reader-subject").textContent = "Unable to open message";
|
||||
$("#reader-subject").textContent = trText("Unable to open message");
|
||||
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
} finally {
|
||||
@@ -667,14 +667,14 @@ function renderThread(detail) {
|
||||
const enabled = state.mailPrefs.thread_mode !== "flat";
|
||||
section.classList.toggle("hidden", !enabled || thread.length <= 1);
|
||||
if (!enabled || thread.length <= 1) return;
|
||||
$("#thread-title").textContent = `Thread (${thread.length})`;
|
||||
$("#thread-title").textContent = fmtText("Thread ({count})", { count: thread.length });
|
||||
list.classList.toggle("hidden", !state.threadExpanded);
|
||||
$("#thread-toggle").classList.toggle("expanded", state.threadExpanded);
|
||||
$("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded));
|
||||
for (const item of thread) {
|
||||
const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
|
||||
button.innerHTML = `<span>${escapeHtml(item.from_name || item.from_address || trText("Unknown"))} — ${escapeHtml(item.subject || trText("(no subject)"))}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
|
||||
button.addEventListener("click", () => openMessage(item.folder, item.uid));
|
||||
list.append(button);
|
||||
}
|
||||
@@ -714,7 +714,7 @@ function renderReaderRecipients(detail) {
|
||||
for (const entry of entries) if (entry?.address) recipients.push({ prefix, entry });
|
||||
}
|
||||
if (!recipients.length) {
|
||||
host.append(el("div", "recipient-line", "to me"));
|
||||
host.append(el("div", "recipient-line", trText("to me")));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -794,8 +794,8 @@ function renderMessageDetail(detail) {
|
||||
const add = el("button", "icon-button small");
|
||||
add.innerHTML = svgIcon("calendar-days");
|
||||
add.type = "button";
|
||||
add.title = "Add to calendar";
|
||||
add.setAttribute("aria-label", "Add calendar attachment");
|
||||
add.title = trText("Add to calendar");
|
||||
add.setAttribute("aria-label", trText("Add calendar attachment"));
|
||||
add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment));
|
||||
item.append(add);
|
||||
}
|
||||
@@ -803,8 +803,8 @@ function renderMessageDetail(detail) {
|
||||
const add = el("button", "icon-button small");
|
||||
add.innerHTML = svgIcon("user-round");
|
||||
add.type = "button";
|
||||
add.title = "Import contact";
|
||||
add.setAttribute("aria-label", "Import vCard attachment");
|
||||
add.title = trText("Import contact");
|
||||
add.setAttribute("aria-label", trText("Import vCard attachment"));
|
||||
add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment));
|
||||
item.append(add);
|
||||
}
|
||||
@@ -813,7 +813,7 @@ function renderMessageDetail(detail) {
|
||||
if ((detail.attachments || []).length > 1) {
|
||||
const all = el("button", "attachment-download-all");
|
||||
all.type = "button";
|
||||
all.innerHTML = `${svgIcon("package")}<span>Download all as ZIP</span>`;
|
||||
all.innerHTML = `${svgIcon("package")}<span>${escapeHtml(trText("Download all as ZIP"))}</span>`;
|
||||
all.addEventListener("click", () => downloadAllAttachmentsZip(detail));
|
||||
attachments.prepend(all);
|
||||
}
|
||||
@@ -849,15 +849,15 @@ function renderPgpReaderState(detail) {
|
||||
const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : "";
|
||||
const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : "";
|
||||
const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : "";
|
||||
if (processed.encrypted && processed.verified) { title.textContent = "OpenPGP decrypted + verified"; status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; }
|
||||
else if (processed.encrypted) { title.textContent = "OpenPGP decrypted"; status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; }
|
||||
else if (processed.verified) { title.textContent = "OpenPGP signature verified"; status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; }
|
||||
if (processed.encrypted && processed.verified) { title.textContent = trText("OpenPGP decrypted + verified"); status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; }
|
||||
else if (processed.encrypted) { title.textContent = trText("OpenPGP decrypted"); status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; }
|
||||
else if (processed.verified) { title.textContent = trText("OpenPGP signature verified"); status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; }
|
||||
else {
|
||||
title.textContent = "OpenPGP signature not verified";
|
||||
status.textContent = "No matching public key verified this signature.";
|
||||
title.textContent = trText("OpenPGP signature not verified");
|
||||
status.textContent = trText("No matching public key verified this signature.");
|
||||
if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden");
|
||||
}
|
||||
$("#pgp-reader-process").textContent = "Reprocess";
|
||||
$("#pgp-reader-process").textContent = trText("Reprocess");
|
||||
}
|
||||
|
||||
async function processCurrentPgpMessage() {
|
||||
@@ -1431,7 +1431,7 @@ async function openAttachment(folder, uid, attachment) {
|
||||
header.append(el("strong", "", attachment.filename || "Attachment preview"));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Close preview");
|
||||
close.setAttribute("aria-label", trText("Close preview"));
|
||||
close.innerHTML = svgIcon("x");
|
||||
header.append(close);
|
||||
const body = el("div", "attachment-preview-body");
|
||||
@@ -1473,10 +1473,10 @@ async function openAttachment(folder, uid, attachment) {
|
||||
}
|
||||
|
||||
const footer = el("footer", "dialog-actions");
|
||||
const download = el("button", "button quiet", "Download");
|
||||
const download = el("button", "button quiet", trText("Download"));
|
||||
download.type = "button";
|
||||
download.addEventListener("click", () => downloadAttachment(folder, uid, attachment));
|
||||
const done = el("button", "button primary", "Close");
|
||||
const done = el("button", "button primary", trText("Close"));
|
||||
done.type = "button";
|
||||
const dismiss = () => { if (dialog.open) dialog.close(); };
|
||||
close.addEventListener("click", dismiss);
|
||||
|
||||
@@ -924,7 +924,7 @@ function renderResponseTemplates() {
|
||||
const select = $("#compose-response-template");
|
||||
if (select) {
|
||||
const current = select.value;
|
||||
select.replaceChildren(new Option("Response…", ""));
|
||||
select.replaceChildren(new Option(trText("Response…"), ""));
|
||||
for (const template of state.responseTemplates) select.add(new Option(template.name, String(template.id)));
|
||||
select.value = state.responseTemplates.some((template) => String(template.id) === current) ? current : "";
|
||||
const insert = $("#compose-response-insert");
|
||||
@@ -933,7 +933,7 @@ function renderResponseTemplates() {
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!state.responseTemplates.length) {
|
||||
list.append(el("span", "muted", "No saved responses."));
|
||||
list.append(el("span", "muted", trText("No saved responses.")));
|
||||
return;
|
||||
}
|
||||
for (const template of state.responseTemplates) {
|
||||
@@ -941,10 +941,10 @@ function renderResponseTemplates() {
|
||||
const meta = el("div", "response-template-meta");
|
||||
meta.append(el("strong", "", template.name), el("span", "muted", template.format === "html" ? "HTML" : "Plain text"));
|
||||
const actions = el("div", "inline-actions");
|
||||
const edit = el("button", "button quiet small", "Edit");
|
||||
const edit = el("button", "button quiet small", trText("Edit"));
|
||||
edit.type = "button";
|
||||
edit.addEventListener("click", () => editResponseTemplate(template));
|
||||
const remove = el("button", "button quiet small danger", "Delete");
|
||||
const remove = el("button", "button quiet small danger", trText("Delete"));
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => deleteResponseTemplate(template));
|
||||
actions.append(edit, remove);
|
||||
|
||||
@@ -196,10 +196,10 @@ function snoozePresetOptions() {
|
||||
weekend.setDate(weekend.getDate() + daysToSaturday);
|
||||
weekend.setHours(9, 0, 0, 0);
|
||||
return [
|
||||
{ label: `Later today (${formatDate(laterToday.toISOString(), true)})`, value: String(Math.floor(laterToday.getTime() / 1000)) },
|
||||
{ label: `Tomorrow (${formatDate(tomorrow.toISOString(), true)})`, value: String(Math.floor(tomorrow.getTime() / 1000)) },
|
||||
{ label: `This weekend (${formatDate(weekend.toISOString(), true)})`, value: String(Math.floor(weekend.getTime() / 1000)) },
|
||||
{ label: `Next week (${formatDate(nextWeek.toISOString(), true)})`, value: String(Math.floor(nextWeek.getTime() / 1000)) },
|
||||
{ label: fmtText("Later today ({time})", { time: formatDate(laterToday.toISOString(), true) }), value: String(Math.floor(laterToday.getTime() / 1000)) },
|
||||
{ label: fmtText("Tomorrow ({time})", { time: formatDate(tomorrow.toISOString(), true) }), value: String(Math.floor(tomorrow.getTime() / 1000)) },
|
||||
{ label: fmtText("This weekend ({time})", { time: formatDate(weekend.toISOString(), true) }), value: String(Math.floor(weekend.getTime() / 1000)) },
|
||||
{ label: fmtText("Next week ({time})", { time: formatDate(nextWeek.toISOString(), true) }), value: String(Math.floor(nextWeek.getTime() / 1000)) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ async function snoozeCurrent(wakeAtEpochSeconds) {
|
||||
});
|
||||
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
|
||||
await refreshFolderListOnly();
|
||||
toast(`Snoozed until ${formatDate(new Date(wakeAtEpochSeconds * 1000).toISOString(), true)}`, "success");
|
||||
toast(fmtText("Snoozed until {time}", { time: formatDate(new Date(wakeAtEpochSeconds * 1000).toISOString(), true) }), "success");
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
}
|
||||
@@ -243,31 +243,31 @@ async function showSnoozedMessages() {
|
||||
const dialog = el("dialog", "dialog app-prompt-dialog");
|
||||
const form = el("div", "dialog-card app-prompt-card");
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", "Snoozed messages"));
|
||||
header.append(el("strong", "", trText("Snoozed messages")));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Close");
|
||||
close.setAttribute("aria-label", trText("Close"));
|
||||
close.innerHTML = svgIcon("x");
|
||||
close.addEventListener("click", () => dialog.close());
|
||||
header.append(close);
|
||||
const body = el("div", "app-prompt-body");
|
||||
if (!items.length) {
|
||||
body.append(el("p", "app-prompt-message", "No snoozed messages."));
|
||||
body.append(el("p", "app-prompt-message", trText("No snoozed messages.")));
|
||||
} else {
|
||||
const list = el("div", "snoozed-message-list");
|
||||
for (const item of items) {
|
||||
const row = el("div", "snoozed-message-row");
|
||||
const info = el("div", "snoozed-message-info");
|
||||
info.append(
|
||||
el("strong", "", item.subject || "(no subject)"),
|
||||
el("strong", "", item.subject || trText("(no subject)")),
|
||||
el("span", "", item.from_name || item.from_address || ""),
|
||||
el("span", "", `Wakes ${formatDate(new Date(item.wake_at * 1000).toISOString(), true)}`),
|
||||
el("span", "", fmtText("Wakes {time}", { time: formatDate(new Date(item.wake_at * 1000).toISOString(), true) })),
|
||||
);
|
||||
const actions = el("div", "snoozed-message-actions");
|
||||
const openBtn = el("button", "button quiet", "Open");
|
||||
const openBtn = el("button", "button quiet", trText("Open"));
|
||||
openBtn.type = "button";
|
||||
openBtn.addEventListener("click", async () => { dialog.close(); await openMessage(item.message_folder, item.message_uid); });
|
||||
const wakeBtn = el("button", "button quiet", "Unsnooze now");
|
||||
const wakeBtn = el("button", "button quiet", trText("Unsnooze now"));
|
||||
wakeBtn.type = "button";
|
||||
wakeBtn.addEventListener("click", async () => {
|
||||
if (await unsnoozeMessage(item.message_folder, item.message_uid)) {
|
||||
@@ -282,7 +282,7 @@ async function showSnoozedMessages() {
|
||||
body.append(list);
|
||||
}
|
||||
const actions = el("footer", "dialog-actions app-prompt-actions");
|
||||
const okBtn = el("button", "button primary", "Close");
|
||||
const okBtn = el("button", "button primary", trText("Close"));
|
||||
okBtn.type = "button";
|
||||
okBtn.addEventListener("click", () => dialog.close());
|
||||
actions.append(okBtn);
|
||||
@@ -457,11 +457,11 @@ async function performBulk(action, targetFolder = null) {
|
||||
renderBulkToolbar();
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" };
|
||||
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: trText("Archived"), read: trText("Marked read"), unread: trText("Marked unread"), flag: trText("Flagged"), unflag: trText("Unflagged"), delete: trText("Deleted"), move: trText("Moved"), copy: trText("Copied") };
|
||||
const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null;
|
||||
toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction);
|
||||
toast(fmtText(completed === 1 ? "{action}: {count} message" : "{action}: {count} messages", { action: bulkMessages[action] || trText("Updated"), count: completed }), "success", undoAction ? 10000 : null, undoAction);
|
||||
} catch (error) {
|
||||
toast(`${completed}/${items.length} completed: ${error.message}`, "error");
|
||||
toast(fmtText("{done}/{total} completed: {error}", { done: completed, total: items.length, error: error.message }), "error");
|
||||
state.searchAllResults = null;
|
||||
await refreshCurrentMessageView();
|
||||
} finally {
|
||||
@@ -508,7 +508,7 @@ async function runSearch(params, label = "Search", options = {}) {
|
||||
const queryString = cleanParams.toString();
|
||||
if (!queryString) return;
|
||||
$("#folder-title").textContent = label;
|
||||
$("#message-list").innerHTML = `<div class="empty-list">Searching…</div>`;
|
||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(trText("Searching…"))}</div>`;
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
state.searchContext = { params: queryString, label, scrollTop: Number(options.restoreScrollTop) || 0 };
|
||||
@@ -520,7 +520,7 @@ async function runSearch(params, label = "Search", options = {}) {
|
||||
state.total = data.total_count || state.messages.length;
|
||||
renderMessages();
|
||||
$("#folder-title").textContent = label;
|
||||
$("#message-count").textContent = `${state.total} result${state.total === 1 ? "" : "s"}`;
|
||||
$("#message-count").textContent = fmtText(state.total === 1 ? "{count} result" : "{count} results", { count: state.total });
|
||||
const restoreScrollTop = Number(options.restoreScrollTop) || 0;
|
||||
if (restoreScrollTop > 0) requestAnimationFrame(() => { $("#message-list").scrollTop = restoreScrollTop; });
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
@@ -540,7 +540,7 @@ async function searchMail(event) {
|
||||
function populateAdvancedSearchFolders() {
|
||||
const select = $("#search-folder");
|
||||
const current = select.value;
|
||||
select.replaceChildren(new Option("All folders", ""));
|
||||
select.replaceChildren(new Option(trText("All folders"), ""));
|
||||
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
|
||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||
}
|
||||
@@ -598,12 +598,12 @@ async function loadSavedSearches() {
|
||||
function renderSavedSearches() {
|
||||
const list = $("#saved-search-list");
|
||||
list.replaceChildren();
|
||||
if (!state.savedSearches.length) { list.append(el("div", "empty-list", "No saved searches")); return; }
|
||||
if (!state.savedSearches.length) { list.append(el("div", "empty-list", trText("No saved searches"))); return; }
|
||||
for (const saved of state.savedSearches) {
|
||||
const row = el("div", "saved-search-item");
|
||||
const name = el("strong", "", saved.name);
|
||||
const run = el("button", "button quiet", "Run"); run.type = "button";
|
||||
const remove = el("button", "button quiet danger", "Delete"); remove.type = "button";
|
||||
const run = el("button", "button quiet", trText("Run")); run.type = "button";
|
||||
const remove = el("button", "button quiet danger", trText("Delete")); remove.type = "button";
|
||||
run.addEventListener("click", async () => { $("#advanced-search-dialog").close(); await runSearch(new URLSearchParams(saved.query_string), `Search: ${saved.name}`); });
|
||||
remove.addEventListener("click", async () => {
|
||||
if (!await appConfirm(`Delete saved search “${saved.name}”?`, { title: "Delete saved search", confirmLabel: "Delete", danger: true })) return;
|
||||
@@ -896,8 +896,8 @@ function showFolderProperties(folder) {
|
||||
dialog.className = "dialog";
|
||||
const card = el("div", "dialog-card folder-properties-card");
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", "Folder properties"));
|
||||
const close = el("button", "button quiet", "Close"); close.type = "button"; close.addEventListener("click", () => dialog.close());
|
||||
header.append(el("strong", "", trText("Folder properties")));
|
||||
const close = el("button", "button quiet", trText("Close")); close.type = "button"; close.addEventListener("click", () => dialog.close());
|
||||
header.append(close);
|
||||
const body = el("div", "folder-properties-list");
|
||||
const rows = [
|
||||
@@ -1059,14 +1059,14 @@ async function showMessageSource() {
|
||||
dialog.dataset.i18nScope = "";
|
||||
const card = el("div", "dialog-card source-card");
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", "Message source"));
|
||||
const close = el("button", "button quiet", "Close"); close.type = "button"; close.addEventListener("click", () => dialog.close());
|
||||
header.append(el("strong", "", trText("Message source")));
|
||||
const close = el("button", "button quiet", trText("Close")); close.type = "button"; close.addEventListener("click", () => dialog.close());
|
||||
header.append(close);
|
||||
const pre = el("pre", "message-source-content"); pre.textContent = source;
|
||||
const actions = el("footer", "dialog-actions");
|
||||
const copy = el("button", "button quiet", "Copy source"); copy.type = "button";
|
||||
const copy = el("button", "button quiet", trText("Copy source")); copy.type = "button";
|
||||
copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(source); toast("Message source copied"); } catch (_) { toast("Could not copy source", "error"); } });
|
||||
const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml);
|
||||
const download = el("button", "button primary", trText("Download .eml")); download.type = "button"; download.addEventListener("click", downloadEml);
|
||||
actions.append(copy, download);
|
||||
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
|
||||
translateScopedUiText(state.prefs.language);
|
||||
@@ -1326,7 +1326,7 @@ async function prepareQuickFilterAdvanced(config) {
|
||||
$("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : "";
|
||||
$("#sieve-builder-extra-actions").replaceChildren();
|
||||
updateSieveBuilder();
|
||||
$("#sieve-result").textContent = "Quick-filter values loaded. Review the rule, add it, validate, then save.";
|
||||
$("#sieve-result").textContent = trText("Quick-filter values loaded. Review the rule, add it, validate, then save.");
|
||||
showSieveRuleDialog("new");
|
||||
}
|
||||
|
||||
@@ -1359,21 +1359,21 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
const form = el("form", "dialog-card quick-filter-card");
|
||||
form.method = "dialog";
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", "Create filter from message"));
|
||||
const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", "Close");
|
||||
header.append(el("strong", "", trText("Create filter from message")));
|
||||
const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", trText("Close"));
|
||||
header.append(close);
|
||||
const body = el("div", "quick-filter-body");
|
||||
const grid = el("div", "quick-filter-grid");
|
||||
|
||||
const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", labelText), control); return label; };
|
||||
const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", trText(labelText)), control); return label; };
|
||||
const condition = document.createElement("select");
|
||||
condition.add(new Option("Sender address", "sender"));
|
||||
condition.add(new Option("Sender domain", "domain"));
|
||||
condition.add(new Option("Subject", "subject"));
|
||||
condition.add(new Option(trText("Sender address"), "sender"));
|
||||
condition.add(new Option(trText("Sender domain"), "domain"));
|
||||
condition.add(new Option(trText("Subject"), "subject"));
|
||||
condition.value = source.kind;
|
||||
const match = document.createElement("select");
|
||||
match.add(new Option("is exactly", "is"));
|
||||
match.add(new Option("contains", "contains"));
|
||||
match.add(new Option(trText("is exactly"), "is"));
|
||||
match.add(new Option(trText("contains"), "contains"));
|
||||
match.value = source.match;
|
||||
const value = document.createElement("input"); value.value = source.value; value.required = true;
|
||||
const action = document.createElement("select");
|
||||
@@ -1385,7 +1385,7 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
["Discard", "discard", []],
|
||||
["Keep", "keep", []],
|
||||
].forEach(([label, actionValue, requires]) => {
|
||||
const option = new Option(label, actionValue);
|
||||
const option = new Option(trText(label), actionValue);
|
||||
option.disabled = requires.some((requirement) => !extensions.has(requirement));
|
||||
action.add(option);
|
||||
});
|
||||
@@ -1395,8 +1395,8 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
target.value = defaultTarget;
|
||||
const script = document.createElement("select");
|
||||
const scriptNames = new Set(state.sieveScripts.map((item) => item.name));
|
||||
for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? " · active" : ""}`, item.name));
|
||||
if (!scriptNames.has("rustbox")) script.add(new Option("rustbox · new", "rustbox"));
|
||||
for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? ` · ${trText("active")}` : ""}`, item.name));
|
||||
if (!scriptNames.has("rustbox")) script.add(new Option(`rustbox · ${trText("new")}`, "rustbox"));
|
||||
script.value = defaultScript;
|
||||
const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true;
|
||||
const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags");
|
||||
@@ -1412,10 +1412,10 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
);
|
||||
body.append(grid);
|
||||
const toggles = el("div", "appearance-toggles quick-filter-toggles");
|
||||
const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", "Stop processing after this rule"));
|
||||
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read"));
|
||||
if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags";
|
||||
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving"));
|
||||
const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", trText("Stop processing after this rule")));
|
||||
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", trText("Also mark matching mail as read")));
|
||||
if (markRead.disabled) readLabel.title = trText("Server does not advertise imap4flags");
|
||||
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", trText("Activate selected script after saving")));
|
||||
const applyLabel = el("label", "checkbox-row"); applyLabel.append(applyExisting, el("span", "", fmtText("Also apply to messages already in “{folder}”", { folder: sourceFolder })));
|
||||
toggles.append(stopLabel, readLabel, activateLabel, applyLabel);
|
||||
body.append(toggles);
|
||||
@@ -1423,9 +1423,9 @@ async function createFilterFromCurrentMessage(kind = "sender") {
|
||||
const hint = el("div", "muted quick-filter-hint"); body.append(hint);
|
||||
|
||||
const actions = el("footer", "dialog-actions");
|
||||
const cancel = el("button", "button quiet", "Cancel"); cancel.type = "button";
|
||||
const advanced = el("button", "button quiet", "Advanced editor"); advanced.type = "button";
|
||||
const save = el("button", "button primary", "Create filter"); save.type = "submit";
|
||||
const cancel = el("button", "button quiet", trText("Cancel")); cancel.type = "button";
|
||||
const advanced = el("button", "button quiet", trText("Advanced editor")); advanced.type = "button";
|
||||
const save = el("button", "button primary", trText("Create filter")); save.type = "submit";
|
||||
actions.append(cancel, advanced, save);
|
||||
form.append(header, body, actions); dialog.append(form); document.body.append(dialog);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ async function loadContactGroups() {
|
||||
function renderContactGroupFilter() {
|
||||
const select = $("#contact-group-filter");
|
||||
const current = select.value;
|
||||
select.replaceChildren(new Option("All contacts", ""));
|
||||
select.replaceChildren(new Option(trText("All contacts"), ""));
|
||||
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||
const hasGroup = Boolean(select.value);
|
||||
@@ -65,7 +65,7 @@ async function showContactDuplicates() {
|
||||
const list = $("#contact-duplicate-list");
|
||||
list.replaceChildren();
|
||||
if (!groups.length) {
|
||||
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
||||
list.append(el("div", "empty-list", trText("No duplicate contacts found")));
|
||||
}
|
||||
for (const group of groups) {
|
||||
const card = el("section", "duplicate-contact-group");
|
||||
@@ -76,7 +76,7 @@ async function showContactDuplicates() {
|
||||
const summary = el("div", "duplicate-contact-summary");
|
||||
summary.append(el("strong", "", contact.name || contact.email));
|
||||
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
||||
const keep = el("button", "button quiet", "Keep this");
|
||||
const keep = el("button", "button quiet", trText("Keep this"));
|
||||
keep.type = "button";
|
||||
keep.addEventListener("click", async () => {
|
||||
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
||||
@@ -156,7 +156,7 @@ function renderContactGroupMembership(contactId, selectedIds) {
|
||||
if (!list) return;
|
||||
const selected = new Set(selectedIds || []);
|
||||
list.replaceChildren();
|
||||
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
||||
if (!state.contactGroups.length) { list.append(el("span", "muted", trText("No groups"))); return; }
|
||||
for (const group of state.contactGroups) {
|
||||
const label = el("label", "checkbox-row");
|
||||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
||||
@@ -174,7 +174,7 @@ function renderContactGroupMembership(contactId, selectedIds) {
|
||||
function renderContacts() {
|
||||
const list = $("#contact-list");
|
||||
list.replaceChildren();
|
||||
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
||||
if (!state.contacts.length) list.append(el("div", "empty-list", trText("No contacts")));
|
||||
for (const contact of state.contacts) {
|
||||
const button = el("button", "contact-item");
|
||||
button.type = "button";
|
||||
|
||||
@@ -151,7 +151,7 @@ function renderCalendarMonthOrWeek(view) {
|
||||
if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today");
|
||||
const heading = el("button", "calendar-day-number", String(date.getDate()));
|
||||
heading.type = "button";
|
||||
heading.title = "Create event on this day";
|
||||
heading.title = trText("Create event on this day");
|
||||
heading.addEventListener("click", () => {
|
||||
const startDate = new Date(date); startDate.setHours(9, 0, 0, 0);
|
||||
const endDate = new Date(startDate.getTime() + 3600000);
|
||||
@@ -176,7 +176,7 @@ function renderCalendarAgenda() {
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(event);
|
||||
}
|
||||
if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; }
|
||||
if (!groups.size) { host.append(el("div", "empty-list", trText("No events in this range"))); return; }
|
||||
for (const [key, events] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const date = new Date(`${key}T12:00:00`);
|
||||
const section = el("section", "calendar-agenda-day");
|
||||
|
||||
@@ -72,18 +72,18 @@ function renderOpenPgp() {
|
||||
$("#pgp-discover").disabled = !discoveryEnabled;
|
||||
$("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first";
|
||||
}
|
||||
$("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`;
|
||||
$("#pgp-key-summary").textContent = fmtText(state.pgpKeys.length === 1 ? "{count} key · {privateCount} private" : "{count} keys · {privateCount} private", { count: state.pgpKeys.length, privateCount: state.pgpKeys.filter((key) => key.hasSecret).length });
|
||||
const list = $("#pgp-key-list");
|
||||
list.replaceChildren();
|
||||
if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet"));
|
||||
if (!state.pgpKeys.length) list.append(el("div", "empty-list", trText("No OpenPGP keys yet")));
|
||||
for (const key of state.pgpKeys) {
|
||||
const card = el("article", "pgp-key-card");
|
||||
const head = el("div", "pgp-key-head");
|
||||
const title = el("div");
|
||||
title.innerHTML = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}</small>`;
|
||||
title.innerHTML = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || trText("No email"))} · ${trText(key.hasSecret ? "private + public" : "public")} · ${escapeHtml(trText(key.source || "import"))}</small>`;
|
||||
const trust = document.createElement("select");
|
||||
trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`);
|
||||
for (const value of ["unknown", "verified", "trusted", "untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value));
|
||||
for (const value of ["unknown", "verified", "trusted", "untrusted"]) trust.add(new Option(trText(value[0].toUpperCase() + value.slice(1)), value));
|
||||
trust.value = key.trust || "unknown";
|
||||
trust.addEventListener("change", async () => {
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method: "PATCH", body: JSON.stringify({ trust: trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); }
|
||||
@@ -91,18 +91,18 @@ function renderOpenPgp() {
|
||||
});
|
||||
head.append(title, trust);
|
||||
const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint));
|
||||
fp.title = "Fingerprint";
|
||||
fp.title = trText("Fingerprint");
|
||||
const actions = el("div", "inline-actions");
|
||||
const pub = el("button", "button quiet small", "Export public"); pub.type = "button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub);
|
||||
const pub = el("button", "button quiet small", trText("Export public")); pub.type = "button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub);
|
||||
if (key.hasSecret) {
|
||||
const test = el("button", "button quiet small", "Test passphrase"); test.type = "button"; test.addEventListener("click", async () => {
|
||||
const test = el("button", "button quiet small", trText("Test passphrase")); test.type = "button"; test.addEventListener("click", async () => {
|
||||
const passphrase = await appPrompt("Test OpenPGP passphrase", { message: "Enter the passphrase for this private key. It is used only for this test and is not stored.", type: "password", confirmLabel: "Test" });
|
||||
if (passphrase === null) return;
|
||||
setBusy(test, true);
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method: "POST", body: JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); }
|
||||
catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); }
|
||||
}); actions.append(test);
|
||||
const change = el("button", "button quiet small", "Change passphrase"); change.type = "button"; change.addEventListener("click", async () => {
|
||||
const change = el("button", "button quiet small", trText("Change passphrase")); change.type = "button"; change.addEventListener("click", async () => {
|
||||
const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message: "Enter the current passphrase. Leave it empty if the key is currently unprotected.", type: "password", confirmLabel: "Continue" });
|
||||
if (currentPassphrase === null) return;
|
||||
const newPassphrase = await appPrompt("New OpenPGP passphrase", { message: "Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type: "password", confirmLabel: "Change" });
|
||||
@@ -112,9 +112,9 @@ function renderOpenPgp() {
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method: "POST", body: JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); }
|
||||
catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); }
|
||||
}); actions.append(change);
|
||||
const sec = el("button", "button quiet small", "Export private"); sec.type = "button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title: "Export private key", confirmLabel: "Export" })) downloadPgpKey(key, true); }); actions.append(sec);
|
||||
const sec = el("button", "button quiet small", trText("Export private")); sec.type = "button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title: "Export private key", confirmLabel: "Export" })) downloadPgpKey(key, true); }); actions.append(sec);
|
||||
}
|
||||
const del = el("button", "button quiet small danger", "Delete"); del.type = "button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title: "Delete OpenPGP key", confirmLabel: "Delete", danger: true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method: "DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message, "error"); } }); actions.append(del);
|
||||
const del = el("button", "button quiet small danger", trText("Delete")); del.type = "button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title: "Delete OpenPGP key", confirmLabel: "Delete", danger: true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method: "DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message, "error"); } }); actions.append(del);
|
||||
card.append(head, fp, actions);
|
||||
list.append(card);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ function renderIdentityPgpOptions(selectedFingerprint = null) {
|
||||
const select = $("#identity-pgp-key");
|
||||
if (!select) return;
|
||||
const value = selectedFingerprint ?? select.value;
|
||||
select.replaceChildren(new Option("Automatic / none", ""));
|
||||
select.replaceChildren(new Option(trText("Automatic / none"), ""));
|
||||
for (const key of state.pgpKeys.filter((item) => item.hasSecret)) {
|
||||
select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint));
|
||||
}
|
||||
@@ -361,7 +361,7 @@ function renderComposeIdentities() {
|
||||
|
||||
select.replaceChildren();
|
||||
if (!hasAccountIdentity) {
|
||||
select.add(new Option(accountEmail ? `${accountEmail} (account)` : "Account default", ""));
|
||||
select.add(new Option(accountEmail ? `${accountEmail} (${trText("account")})` : trText("Account default"), ""));
|
||||
}
|
||||
for (const identity of state.identities) {
|
||||
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
|
||||
@@ -376,11 +376,11 @@ function renderIdentities() {
|
||||
const list = $("#identity-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet"));
|
||||
if (!state.identities.length) list.append(el("div", "empty-list", trText("No identities yet")));
|
||||
for (const identity of state.identities) {
|
||||
const button = el("button", "identity-item");
|
||||
button.type = "button";
|
||||
button.innerHTML = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}</small>`;
|
||||
button.innerHTML = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? ` · ${trText("default")}` : ""}</small>`;
|
||||
button.addEventListener("click", () => editIdentity(identity));
|
||||
list.append(button);
|
||||
}
|
||||
@@ -753,10 +753,10 @@ function populateSpamDefaultFolderSelect() {
|
||||
const select = $("#spam-default-folder");
|
||||
if (!select) return;
|
||||
const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
|
||||
select.replaceChildren(new Option("Automatic IMAP Junk/Spam", ""));
|
||||
select.replaceChildren(new Option(trText("Automatic IMAP Junk/Spam"), ""));
|
||||
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
|
||||
if (stored && !state.folders.some((folder) => folder.name === stored)) {
|
||||
const stale = new Option(`${stored} (not available in this account)`, stored);
|
||||
const stale = new Option(fmtText("{folder} (not available in this account)", { folder: stored }), stored);
|
||||
stale.disabled = true;
|
||||
select.add(stale);
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ async function resetAllSettings() {
|
||||
}
|
||||
|
||||
function capabilityBadge(label, supported) {
|
||||
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
|
||||
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${trText(supported ? "yes" : "no")}</strong></span>`;
|
||||
}
|
||||
|
||||
async function loadDiagnostics() {
|
||||
@@ -705,15 +705,15 @@ async function loadDiagnostics() {
|
||||
const wsStatus = $("#diagnostic-ws-status");
|
||||
const syncStatus = $("#diagnostic-sync-status");
|
||||
const searchStatus = $("#diagnostic-search-status");
|
||||
imapStatus.textContent = "Checking";
|
||||
smtpStatus.textContent = "Checking";
|
||||
sieveStatus.textContent = "Checking";
|
||||
syncStatus.textContent = "Checking";
|
||||
searchStatus.textContent = "Checking";
|
||||
wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected";
|
||||
imapStatus.textContent = trText("Checking");
|
||||
smtpStatus.textContent = trText("Checking");
|
||||
sieveStatus.textContent = trText("Checking");
|
||||
syncStatus.textContent = trText("Checking");
|
||||
searchStatus.textContent = trText("Checking");
|
||||
wsStatus.textContent = trText(state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected");
|
||||
try {
|
||||
const info = await request("/diagnostics/imap", { method: "GET" });
|
||||
imapStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
imapStatus.textContent = trText(info.connected ? "Connected" : "Unavailable");
|
||||
$("#diagnostic-imap-summary").innerHTML = [
|
||||
["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore],
|
||||
["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace],
|
||||
@@ -721,45 +721,59 @@ async function loadDiagnostics() {
|
||||
].map(([label, supported]) => capabilityBadge(label, supported)).join("");
|
||||
$("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported";
|
||||
} catch (error) {
|
||||
imapStatus.textContent = "Unavailable";
|
||||
imapStatus.textContent = trText("Unavailable");
|
||||
$("#diagnostic-imap-summary").textContent = error.message;
|
||||
$("#diagnostic-imap-caps").textContent = "";
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/smtp", { method: "GET" });
|
||||
smtpStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable";
|
||||
smtpStatus.textContent = trText(info.connected ? "Connected" : "Unavailable");
|
||||
$("#diagnostic-smtp-summary").textContent = trText(info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable");
|
||||
} catch (error) {
|
||||
smtpStatus.textContent = "Unavailable";
|
||||
smtpStatus.textContent = trText("Unavailable");
|
||||
$("#diagnostic-smtp-summary").textContent = error.message;
|
||||
}
|
||||
if (state.authPolicy.sieveEnabled !== false) {
|
||||
try {
|
||||
const info = await request("/sieve/status", { method: "GET" });
|
||||
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
sieveStatus.textContent = trText(info.connected ? "Connected" : "Unavailable");
|
||||
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
||||
} catch (error) {
|
||||
sieveStatus.textContent = "Unavailable";
|
||||
sieveStatus.textContent = trText("Unavailable");
|
||||
$("#diagnostic-sieve-summary").textContent = error.message;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/sync", { method: "GET" });
|
||||
syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready";
|
||||
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never";
|
||||
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never";
|
||||
$("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`;
|
||||
syncStatus.textContent = trText(info.unsyncedFolders ? "Partial" : "Ready");
|
||||
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : trText("never");
|
||||
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : trText("never");
|
||||
$("#diagnostic-sync-summary").textContent = fmtText("{folders} cached folders · {messages} cached messages · folder list {folderSync} · messages {messageSync}{pending} · {idleTasks} IDLE task(s) / {subscribers} subscriber(s)", {
|
||||
folders: info.cachedFolders,
|
||||
messages: info.cachedMessages,
|
||||
folderSync,
|
||||
messageSync,
|
||||
pending: info.unsyncedFolders ? fmtText(" · {count} folders awaiting message sync", { count: info.unsyncedFolders }) : "",
|
||||
idleTasks: info.idleTasks || 0,
|
||||
subscribers: info.idleSubscribers || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
syncStatus.textContent = "Unavailable";
|
||||
syncStatus.textContent = trText("Unavailable");
|
||||
$("#diagnostic-sync-summary").textContent = error.message;
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/search", { method: "GET" });
|
||||
searchStatus.textContent = "Ready";
|
||||
const bodyCacheState = info.deepIndexEnabled ? "body cache enabled" : "body cache disabled";
|
||||
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies · ${bodyCacheState}${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
|
||||
searchStatus.textContent = trText("Ready");
|
||||
const bodyCacheState = trText(info.deepIndexEnabled ? "body cache enabled" : "body cache disabled");
|
||||
$("#diagnostic-search-summary").textContent = fmtText("{indexed} indexed · {messages} cached messages · {bodies} cached bodies · {bodyCache}{excluded}", {
|
||||
indexed: info.indexedDocuments,
|
||||
messages: info.cachedMessages,
|
||||
bodies: info.cachedBodies,
|
||||
bodyCache: bodyCacheState,
|
||||
excluded: info.excludedMessages ? fmtText(" · {count} excluded", { count: info.excludedMessages }) : "",
|
||||
});
|
||||
} catch (error) {
|
||||
searchStatus.textContent = "Unavailable";
|
||||
searchStatus.textContent = trText("Unavailable");
|
||||
$("#diagnostic-search-summary").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -2,7 +2,7 @@
|
||||
async function loadSieve() {
|
||||
if (state.authPolicy.sieveEnabled === false) return;
|
||||
const status = $("#sieve-status");
|
||||
status.textContent = "Connecting…";
|
||||
status.textContent = trText("Connecting…");
|
||||
try {
|
||||
const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]);
|
||||
status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`;
|
||||
@@ -655,7 +655,7 @@ function moveParsedSieveRule(ruleId, delta) {
|
||||
editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end);
|
||||
state.sieveEditingRuleId = null;
|
||||
renderManagedSieveRules();
|
||||
$("#sieve-result").textContent = "Rule order changed — save to apply";
|
||||
$("#sieve-result").textContent = trText("Rule order changed — save to apply");
|
||||
}
|
||||
|
||||
function toggleSieveRuleEnabled(rule) {
|
||||
@@ -665,7 +665,7 @@ function toggleSieveRuleEnabled(rule) {
|
||||
editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd);
|
||||
state.sieveEditingRuleId = null;
|
||||
renderManagedSieveRules();
|
||||
$("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`;
|
||||
$("#sieve-result").textContent = fmtText("{name} {state} — save to apply", { name: rule.name, state: trText(rule.enabled ? "disabled" : "enabled") });
|
||||
}
|
||||
|
||||
async function deleteSieveRule(rule) {
|
||||
@@ -675,7 +675,7 @@ async function deleteSieveRule(rule) {
|
||||
state.sieveEditingRuleId = null;
|
||||
clearSieveRuleBuilder();
|
||||
renderManagedSieveRules();
|
||||
$("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`;
|
||||
$("#sieve-result").textContent = fmtText("Rule “{name}” removed — save to apply", { name: rule.name });
|
||||
}
|
||||
|
||||
function renderManagedSieveRules() {
|
||||
@@ -691,7 +691,7 @@ function renderManagedSieveRules() {
|
||||
}
|
||||
list.replaceChildren();
|
||||
if (!rules.length) {
|
||||
list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically."));
|
||||
list.append(el("div", "muted", trText("No named rules found. Rules using # rule:[name] are parsed automatically.")));
|
||||
return;
|
||||
}
|
||||
rules.forEach((rule, index) => {
|
||||
@@ -709,8 +709,8 @@ function renderManagedSieveRules() {
|
||||
const actions = el("div", "sieve-managed-rule-actions");
|
||||
const edit = el("button", "button quiet small", trText("Edit")); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
|
||||
const toggle = el("button", "button quiet small", trText(rule.enabled ? "Disable" : "Enable")); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
|
||||
const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = "Move rule up"; up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
|
||||
const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = "Move rule down"; down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
|
||||
const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = trText("Move rule up"); up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
|
||||
const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = trText("Move rule down"); down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
|
||||
const remove = el("button", "button quiet small danger", trText("Delete")); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule));
|
||||
const runNow = el("button", "button quiet small", trText("Apply now")); runNow.type = "button"; runNow.disabled = !rule.editable;
|
||||
runNow.title = trText("Apply this rule to messages that are already in a folder");
|
||||
@@ -723,7 +723,7 @@ function renderManagedSieveRules() {
|
||||
|
||||
function createSieveDynamicCondition() {
|
||||
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
|
||||
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
row.innerHTML = `<label><span>${escapeHtml(trText("Join"))}</span><select class="sieve-dynamic-logic"><option value="allof">${escapeHtml(trText("AND"))}</option><option value="anyof">${escapeHtml(trText("OR"))}</option></select></label><label><span>${escapeHtml(trText("Condition"))}</span><select class="sieve-dynamic-kind">${sieveOptionsHtml(sieveConditionKinds)}</select></label><label><span>${escapeHtml(trText("Match"))}</span><select class="sieve-dynamic-match"><option value="contains">${escapeHtml(trText("contains"))}</option><option value="is">${escapeHtml(trText("is exactly"))}</option><option value="matches">${escapeHtml(trText("wildcard matches"))}</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>${escapeHtml(trText("Header name"))}</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>${escapeHtml(trText("Value"))}</span><input class="sieve-dynamic-value" placeholder="${escapeHtml(trText("value"))}"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>${escapeHtml(trText("NOT / negate"))}</span></label><button class="button quiet small sieve-dynamic-remove" type="button">${escapeHtml(trText("Remove"))}</button>`;
|
||||
const refresh = () => { syncSieveRowValueFields(row); updateSieveBuilder(); };
|
||||
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
|
||||
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
|
||||
@@ -785,7 +785,7 @@ function buildSieveException(row) {
|
||||
|
||||
function createSieveDynamicAction() {
|
||||
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
|
||||
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action">${sieveOptionsHtml(sieveActionKinds)}</select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||||
row.innerHTML = `<label><span>${escapeHtml(trText("Action"))}</span><select class="sieve-dynamic-action">${sieveOptionsHtml(sieveActionKinds)}</select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">${escapeHtml(trText("Folder"))}</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="${escapeHtml(trText("Archive"))}"></label><button class="button quiet small sieve-dynamic-remove" type="button">${escapeHtml(trText("Remove"))}</button>`;
|
||||
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
|
||||
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
|
||||
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
|
||||
@@ -910,7 +910,7 @@ function renderSieveBuilderPreview() {
|
||||
} catch (error) {
|
||||
preview.textContent = error.message;
|
||||
code.textContent = "";
|
||||
statePill.textContent = "Incomplete";
|
||||
statePill.textContent = trText("Incomplete");
|
||||
statePill.className = "sieve-status-pill error";
|
||||
}
|
||||
}
|
||||
@@ -922,7 +922,7 @@ function updateSieveBuilder() {
|
||||
const kind = $(`#sieve-builder-condition${suffix}`).value;
|
||||
const value = $(`#sieve-builder-value${suffix}`);
|
||||
value.closest("label")?.classList.toggle("hidden", valuelessSieveKinds.has(kind));
|
||||
if (sizeSieveKinds.has(kind)) value.placeholder = "2M";
|
||||
if (sizeSieveKinds.has(kind)) value.placeholder = trText("2M");
|
||||
else value.placeholder = suffix ? trText("optional") : "example.com";
|
||||
if (!suffix) $("#sieve-builder-match").disabled = valuelessSieveKinds.has(kind) || sizeSieveKinds.has(kind);
|
||||
}
|
||||
@@ -1024,7 +1024,7 @@ async function appendSieveVacation() {
|
||||
const built = buildSieveVacationRule();
|
||||
const editor = $("#sieve-editor");
|
||||
editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
|
||||
$("#sieve-result").textContent = "Autoresponder appended";
|
||||
$("#sieve-result").textContent = trText("Autoresponder appended");
|
||||
await validateSieve();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
@@ -1060,11 +1060,11 @@ async function applySieveVisualRule(updateSelected) {
|
||||
function renderSieveScripts() {
|
||||
const list = $("#sieve-scripts");
|
||||
list.replaceChildren();
|
||||
if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts"));
|
||||
if (!state.sieveScripts.length) list.append(el("div", "empty-list", trText("No scripts")));
|
||||
for (const script of state.sieveScripts) {
|
||||
const button = el("button", `sieve-script ${script.active ? "active" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `<span>${escapeHtml(script.name)}</span><span>${script.active ? "ACTIVE" : ""}</span>`;
|
||||
button.innerHTML = `<span>${escapeHtml(script.name)}</span><span>${script.active ? escapeHtml(trText("ACTIVE")) : ""}</span>`;
|
||||
button.addEventListener("click", () => openSieveScript(script.name));
|
||||
list.append(button);
|
||||
}
|
||||
@@ -1078,7 +1078,7 @@ async function openSieveScript(name) {
|
||||
state.sieveEditingRuleId = null;
|
||||
clearSieveRuleBuilder();
|
||||
renderManagedSieveRules();
|
||||
$("#sieve-result").textContent = "Loaded";
|
||||
$("#sieve-result").textContent = trText("Loaded");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -1088,7 +1088,7 @@ function newSieveScript() {
|
||||
state.sieveEditingRuleId = null;
|
||||
clearSieveRuleBuilder();
|
||||
renderManagedSieveRules();
|
||||
$("#sieve-result").textContent = "New script";
|
||||
$("#sieve-result").textContent = trText("New script");
|
||||
$("#sieve-name").focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// Filter templates, missing-folder creation and "Apply now" for existing mail.
|
||||
|
||||
function fmtText(template, values = {}) {
|
||||
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- templates
|
||||
|
||||
const sieveRuleTemplates = [
|
||||
|
||||
Reference in new issue
Block a user