Reported as "the Portuguese option isn't translating the advice in English — it's just reprinting Portuguese." Nothing was wrong with targetFor. It was reading a direction the account could not leave. learnerPairs held only zh, so SetPair refused learning_pair for pt-PT and every Portuguese account was learning_en by force. targetFor then did exactly what it says: explanations follow the half of the pair she is not learning, which for a forced learning_en account is Portuguese. A Portuguese document, corrected in Portuguese, explained in Portuguese, with no way to ask for English — correct behaviour derived from a fact about the roster that was no longer true. The note in learnerPairs was written one phase too early to see it. It said turning a pair around needs a word list and a dictionary reading into English, and that fr, es and pt-PT had neither. Portuguese has both. Word boundaries are spaces — the megabyte jieba needs is a property of a writing system that doesn't use them, not a debt every pair owes. And the dictionary arrived with dict.db, which reads pt→en as readily as en→pt; dreamProvider.reverse has been answering that question since the pair shipped. What was actually blocking the pair a native English speaker learning Portuguese needs was this list. So pt-PT joins it, and the pt-PT pack gets the learner copy the control renders from — each label in the language of whoever would pick it, since someone on the wrong side of that switch cannot read the side they are reaching for. fr and es clear the same two bars through the same dict.db and stay out: their packs carry no learner block yet, which is a translation question rather than a data one, and the server should keep saying no until one is written. Two things that assumed learning_pair meant Chinese, now that it doesn't. The segmenter gate reads the pair as well as the direction, or a Portuguese learner would load a megabyte of Chinese word list and hover Portuguese words at /api/hanzi. And that endpoint's own comment justified skipping providerFor with a guarantee it no longer has; the real guarantee was always the caller's — it is only ever asked about tokens the Chinese segmenter found — and a stray lookup was already safe, answering a miss with an empty 200. Tests in both packages. The auth test that pinned pt-PT's refusal now pins its acceptance, with fr and es still refused; the suggestions test pins the consequence where it actually lands, which is the language she reads her advice in. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
159 lines
5.9 KiB
Go
159 lines
5.9 KiB
Go
package lexicon
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
|
)
|
|
|
|
// Handler serves the word-lookup endpoints. It holds the shared provider Set
|
|
// and the database, because which provider answers depends on who is asking.
|
|
type Handler struct {
|
|
Set *Set
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewHandler constructs a Handler over a provider Set. db is used for one
|
|
// thing: reading the caller's pair language.
|
|
func NewHandler(db *sql.DB, set *Set) *Handler { return &Handler{Set: set, db: db} }
|
|
|
|
// Routes returns the router mounted at /api/word. The word is a path segment so
|
|
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
|
// punctuated token.
|
|
func (h *Handler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/{word}", h.lookup)
|
|
return r
|
|
}
|
|
|
|
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
|
|
// translation-only lookup behind the inline hover/select gloss. It shares the
|
|
// Handler's Set, so the embedded datasets and dict.db are still opened once.
|
|
func (h *Handler) GlossRoutes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/{word}", h.gloss)
|
|
return r
|
|
}
|
|
|
|
// HanziRoutes returns the router mounted at /api/hanzi — a Chinese word to its
|
|
// pinyin and English senses, for a writer going the other way through the zh
|
|
// pair (`users.direction = 'learning_pair'`).
|
|
//
|
|
// It does not go through [Handler.providerFor], and that is not an oversight.
|
|
// providerFor picks a dictionary by the writer's *pair*, to answer "what does
|
|
// this English word mean in her language" — a question whose answer differs per
|
|
// pair. This endpoint asks the opposite question of exactly one language: it
|
|
// reads hanzi, and hanzi are Chinese whoever is looking them up. Routing it
|
|
// through the pair would add a database read per hover to choose between one
|
|
// option and itself.
|
|
//
|
|
// What no longer holds is the reason this used to give — that
|
|
// [auth.SupportsLearnerDirection] guarantees the caller is on the zh pair. Since
|
|
// Portuguese joined `learnerPairs` a learning_pair account may be Portuguese, so
|
|
// the guarantee now comes from the *caller*: the client only ever asks this
|
|
// route about a token its Chinese segmenter found, and that segmenter is loaded
|
|
// only for the zh pair (see useSegmenter in App.tsx). A stray lookup is still
|
|
// answered safely — a word the Chinese dictionary has never heard of is a 200
|
|
// with empty lists, exactly like any other miss.
|
|
func (h *Handler) HanziRoutes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/{word}", h.hanzi)
|
|
return r
|
|
}
|
|
|
|
// hanzi answers a Chinese word lookup. Like the other two, a miss is a 200 with
|
|
// empty lists — a hover that lands on a word the dictionary has never heard of
|
|
// is an ordinary thing to happen while reading, and the tooltip simply doesn't
|
|
// open.
|
|
func (h *Handler) hanzi(w http.ResponseWriter, r *http.Request) {
|
|
res, err := h.Set.Hanzi(pathWord(r))
|
|
if err != nil {
|
|
writeLookupErr(w, err)
|
|
return
|
|
}
|
|
writeLookup(w, res)
|
|
}
|
|
|
|
// providerFor returns the provider for the caller's language pair.
|
|
//
|
|
// The pair language is read here rather than threaded down because a word
|
|
// lookup has no other query to piggyback on — unlike the document handlers,
|
|
// which take pair_lang from the row-scoped query that already proves
|
|
// ownership. It is one indexed primary-key read against a local SQLite file,
|
|
// which costs less than encoding the response it feeds.
|
|
//
|
|
// A read that fails, or a caller with no user row, resolves to the empty
|
|
// language, and [Set.For] maps that to today's embedded behaviour. Falling back
|
|
// to a working dictionary beats failing the lookup.
|
|
func (h *Handler) providerFor(ctx context.Context) Provider {
|
|
var lang string
|
|
if h.db != nil {
|
|
_ = h.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(pair_lang, '') FROM users WHERE id = ?`,
|
|
auth.UserID(ctx),
|
|
).Scan(&lang)
|
|
}
|
|
return h.Set.For(lang)
|
|
}
|
|
|
|
// pathWord reads the {word} segment, URL-decoded.
|
|
func pathWord(r *http.Request) string {
|
|
word := chi.URLParam(r, "word")
|
|
if decoded, err := url.PathUnescape(word); err == nil {
|
|
word = decoded
|
|
}
|
|
return word
|
|
}
|
|
|
|
// lookup returns the definition + synonyms for one word. A word found in no
|
|
// dataset still returns 200 with empty lists, so the popover can show a
|
|
// friendly "nothing found" rather than an error state.
|
|
func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
|
res, err := h.providerFor(r.Context()).Lookup(pathWord(r))
|
|
if err != nil {
|
|
writeLookupErr(w, err)
|
|
return
|
|
}
|
|
writeLookup(w, res)
|
|
}
|
|
|
|
// gloss returns just the translation for one word. Like lookup, a miss is a 200
|
|
// with an empty gloss so the hover tooltip can quietly skip rather than error.
|
|
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
|
|
res, err := h.providerFor(r.Context()).Gloss(pathWord(r))
|
|
if err != nil {
|
|
writeLookupErr(w, err)
|
|
return
|
|
}
|
|
writeLookup(w, res)
|
|
}
|
|
|
|
// writeLookupErr answers a failed lookup. The real error is a dictionary or
|
|
// database fault — a file path, a SQLite message — and belongs in the log, not
|
|
// in a tooltip. The client treats any non-200 the same way, so nothing is lost
|
|
// by saying less.
|
|
func writeLookupErr(w http.ResponseWriter, err error) {
|
|
log.Printf("lexicon: lookup failed: %v", err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "lookup failed"})
|
|
}
|
|
|
|
func writeLookup(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// A lookup is stable for the life of the deployment, so let the browser
|
|
// keep it — repeated right-clicks on the same word are then instant. It is
|
|
// `private` rather than `public` because the gloss is now in *her*
|
|
// language: a shared cache keyed on the URL alone would hand one writer
|
|
// another writer's language.
|
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|