Compose: spelling and grammar check with LanguageTool
A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by `make up`) checks what the user writes in the compose window, through POST /api/spellcheck — the browser never talks to the engine directly. - Marks are CSS highlights over the text, never written into the message: red wavy for spelling, blue for grammar/style. Clicking one shows the message, up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore. The popup, like the link popup, opens above its anchor when there is no room below. - Checked after a pause in typing; the signature and quoted original are skipped; marks on words just edited drop at once; a check overtaken by a newer one, by turning checking off, or by closing the window is discarded. - With "auto", text is checked in chunks of lines, each in its own detected language — LanguageTool settles on one language per request, so a mixed Polish/English message had every English word flagged as a Polish typo. - Settings > Appearance: on/off and language (Auto-detect or any of the engine's languages); the compose window's More menu overrides it for one message. The browser's own spellcheck steps aside while ours runs, and comes back when it is off or the server has no engine (503 from the endpoint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,3 +18,6 @@ LOG_LEVEL=DEBUG
|
||||
RSPAMD_URL=http://127.0.0.1:11333
|
||||
RSPAMD_CONTROLLER_URL=http://127.0.0.1:11334
|
||||
RSPAMD_CONTROLLER_PASSWORD=devpass
|
||||
|
||||
# Spelling/grammar check in the compose window (LanguageTool). Empty = off.
|
||||
LANGUAGETOOL_URL=http://127.0.0.1:8010
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
up: ## start services
|
||||
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch
|
||||
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool
|
||||
down: ## stop services (data kept)
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
|
||||
|
||||
@@ -23,6 +23,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.spellcheck import router as spellcheck_router
|
||||
from app.api.threads import router as threads_router
|
||||
from app.identity.routes import router as auth_router
|
||||
from app.ws import router as ws_router
|
||||
@@ -42,6 +43,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
avatar_router,
|
||||
plugins_router,
|
||||
admin_router,
|
||||
spellcheck_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -24,6 +25,10 @@ _ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
|
||||
_ALLOWED_INVITE_FROM = {"contacts", "anyone"}
|
||||
|
||||
|
||||
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
|
||||
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
|
||||
|
||||
|
||||
class ReminderPref(BaseModel):
|
||||
method: str = "alert" # alert = in-app notification, email = email me
|
||||
minutes: int = 0 # minutes before the event start (0 = at time of event)
|
||||
@@ -42,6 +47,10 @@ class SettingsOut(BaseModel):
|
||||
invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
|
||||
invitation_file: bool = False # also mark the invite message read + filed
|
||||
allowed_image_senders: list[str] = []
|
||||
# Spelling and grammar check in the compose window, and its language
|
||||
# ("auto" detects it per message).
|
||||
spellcheck_enabled: bool = True
|
||||
spellcheck_language: str = "auto"
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
@@ -56,6 +65,8 @@ class SettingsPatch(BaseModel):
|
||||
invitation_from: str | None = None
|
||||
invitation_calendar: str | None = None
|
||||
invitation_file: bool | None = None
|
||||
spellcheck_enabled: bool | None = None
|
||||
spellcheck_language: str | None = None
|
||||
|
||||
|
||||
class QuotaOut(BaseModel):
|
||||
@@ -113,6 +124,8 @@ def _settings_out(user: User) -> SettingsOut:
|
||||
invitation_calendar=str(s.get("invitation_calendar", "")),
|
||||
invitation_file=bool(s.get("invitation_file", False)),
|
||||
allowed_image_senders=sorted(allowed_image_senders(user)),
|
||||
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
|
||||
spellcheck_language=str(s.get("spellcheck_language", "auto")),
|
||||
)
|
||||
|
||||
|
||||
@@ -144,9 +157,7 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
||||
if body.default_calendar is not None:
|
||||
updates["default_calendar"] = body.default_calendar
|
||||
if body.default_reminders_timed is not None:
|
||||
updates["default_reminders_timed"] = [
|
||||
r.model_dump() for r in body.default_reminders_timed
|
||||
]
|
||||
updates["default_reminders_timed"] = [r.model_dump() for r in body.default_reminders_timed]
|
||||
if body.default_reminders_allday is not None:
|
||||
updates["default_reminders_allday"] = [
|
||||
r.model_dump() for r in body.default_reminders_allday
|
||||
@@ -159,6 +170,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
|
||||
updates["invitation_calendar"] = body.invitation_calendar
|
||||
if body.invitation_file is not None:
|
||||
updates["invitation_file"] = body.invitation_file
|
||||
if body.spellcheck_enabled is not None:
|
||||
updates["spellcheck_enabled"] = body.spellcheck_enabled
|
||||
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
|
||||
updates["spellcheck_language"] = body.spellcheck_language
|
||||
user.settings = updates
|
||||
await db.commit()
|
||||
return _settings_out(user)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Spelling and grammar check for the compose window, backed by LanguageTool.
|
||||
|
||||
The browser sends the text it wants checked here, never to LanguageTool
|
||||
itself: the engine stays on the internal network, only signed-in users reach
|
||||
it, and nothing leaves the server. When no engine is configured (or it is
|
||||
down), the check answers 503 and the compose window simply goes without.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
|
||||
router = APIRouter(prefix="/spellcheck", tags=["compose"])
|
||||
|
||||
_TIMEOUT = 10.0
|
||||
# A message body is checked as a whole; past this it is a document, not mail.
|
||||
MAX_TEXT = 20_000
|
||||
_MAX_SUGGESTIONS = 6
|
||||
# With "auto", LanguageTool settles on ONE language per request, so a message
|
||||
# mixing Polish and English paragraphs would have every English word flagged as
|
||||
# a Polish typo. Auto-detected text is therefore checked in chunks, each in its
|
||||
# own language. A chunk is at least this many characters of consecutive lines:
|
||||
# a greeting alone ("Witam,") is too short to detect reliably, so it rides with
|
||||
# the lines after it.
|
||||
_MIN_CHUNK = 60
|
||||
_PARALLEL = 4
|
||||
|
||||
|
||||
class CheckIn(BaseModel):
|
||||
text: str = Field(max_length=MAX_TEXT)
|
||||
# "auto" or a LanguageTool code ("pl-PL", "en-GB", "de-DE" ...).
|
||||
language: str = Field(default="auto", max_length=16)
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
offset: int
|
||||
length: int
|
||||
message: str
|
||||
short_message: str
|
||||
replacements: list[str]
|
||||
# spelling | grammar | style: what the underline colour follows.
|
||||
kind: str
|
||||
rule_id: str
|
||||
|
||||
|
||||
class CheckOut(BaseModel):
|
||||
language: str # the language checked (detected, with "auto")
|
||||
language_name: str
|
||||
issues: list[Issue]
|
||||
|
||||
|
||||
class LanguageOut(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
|
||||
|
||||
def _base() -> str:
|
||||
url = get_settings().languagetool_url.rstrip("/")
|
||||
if not url:
|
||||
raise_api_error(503, "spellcheck_unavailable", "spell checking is not set up")
|
||||
return url
|
||||
|
||||
|
||||
def _kind(issue_type: str) -> str:
|
||||
if issue_type == "misspelling":
|
||||
return "spelling"
|
||||
if issue_type in ("style", "typographical", "whitespace", "register", "locale-violation"):
|
||||
return "style"
|
||||
return "grammar"
|
||||
|
||||
|
||||
def chunks(text: str) -> list[tuple[int, str]]:
|
||||
"""Split ``text`` into (offset, chunk) runs of whole lines, each at least
|
||||
_MIN_CHUNK characters where the text allows; a blank line always ends one."""
|
||||
out: list[tuple[int, str]] = []
|
||||
start = pos = 0
|
||||
for line in text.split("\n"):
|
||||
end = pos + len(line)
|
||||
if not line.strip():
|
||||
if text[start:pos].strip():
|
||||
# Up to the line break before the blank line, not including it.
|
||||
out.append((start, text[start:pos].rstrip("\n")))
|
||||
start = end + 1
|
||||
elif end - start >= _MIN_CHUNK:
|
||||
out.append((start, text[start:end]))
|
||||
start = end + 1
|
||||
pos = end + 1
|
||||
if text[start:].strip():
|
||||
out.append((start, text[start:]))
|
||||
return out
|
||||
|
||||
|
||||
async def _check_one(
|
||||
client: httpx.AsyncClient, base: str, text: str, language: str
|
||||
) -> dict[str, Any]:
|
||||
resp = await client.post(f"{base}/v2/check", data={"text": text, "language": language})
|
||||
resp.raise_for_status()
|
||||
data: dict[str, Any] = resp.json()
|
||||
return data
|
||||
|
||||
|
||||
def _issues(data: dict[str, Any], shift: int) -> list[Issue]:
|
||||
return [
|
||||
Issue(
|
||||
offset=int(m["offset"]) + shift,
|
||||
length=int(m["length"]),
|
||||
message=str(m.get("message", "")),
|
||||
short_message=str(m.get("shortMessage", "")),
|
||||
replacements=[str(r["value"]) for r in (m.get("replacements") or [])][
|
||||
:_MAX_SUGGESTIONS
|
||||
],
|
||||
kind=_kind(str((m.get("rule") or {}).get("issueType", ""))),
|
||||
rule_id=str((m.get("rule") or {}).get("id", "")),
|
||||
)
|
||||
for m in data.get("matches") or []
|
||||
]
|
||||
|
||||
|
||||
def _detected(data: dict[str, Any]) -> tuple[str, str]:
|
||||
lang = data.get("language") or {}
|
||||
detected = lang.get("detectedLanguage") or lang
|
||||
return str(detected.get("code", "")), str(detected.get("name", ""))
|
||||
|
||||
|
||||
@router.post("", response_model=CheckOut, dependencies=[Depends(require_csrf)])
|
||||
async def check(body: CheckIn, user: CurrentUser) -> CheckOut:
|
||||
base = _base()
|
||||
if not body.text.strip():
|
||||
return CheckOut(language=body.language, language_name="", issues=[])
|
||||
parts = chunks(body.text) if body.language == "auto" else [(0, body.text)]
|
||||
gate = asyncio.Semaphore(_PARALLEL)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
|
||||
async def one(text: str) -> dict[str, Any]:
|
||||
async with gate:
|
||||
return await _check_one(client, base, text, body.language)
|
||||
|
||||
results = await asyncio.gather(*(one(text) for _, text in parts))
|
||||
except (httpx.HTTPError, ValueError):
|
||||
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
|
||||
|
||||
issues = [
|
||||
i for (shift, _), data in zip(parts, results, strict=True) for i in _issues(data, shift)
|
||||
]
|
||||
# The language reported is the one most of the text was written in.
|
||||
weight: Counter[tuple[str, str]] = Counter()
|
||||
for (_, text), data in zip(parts, results, strict=True):
|
||||
weight[_detected(data)] += len(text)
|
||||
(code, name), _ = weight.most_common(1)[0]
|
||||
return CheckOut(language=code or body.language, language_name=name, issues=issues)
|
||||
|
||||
|
||||
_languages_cache: tuple[float, list[LanguageOut]] | None = None
|
||||
|
||||
|
||||
@router.get("/languages", response_model=list[LanguageOut])
|
||||
async def languages(user: CurrentUser) -> list[LanguageOut]:
|
||||
"""What the engine can check, for the language picker in Settings."""
|
||||
global _languages_cache
|
||||
if _languages_cache and time.monotonic() - _languages_cache[0] < 3600:
|
||||
return _languages_cache[1]
|
||||
base = _base()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"{base}/v2/languages")
|
||||
resp.raise_for_status()
|
||||
rows: list[dict[str, Any]] = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
raise_api_error(503, "spellcheck_unavailable", "the spell checker is not answering")
|
||||
seen: dict[str, str] = {}
|
||||
for row in rows:
|
||||
seen.setdefault(str(row["longCode"]), str(row["name"]))
|
||||
out = sorted((LanguageOut(code=c, name=n) for c, n in seen.items()), key=lambda x: x.name)
|
||||
_languages_cache = (time.monotonic(), out)
|
||||
return out
|
||||
@@ -68,6 +68,9 @@ class Settings(BaseSettings):
|
||||
# Server-side sender avatars (Gravatar + domain favicon). Fetched by the server
|
||||
# (never the browser) so senders can't see the user's IP or read activity.
|
||||
sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED")
|
||||
# Spelling and grammar checking in the compose window (LanguageTool server,
|
||||
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
|
||||
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
|
||||
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Spelling/grammar check proxy (compose window -> LanguageTool).
|
||||
|
||||
LanguageTool is replaced by a stub transport, so these run without the engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.api import spellcheck
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
|
||||
LT_REPLY: dict[str, Any] = {
|
||||
"language": {
|
||||
"code": "auto",
|
||||
"name": "Auto",
|
||||
"detectedLanguage": {"code": "pl-PL", "name": "Polish"},
|
||||
},
|
||||
"matches": [
|
||||
{
|
||||
"offset": 12,
|
||||
"length": 6,
|
||||
"message": "Wyraz „prosze” to najczęściej literówka",
|
||||
"shortMessage": "Literówka",
|
||||
"replacements": [{"value": "proszę"}],
|
||||
"rule": {"id": "PL_PROSZE", "issueType": "misspelling"},
|
||||
},
|
||||
{
|
||||
"offset": 40,
|
||||
"length": 11,
|
||||
"message": "Powtórzony wyraz",
|
||||
"shortMessage": "",
|
||||
"replacements": [{"value": v} for v in "abcdefgh"],
|
||||
"rule": {"id": "WORD_REPEAT", "issueType": "duplication"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
session = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
user = User(handle=f"sp{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": "c"},
|
||||
headers={"X-CSRF": "c"},
|
||||
) as c:
|
||||
yield c
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _stub(monkeypatch: pytest.MonkeyPatch, handler: Any) -> list[httpx.Request]:
|
||||
"""Route the module's outbound calls to `handler`; return what was sent."""
|
||||
seen: list[httpx.Request] = []
|
||||
real = httpx.AsyncClient
|
||||
|
||||
def wrapped(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
monkeypatch.setattr(
|
||||
spellcheck.httpx,
|
||||
"AsyncClient",
|
||||
lambda **kw: real(transport=httpx.MockTransport(wrapped), **kw),
|
||||
)
|
||||
monkeypatch.setattr(get_settings(), "languagetool_url", "http://lt.test:8010")
|
||||
return seen
|
||||
|
||||
|
||||
async def test_check_returns_trimmed_typed_issues(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
|
||||
resp = await client.post(
|
||||
"/api/spellcheck",
|
||||
json={"text": "Dzień dobry prosze o pliki ważne ważne", "language": "auto"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert (body["language"], body["language_name"]) == ("pl-PL", "Polish")
|
||||
first, second = body["issues"]
|
||||
assert first["kind"] == "spelling" and first["replacements"] == ["proszę"]
|
||||
assert second["kind"] == "grammar" and len(second["replacements"]) == 6
|
||||
assert b"language=auto" in sent[0].content
|
||||
|
||||
|
||||
async def test_unconfigured_or_down_engine_is_a_503(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def down(_r: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
_stub(monkeypatch, down)
|
||||
resp = await client.post("/api/spellcheck", json={"text": "hello"})
|
||||
assert resp.json()["error"]["code"] == "spellcheck_unavailable"
|
||||
|
||||
monkeypatch.setattr(get_settings(), "languagetool_url", "")
|
||||
resp = await client.post("/api/spellcheck", json={"text": "hello"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
async def test_empty_text_never_reaches_the_engine(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
sent = _stub(monkeypatch, lambda _r: httpx.Response(200, json=LT_REPLY))
|
||||
resp = await client.post("/api/spellcheck", json={"text": " \n "})
|
||||
assert resp.json()["issues"] == [] and sent == []
|
||||
|
||||
|
||||
async def test_settings_store_the_spellcheck_choice(client: httpx.AsyncClient) -> None:
|
||||
out = (await client.patch("/api/settings", json={"spellcheck_language": "pl-PL"})).json()
|
||||
assert out["spellcheck_language"] == "pl-PL" and out["spellcheck_enabled"] is True
|
||||
out = (await client.patch("/api/settings", json={"spellcheck_enabled": False})).json()
|
||||
assert out["spellcheck_enabled"] is False
|
||||
bad = (await client.patch("/api/settings", json={"spellcheck_language": "<script>"})).json()
|
||||
assert bad["spellcheck_language"] == "pl-PL" # rejected, previous value kept
|
||||
|
||||
|
||||
def test_auto_text_is_split_into_language_sized_chunks() -> None:
|
||||
text = "Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.\n\nHi\n\nThanks!"
|
||||
parts = spellcheck.chunks(text)
|
||||
# The greeting rides with the long line after it; blank lines end a chunk.
|
||||
assert [t for _, t in parts] == [
|
||||
"Witam,\nprosze o przesłanie dokumentów do jutra, to bardzo ważne.",
|
||||
"Hi",
|
||||
"Thanks!",
|
||||
]
|
||||
# Offsets point back into the original text.
|
||||
assert all(text[o : o + len(t)] == t for o, t in parts)
|
||||
|
||||
|
||||
async def test_auto_checks_each_chunk_and_shifts_offsets(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def per_chunk(request: httpx.Request) -> httpx.Response:
|
||||
body = request.content.decode()
|
||||
lang = ("pl-PL", "Polish") if "Witam" in body else ("en-US", "English (US)")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"language": {"detectedLanguage": {"code": lang[0], "name": lang[1]}},
|
||||
"matches": [
|
||||
{
|
||||
"offset": 0,
|
||||
"length": 2,
|
||||
"message": "m",
|
||||
"replacements": [],
|
||||
"rule": {"id": "R", "issueType": "misspelling"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
sent = _stub(monkeypatch, per_chunk)
|
||||
text = "Witam, to jest długi polski akapit pełen słów i zdań.\n\nHello there friend"
|
||||
out = (await client.post("/api/spellcheck", json={"text": text})).json()
|
||||
assert len(sent) == 2
|
||||
assert [i["offset"] for i in out["issues"]] == [0, text.index("Hello")]
|
||||
assert out["language_name"] == "Polish" # most of the text
|
||||
@@ -118,6 +118,23 @@ services:
|
||||
retries: 10
|
||||
|
||||
# Test mail servers (integration tests + manual dev against a throwaway mailbox)
|
||||
# Spelling and grammar checking for the compose window (POST /api/spellcheck
|
||||
# forwards to it; the browser never talks to it directly). No n-gram data:
|
||||
# it would add several GB per language for a modest gain in grammar checks.
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:6.8
|
||||
environment:
|
||||
Java_Xms: 256m
|
||||
Java_Xmx: 1g
|
||||
ports:
|
||||
- "127.0.0.1:8010:8010"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8010/v2/languages || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
greenmail:
|
||||
image: greenmail/standalone:2.0.1
|
||||
environment:
|
||||
|
||||
@@ -159,6 +159,21 @@ export function Composer({
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [fromOpen, setFromOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
// This message's spell-check language (More menu); null follows Settings.
|
||||
const [spellLang, setSpellLang] = useState<string | null>(null);
|
||||
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
// The language list folds away whenever the More menu closes.
|
||||
useEffect(() => {
|
||||
if (!moreOpen) setSpellMenuOpen(false);
|
||||
}, [moreOpen]);
|
||||
// What the engine can check — and whether this server has one at all.
|
||||
const spellLanguages = useQuery({
|
||||
queryKey: ["spellcheck-languages"],
|
||||
queryFn: api.spellcheckLanguages,
|
||||
retry: false,
|
||||
staleTime: 3_600_000,
|
||||
});
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const fromRef = useRef<HTMLDivElement>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
@@ -656,7 +671,7 @@ export function Composer({
|
||||
{t("compose.more")}
|
||||
</HeaderButton>
|
||||
{moreOpen && (
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
@@ -676,6 +691,56 @@ export function Composer({
|
||||
>
|
||||
{t("compose.insertSignature")}
|
||||
</button>
|
||||
{spellLanguages.data && (() => {
|
||||
// The language in force: this message's choice, else Settings.
|
||||
const fallback = settingsQuery.data?.spellcheck_enabled
|
||||
? (settingsQuery.data?.spellcheck_language ?? "auto")
|
||||
: "off";
|
||||
const current = spellLang ?? fallback;
|
||||
const nameOf = (code: string) =>
|
||||
code === "off"
|
||||
? t("compose.spellOff")
|
||||
: code === "auto"
|
||||
? t("compose.spellAuto")
|
||||
: (spellLanguages.data.find((l) => l.code === code)?.name ?? code);
|
||||
const choices = [
|
||||
{ code: "off", name: t("compose.spellOffMessage") },
|
||||
{ code: "auto", name: t("compose.spellAuto") },
|
||||
...spellLanguages.data,
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
aria-expanded={spellMenuOpen}
|
||||
onClick={() => setSpellMenuOpen((v) => !v)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("compose.spellMenu")}: {nameOf(current)}
|
||||
</span>
|
||||
<span className="shrink-0 text-app-faint">{spellMenuOpen ? "▾" : "›"}</span>
|
||||
</button>
|
||||
{spellMenuOpen && (
|
||||
<div className="max-h-56 overflow-y-auto border-y border-app-border bg-app-surface py-1">
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
className="flex w-full items-center gap-2 px-3 py-1 text-left text-[13px] text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setSpellLang(c.code);
|
||||
setSpellMenuOpen(false);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-app-accent">{c.code === current ? "✓" : ""}</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
@@ -870,6 +935,7 @@ export function Composer({
|
||||
setBodyHtml(html);
|
||||
}}
|
||||
showToolbar={showToolbar}
|
||||
spellLanguage={spellLang}
|
||||
// With a height to divide, the body takes what is left and
|
||||
// scrolls. A floor here would instead push the window past that
|
||||
// height — and `overflow-hidden` would clip the difference.
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import {
|
||||
canHighlight,
|
||||
clearPaint,
|
||||
collectText,
|
||||
ignoreKey,
|
||||
paint,
|
||||
place,
|
||||
stillValid,
|
||||
type PlacedIssue,
|
||||
} from "./spellcheck";
|
||||
|
||||
// Imperative handle for the composer to manage the signature block.
|
||||
export interface RichTextEditorHandle {
|
||||
@@ -170,9 +190,12 @@ export const RichTextEditor = forwardRef<
|
||||
onReady?: (el: HTMLDivElement) => void;
|
||||
showToolbar?: boolean;
|
||||
minHeight?: string; // Tailwind min-h-* class for the editable area
|
||||
/** This message's spell-check language from the compose window's More menu:
|
||||
* "off", "auto" or a language code. Unset follows Settings. */
|
||||
spellLanguage?: string | null;
|
||||
}
|
||||
>(function RichTextEditor(
|
||||
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" },
|
||||
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]", spellLanguage: spellOverride },
|
||||
handleRef,
|
||||
) {
|
||||
const t = useT();
|
||||
@@ -193,6 +216,27 @@ export const RichTextEditor = forwardRef<
|
||||
const [newLinkRange, setNewLinkRange] = useState<Range | null>(null);
|
||||
const linkPopupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Spelling and grammar check (LanguageTool via /api/spellcheck), when the user
|
||||
// has it on and the server has an engine. Off -> the browser's own checker.
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const [spellUnavailable, setSpellUnavailable] = useState(false);
|
||||
const spellWanted = spellOverride
|
||||
? spellOverride !== "off"
|
||||
: Boolean(settings.data?.spellcheck_enabled);
|
||||
const spellOn = spellWanted && !spellUnavailable && canHighlight();
|
||||
const spellLanguage =
|
||||
spellOverride && spellOverride !== "off"
|
||||
? spellOverride
|
||||
: (settings.data?.spellcheck_language ?? "auto");
|
||||
const spellOwner = useRef({}); // this editor's key in the shared highlight set
|
||||
const issuesRef = useRef<PlacedIssue[]>([]);
|
||||
const ignoredRef = useRef(new Set<string>());
|
||||
const spellSeq = useRef(0);
|
||||
const spellTimer = useRef<number | null>(null);
|
||||
const [spellIssue, setSpellIssue] = useState<PlacedIssue | null>(null);
|
||||
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const spellPopupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// A reply's quoted original starts folded away behind the "•••" pill.
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
|
||||
@@ -306,8 +350,63 @@ export const RichTextEditor = forwardRef<
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [colorMenu]);
|
||||
|
||||
const runSpellcheck = useCallback(async () => {
|
||||
const ed = ref.current;
|
||||
if (!ed || !spellOn) return;
|
||||
const map = collectText(ed);
|
||||
const seq = ++spellSeq.current;
|
||||
if (!map.text.trim() || map.text.length > 20_000) {
|
||||
issuesRef.current = [];
|
||||
paint(spellOwner.current, []);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.spellcheck(map.text, spellLanguage);
|
||||
if (seq !== spellSeq.current) return; // a newer check is on its way
|
||||
issuesRef.current = place(map, res.issues).filter(
|
||||
(i) => !ignoredRef.current.has(ignoreKey(i)),
|
||||
);
|
||||
paint(spellOwner.current, issuesRef.current);
|
||||
} catch (e) {
|
||||
// No engine on this server (or it is down): fall back to the browser's.
|
||||
if (e instanceof ApiError && e.status === 503) setSpellUnavailable(true);
|
||||
}
|
||||
}, [spellOn, spellLanguage]);
|
||||
|
||||
// Check again once typing pauses. Marks on words just edited go at once.
|
||||
const scheduleSpellcheck = useCallback(() => {
|
||||
if (!spellOn) return;
|
||||
issuesRef.current = issuesRef.current.filter(stillValid);
|
||||
paint(spellOwner.current, issuesRef.current);
|
||||
if (spellTimer.current) window.clearTimeout(spellTimer.current);
|
||||
spellTimer.current = window.setTimeout(() => void runSpellcheck(), 800);
|
||||
}, [spellOn, runSpellcheck]);
|
||||
|
||||
// On open, when turned on, or when the language changes; off clears the marks.
|
||||
useEffect(() => {
|
||||
if (spellOn) scheduleSpellcheck();
|
||||
else {
|
||||
// A check still on its way must not paint marks once checking is off.
|
||||
spellSeq.current++;
|
||||
if (spellTimer.current) window.clearTimeout(spellTimer.current);
|
||||
issuesRef.current = [];
|
||||
clearPaint(spellOwner.current);
|
||||
}
|
||||
}, [spellOn, scheduleSpellcheck]);
|
||||
|
||||
useEffect(() => {
|
||||
const owner = spellOwner.current;
|
||||
const seq = spellSeq; // the counter itself, not a value read at cleanup
|
||||
return () => {
|
||||
seq.current++; // nor after the editor is gone
|
||||
if (spellTimer.current) window.clearTimeout(spellTimer.current);
|
||||
clearPaint(owner);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emit = () => {
|
||||
if (ref.current) onInput(contentHtml(ref.current));
|
||||
scheduleSpellcheck();
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
@@ -441,6 +540,88 @@ export const RichTextEditor = forwardRef<
|
||||
const a = el.closest("a");
|
||||
setSelLink(a && ref.current?.contains(a) ? a : null);
|
||||
setLinkEdit(null);
|
||||
setSpellIssue(null);
|
||||
if (a || !spellOn) return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.anchorNode) return;
|
||||
const node = sel.anchorNode;
|
||||
const offset = sel.anchorOffset;
|
||||
const hit = issuesRef.current.find((i) => {
|
||||
if (!stillValid(i)) return false;
|
||||
try {
|
||||
return i.range.comparePoint(node, offset) === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (hit) setSpellIssue(hit);
|
||||
};
|
||||
|
||||
// The spell popup sits just under the marked words, like the link popup.
|
||||
const computeSpellPos = useCallback(() => {
|
||||
const ed = ref.current;
|
||||
if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
|
||||
const r = spellIssue.range.getBoundingClientRect();
|
||||
const er = ed.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.max(8, Math.min(r.left - er.left, er.width - 300)),
|
||||
top: popupTop(r, spellPopupRef.current?.offsetHeight || 160),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spellIssue]);
|
||||
|
||||
useEffect(() => setSpellPos(computeSpellPos()), [spellIssue, computeSpellPos]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!spellIssue) return;
|
||||
const onWin = () => setSpellPos(computeSpellPos());
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (spellPopupRef.current?.contains(e.target as Node)) return;
|
||||
setSpellIssue(null);
|
||||
};
|
||||
window.addEventListener("resize", onWin);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onWin);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [spellIssue, computeSpellPos]);
|
||||
|
||||
// Take a suggestion: replace the marked words as if typed (so Ctrl+Z undoes it).
|
||||
const applySuggestion = (issue: PlacedIssue, replacement: string) => {
|
||||
const ed = ref.current;
|
||||
setSpellIssue(null);
|
||||
if (!ed || !stillValid(issue)) return;
|
||||
ed.focus();
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(issue.range);
|
||||
document.execCommand("insertText", false, replacement);
|
||||
issuesRef.current = issuesRef.current.filter((i) => i !== issue);
|
||||
emit();
|
||||
};
|
||||
|
||||
// "Ignore": this word (or this grammar point) is fine, for the rest of the message.
|
||||
const ignoreIssue = (issue: PlacedIssue) => {
|
||||
const key = ignoreKey(issue);
|
||||
ignoredRef.current.add(key);
|
||||
issuesRef.current = issuesRef.current.filter((i) => ignoreKey(i) !== key);
|
||||
paint(spellOwner.current, issuesRef.current);
|
||||
setSpellIssue(null);
|
||||
};
|
||||
|
||||
// A popup's top edge, relative to the editor: just under its anchor, or just
|
||||
// above it when there is no room left below — inside the editor's visible
|
||||
// box and the window alike — so it is never cut off at the bottom.
|
||||
const popupTop = (anchor: DOMRect, popupH: number) => {
|
||||
const er = ref.current!.getBoundingClientRect();
|
||||
const bottom = Math.min(er.bottom, window.innerHeight) - 4;
|
||||
const top = Math.max(er.top, 0) + 4;
|
||||
const below = anchor.bottom + 4;
|
||||
const above = anchor.top - 4 - popupH;
|
||||
const fitsBelow = below + popupH <= bottom;
|
||||
return (fitsBelow || above < top ? below : above) - er.top;
|
||||
};
|
||||
|
||||
// Where the link popup goes: just under the link (or the text becoming one),
|
||||
@@ -458,11 +639,27 @@ export const RichTextEditor = forwardRef<
|
||||
const er = ed.getBoundingClientRect();
|
||||
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
|
||||
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320));
|
||||
return { left, top: ar.bottom - er.top + 4 };
|
||||
return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selLink, newLinkRange]);
|
||||
|
||||
useEffect(() => setLinkPos(computeLinkPos()), [selLink, newLinkRange, computeLinkPos]);
|
||||
|
||||
// The first placement guesses a popup's height; once it is on screen, place it
|
||||
// again with the real one (it may need to go above after all). Only a change
|
||||
// in position re-renders, so this settles at once.
|
||||
useLayoutEffect(() => {
|
||||
if (spellIssue && spellPopupRef.current) {
|
||||
const p = computeSpellPos();
|
||||
if (p && (p.top !== spellPos?.top || p.left !== spellPos?.left)) setSpellPos(p);
|
||||
}
|
||||
if ((selLink || newLinkRange) && linkPopupRef.current) {
|
||||
const p = computeLinkPos();
|
||||
if (p && (p.top !== linkPos?.top || p.left !== linkPos?.left)) setLinkPos(p);
|
||||
}
|
||||
// linkEdit: switching the link popup to its URL field changes its height.
|
||||
}, [spellIssue, spellPos, computeSpellPos, selLink, newLinkRange, linkEdit, linkPos, computeLinkPos]);
|
||||
|
||||
// While the URL field has focus the browser hides the text selection, so the
|
||||
// words about to become a link would look unselected. Keep them marked with a
|
||||
// CSS highlight (styled in index.css; nothing is written into the message).
|
||||
@@ -556,6 +753,13 @@ export const RichTextEditor = forwardRef<
|
||||
const onEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Typing (or Escape) in the message puts the link popup away. Escape stops
|
||||
// here: the mail view's own Escape would otherwise minimise the whole window.
|
||||
if (spellIssue) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // the popup, not the compose window
|
||||
}
|
||||
setSpellIssue(null);
|
||||
}
|
||||
if (selLink || newLinkRange) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -737,7 +941,12 @@ export const RichTextEditor = forwardRef<
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
onInput={(e) => onInput(contentHtml(e.currentTarget))}
|
||||
onInput={(e) => {
|
||||
onInput(contentHtml(e.currentTarget));
|
||||
scheduleSpellcheck();
|
||||
}}
|
||||
// Our own checker replaces the browser's, rather than doubling it up.
|
||||
spellCheck={!spellOn}
|
||||
onKeyUp={refreshActive}
|
||||
onMouseUp={refreshActive}
|
||||
onClick={onEditorClick}
|
||||
@@ -745,9 +954,51 @@ export const RichTextEditor = forwardRef<
|
||||
onScroll={() => {
|
||||
setBox(computeBox());
|
||||
setLinkPos(computeLinkPos());
|
||||
setSpellPos(computeSpellPos());
|
||||
}}
|
||||
className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
|
||||
/>
|
||||
{spellIssue && spellPos && (
|
||||
<div
|
||||
ref={spellPopupRef}
|
||||
role="dialog"
|
||||
aria-label={t("compose.spellTitle")}
|
||||
className="absolute z-20 w-[300px] max-w-[calc(100%-16px)] rounded-lg border border-app-border bg-app-elevated p-3 text-[13px] text-app-fg shadow-xl"
|
||||
style={{ left: spellPos.left, top: spellPos.top }}
|
||||
>
|
||||
<p className="mb-2 flex items-start gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${
|
||||
spellIssue.kind === "spelling" ? "bg-[#e5484d]" : "bg-app-accent"
|
||||
}`}
|
||||
/>
|
||||
<span>{spellIssue.message}</span>
|
||||
</p>
|
||||
{spellIssue.replacements.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{spellIssue.replacements.slice(0, 5).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => applySuggestion(spellIssue, r)}
|
||||
className="rounded-md bg-app-accent/10 px-2 py-0.5 font-medium text-app-accent hover:bg-app-accent/20"
|
||||
>
|
||||
{r || t("compose.spellRemove")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ignoreIssue(spellIssue)}
|
||||
className="text-app-muted hover:text-app-fg hover:underline"
|
||||
>
|
||||
{t("compose.spellIgnore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(selLink || newLinkRange) && linkPos && (
|
||||
<div
|
||||
ref={linkPopupRef}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Spelling and grammar marks for the compose editor (LanguageTool, through
|
||||
// POST /api/spellcheck).
|
||||
//
|
||||
// Nothing is ever written into the message for this: the underlines are CSS
|
||||
// highlights (`::highlight(wmc-spell)` / `wmc-grammar` in index.css) painted
|
||||
// over live DOM Ranges, so the HTML that gets sent is exactly what was typed.
|
||||
// Ranges also follow later edits on their own; an issue whose words changed is
|
||||
// dropped rather than shown in the wrong place.
|
||||
|
||||
import type { SpellIssue } from "../../lib/types";
|
||||
|
||||
/** Where the checked text came from: each text node and its start offset in it. */
|
||||
export interface TextMap {
|
||||
text: string;
|
||||
pieces: { node: Text; start: number }[];
|
||||
}
|
||||
|
||||
export interface PlacedIssue extends SpellIssue {
|
||||
range: Range;
|
||||
/** The words as they were when checked: a mismatch means they were edited. */
|
||||
original: string;
|
||||
}
|
||||
|
||||
// The signature and the quoted original are not the user's writing to check.
|
||||
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle";
|
||||
const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/;
|
||||
|
||||
/**
|
||||
* The editor's own text as one string, with a line break wherever the editor
|
||||
* starts a new line (so "end." and "Next" on two lines never read as one word),
|
||||
* plus the map back to the text nodes.
|
||||
*/
|
||||
export function collectText(root: HTMLElement): TextMap {
|
||||
const pieces: TextMap["pieces"] = [];
|
||||
let text = "";
|
||||
const newline = () => {
|
||||
if (text && !text.endsWith("\n")) text += "\n";
|
||||
};
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const t = node as Text;
|
||||
if (t.data) {
|
||||
pieces.push({ node: t, start: text.length });
|
||||
text += t.data;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
if (node.matches(SKIP)) return;
|
||||
if (node.tagName === "BR") {
|
||||
text += "\n";
|
||||
return;
|
||||
}
|
||||
const block = BLOCK.test(node.tagName);
|
||||
if (block) newline();
|
||||
node.childNodes.forEach(walk);
|
||||
if (block) newline();
|
||||
};
|
||||
root.childNodes.forEach(walk);
|
||||
return { text, pieces };
|
||||
}
|
||||
|
||||
/** The DOM point for a character offset into the collected text. */
|
||||
function pointAt(map: TextMap, offset: number, isEnd: boolean): [Text, number] | null {
|
||||
for (const { node, start } of map.pieces) {
|
||||
const end = start + node.data.length;
|
||||
if (isEnd ? offset > start && offset <= end : offset >= start && offset < end) {
|
||||
return [node, offset - start];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Issues pinned to live Ranges in the editor; ones that no longer fit are left out. */
|
||||
export function place(map: TextMap, issues: SpellIssue[]): PlacedIssue[] {
|
||||
const out: PlacedIssue[] = [];
|
||||
for (const issue of issues) {
|
||||
const original = map.text.slice(issue.offset, issue.offset + issue.length);
|
||||
// Spanning a line break means spanning elements: not something to underline.
|
||||
if (!original || original.includes("\n")) continue;
|
||||
const a = pointAt(map, issue.offset, false);
|
||||
const b = pointAt(map, issue.offset + issue.length, true);
|
||||
if (!a || !b || !a[0].isConnected || !b[0].isConnected) continue;
|
||||
const range = document.createRange();
|
||||
try {
|
||||
range.setStart(a[0], a[1]);
|
||||
range.setEnd(b[0], b[1]);
|
||||
} catch {
|
||||
continue; // the text changed under us
|
||||
}
|
||||
if (range.toString() !== original) continue;
|
||||
out.push({ ...issue, range, original });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still pointing at the words it was found on (not edited since). */
|
||||
export function stillValid(issue: PlacedIssue): boolean {
|
||||
return issue.range.startContainer.isConnected && issue.range.toString() === issue.original;
|
||||
}
|
||||
|
||||
/** The key "Ignore" remembers an issue by, for the rest of this message. */
|
||||
export function ignoreKey(issue: { rule_id: string; original: string }): string {
|
||||
return `${issue.rule_id}\u0000${issue.original}`;
|
||||
}
|
||||
|
||||
type HighlightRegistry = Map<string, unknown>;
|
||||
|
||||
function registry(): HighlightRegistry | null {
|
||||
return (globalThis.CSS as unknown as { highlights?: HighlightRegistry }).highlights ?? null;
|
||||
}
|
||||
|
||||
/** Whether this browser can draw the underlines at all. */
|
||||
export function canHighlight(): boolean {
|
||||
return registry() !== null && "Highlight" in globalThis;
|
||||
}
|
||||
|
||||
// Several compose windows can be open at once, but a highlight name is global
|
||||
// to the page. Each editor keeps its own marks here, and every repaint draws
|
||||
// all of them, so one window's check never wipes another's underlines.
|
||||
const owners = new Map<object, PlacedIssue[]>();
|
||||
|
||||
function repaint(): void {
|
||||
const reg = registry();
|
||||
const HighlightCtor = (globalThis as unknown as { Highlight?: new (...r: Range[]) => unknown })
|
||||
.Highlight;
|
||||
if (!reg || !HighlightCtor) return;
|
||||
const all = [...owners.values()].flat();
|
||||
reg.set("wmc-spell", new HighlightCtor(...all.filter((i) => i.kind === "spelling").map((i) => i.range)));
|
||||
reg.set("wmc-grammar", new HighlightCtor(...all.filter((i) => i.kind !== "spelling").map((i) => i.range)));
|
||||
}
|
||||
|
||||
/** Paint one editor's underlines: red for spelling, blue for grammar and style. */
|
||||
export function paint(owner: object, issues: PlacedIssue[]): void {
|
||||
owners.set(owner, issues);
|
||||
repaint();
|
||||
}
|
||||
|
||||
/** Remove one editor's underlines (it closed, or checking was turned off). */
|
||||
export function clearPaint(owner: object): void {
|
||||
owners.delete(owner);
|
||||
repaint();
|
||||
}
|
||||
@@ -255,6 +255,13 @@ export const en = {
|
||||
"compose.linkEdit": "Edit",
|
||||
"compose.linkDelete": "Delete",
|
||||
"compose.linkSave": "Save",
|
||||
"compose.spellTitle": "Spelling and grammar",
|
||||
"compose.spellIgnore": "Ignore",
|
||||
"compose.spellRemove": "(remove)",
|
||||
"compose.spellMenu": "Spelling",
|
||||
"compose.spellAuto": "Auto-detect",
|
||||
"compose.spellOff": "Off",
|
||||
"compose.spellOffMessage": "Off for this message",
|
||||
"compose.imageTooLarge": "Image is too large (max 5 MB).",
|
||||
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
|
||||
"compose.attachments": "Attachments",
|
||||
@@ -352,6 +359,12 @@ export const en = {
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"settings.recoveryEmail": "Recovery email",
|
||||
"settings.spellcheck": "Spelling and grammar check",
|
||||
"settings.on": "On",
|
||||
"settings.off": "Off",
|
||||
"settings.spellcheckLanguage": "Language",
|
||||
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
|
||||
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
|
||||
"settings.recoveryEmailHint":
|
||||
"If you forget your password, a link to choose a new one is sent here.",
|
||||
"settings.recoveryEmailNew": "Recovery email address",
|
||||
|
||||
@@ -192,6 +192,20 @@ body {
|
||||
background-color: rgb(var(--app-accent) / 0.25);
|
||||
}
|
||||
|
||||
/* Spelling (red) and grammar/style (blue) marks from the compose editor's
|
||||
LanguageTool check, drawn as CSS highlights over the text — never written
|
||||
into the message (components/compose/spellcheck.ts). */
|
||||
::highlight(wmc-spell) {
|
||||
text-decoration: underline wavy #e5484d;
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
::highlight(wmc-grammar) {
|
||||
text-decoration: underline wavy rgb(var(--app-accent));
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Thin, unobtrusive scrollbars that match the theme. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
SessionInfo,
|
||||
SpellCheckOut,
|
||||
SyncWindow,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
@@ -230,11 +231,19 @@ export const api = {
|
||||
| "invitation_from"
|
||||
| "invitation_calendar"
|
||||
| "invitation_file"
|
||||
| "spellcheck_enabled"
|
||||
| "spellcheck_language"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
spellcheck: (text: string, language: string) =>
|
||||
apiFetch<SpellCheckOut>("/spellcheck", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, language }),
|
||||
}),
|
||||
spellcheckLanguages: () => apiFetch<{ code: string; name: string }[]>("/spellcheck/languages"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
// --- calendars (builtin calendar plugin) ---
|
||||
|
||||
@@ -230,6 +230,27 @@ export interface UserSettings {
|
||||
invitation_calendar: string;
|
||||
invitation_file: boolean;
|
||||
allowed_image_senders: string[];
|
||||
/** Spelling and grammar check in the compose window (LanguageTool). */
|
||||
spellcheck_enabled: boolean;
|
||||
/** "auto", or a LanguageTool code such as "pl-PL". */
|
||||
spellcheck_language: string;
|
||||
}
|
||||
|
||||
/** One problem the spell checker found, by character offset in the checked text. */
|
||||
export interface SpellIssue {
|
||||
offset: number;
|
||||
length: number;
|
||||
message: string;
|
||||
short_message: string;
|
||||
replacements: string[];
|
||||
kind: "spelling" | "grammar" | "style";
|
||||
rule_id: string;
|
||||
}
|
||||
|
||||
export interface SpellCheckOut {
|
||||
language: string;
|
||||
language_name: string;
|
||||
issues: SpellIssue[];
|
||||
}
|
||||
|
||||
export interface QuotaOut {
|
||||
|
||||
@@ -720,6 +720,70 @@ function ServerRow({
|
||||
);
|
||||
}
|
||||
|
||||
// Spelling and grammar check in the compose window (LanguageTool on the server).
|
||||
function SpellcheckSetting() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
// Also tells us whether this server has a checker at all (503 when not).
|
||||
const languages = useQuery({
|
||||
queryKey: ["spellcheck-languages"],
|
||||
queryFn: api.spellcheckLanguages,
|
||||
retry: false,
|
||||
staleTime: 3_600_000,
|
||||
});
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
|
||||
});
|
||||
const on = settings.data?.spellcheck_enabled ?? true;
|
||||
const unavailable = languages.isError;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-app-border py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.spellcheck")}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={unavailable || patch.isPending}
|
||||
onClick={() => patch.mutate({ spellcheck_enabled: value })}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
on === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "settings.on" : "settings.off")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
<p className="text-xs text-app-faint">{t("settings.spellcheckUnavailable")}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.spellcheckLanguage")}</span>
|
||||
<select
|
||||
className="max-w-[60%] rounded border border-app-border bg-app-surface px-2 py-1 disabled:opacity-50"
|
||||
disabled={!on || patch.isPending}
|
||||
value={settings.data?.spellcheck_language ?? "auto"}
|
||||
onChange={(e) => patch.mutate({ spellcheck_language: e.target.value })}
|
||||
>
|
||||
<option value="auto">{t("settings.spellcheckAuto")}</option>
|
||||
{(languages.data ?? []).map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -757,6 +821,7 @@ function AppearanceSection() {
|
||||
{/* Saved per device, like the theme: the right choice depends on the screen. */}
|
||||
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user