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) }