v0.2.26
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 14:03:43 +01:00
1 parent 99ee3385ac
commit 53f4812adc
14 files changed
+730 -192

No files matched your search

+489 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+47 -8
View File
@@ -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) {
+10 -10
View File
@@ -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"); }
}
+32 -32
View File
@@ -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);
+4 -4
View File
@@ -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);
+47 -47
View File
@@ -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);
+5 -5
View File
@@ -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";
+2 -2
View File
@@ -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");
+16 -16
View File
@@ -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);
}
+37 -23
View File
@@ -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
View File
@@ -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();
}
-4
View File
@@ -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 = [