Productivity features, UI translations, local email translation

- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 20:00:31 +02:00
co-authored by Claude Opus 5.5
parent 463f96ad57
commit 8a1b9979e9
83 changed files with 8677 additions and 293 deletions
+89
View File
@@ -0,0 +1,89 @@
"""Sentence completion: phrases from the user's own sent mail, plus starters."""
from __future__ import annotations
from collections import Counter
from app.completion import build_phrases, choose, complete, html_to_text, own_text
def personal(*texts: str) -> dict[str, Counter[str]]:
out: dict[str, Counter[str]] = {}
for (ctx, cont), n in build_phrases(texts).items():
out.setdefault(ctx, Counter())[cont] = n
return out
def test_own_text_drops_quotes_and_signature() -> None:
for quote in (
"On Mon, 5 Oct 2026 Anna wrote:",
"W dniu 5.10.2026 Anna pisze:",
"Am 05.10.2026 um 10:00 schrieb Anna:",
"-----Original Message-----",
"From: Anna <anna@x.example>",
"--",
):
text = f"Sure, I'll send it today.\n\n{quote}\nOld text here."
assert own_text(text).strip() == "Sure, I'll send it today.", quote
assert own_text("Yes.\n> quoted line\nAgreed.") == "Yes.\nAgreed."
def test_only_repeated_phrases_are_kept() -> None:
phrases = build_phrases(
["I'll send the drawings by Friday.", "I'll send the drawings by Friday."]
)
assert phrases[("the", "drawings by Friday.")] == 2
once = build_phrases(["A private sentence written once."])
assert once == {}
def test_personal_phrases_complete_in_any_language() -> None:
mine = personal(
"I'll send the drawings by Friday.",
"I'll send the drawings by Friday.",
"Prześlę rysunki do piątku.",
"Prześlę rysunki do piątku.",
)
assert complete(mine, "I'll send the dr") == "awings by Friday."
assert complete(mine, "Prześlę rys") == "unki do piątku."
assert complete(mine, "Prześlę rysunki ") == "do piątku."
def test_starter_phrases_and_spacing() -> None:
assert complete({}, "I look forward ") == "to hearing from you."
assert complete({}, "I look forward") == " to hearing from you." # space added
assert complete({}, "Z pow") == "ażaniem,"
assert complete({}, "Mit freundlichen ") == "Grüßen"
def test_nothing_after_punctuation_or_on_an_empty_line() -> None:
assert complete({}, "Thanks,") is None
assert complete({}, "Done. ") is None
assert complete({}, "") is None
assert complete({}, " ") is None
def test_suggestion_stops_where_habits_part() -> None:
# "see you" is followed by "soon" or "tomorrow" equally: suggest only "you".
assert choose(Counter({"you": 4, "you soon": 2, "you tomorrow": 2})) == "you"
assert choose(Counter({"you": 4, "you soon": 4, "you soon then": 3})) == "you soon then"
def test_polish_gmail_quotes_and_own_signature_are_left_out() -> None:
text = (
"Dziękuję, prześlę jutro.\n"
"Imaginary Pixels – visual effects studio\n"
"pon., 5 paź 2026 o 10:00 Anna Nowak <anna@x.example> napisał(a):\n"
"> stary tekst"
)
sig = html_to_text(
"<div>Imaginary Pixels – visual effects studio</div><div>+48 602 691 231</div>"
)
assert own_text(text, [sig]).strip() == "Dziękuję, prześlę jutro."
def test_numbers_addresses_and_links_are_never_learned() -> None:
texts = ["Call me at +48 602 691 231 or write to me@x.example today."] * 3
learned = " ".join(f"{c} {n}" for (c, n) in build_phrases(texts))
assert "602" not in learned and "@" not in learned
assert ("call me", "at") in build_phrases(texts)