Files
petal/internal/lexicon/handlers.go
T
prosolis 77f284f65c The zh pair's other direction, and a rule pack that mostly says no
`pair_lang` had always been answering a second question nobody asked: it
says which two languages, and every surface built on it assumed English
was the one being learned. That is why hanzi is never tokenized, never
spell-checked, never glossed — correct for a Mandarin native practising
English, backwards for an English native practising Mandarin.
`users.direction` (migration 0016) separates the two questions; a
`zh-learner` pair code would have been cheaper and would have made two
directions of one pair look like two unrelated languages to every query.

Segmentation is what replaces `wordAt` where there are no spaces: a
shortest-path walk over log-probabilities, 232 ms and 14 MB for 188,522
words. The browser gets the word list because segmentation runs on hover;
the server keeps the whole dictionary. Their coverage gates come out
opposite on purpose — the client list is frequency-gated because the
segmentation is measurably identical without the tail, and the dictionary
is gated by nothing, because its only power is to explain and the word a
learner stops on is the rare one.

The 错别字 pack is 24 confusable pairs behind two mechanical gates. One
admits a pair only if the wrong form is not a dictionary word and the
right form is, which is why it refuses 自已 for 自己 — a real error whose
wrong form is a headword. The other asks the segmenter whether the two
characters already belong to two different words, without which 自己经常,
睡觉的时候 and 不知到底 would all be corrupted silently into text still
made of real characters.

Not deployed (this carries a migration), not seen in a browser, and no
account has ever been in the learner direction. The IME composition
guards were in scope and are not done — see BUILD_PLAN Phase 26.
2026-07-28 19:04:53 -07:00

150 lines
5.3 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, and
// [auth.SupportsLearnerDirection] already guarantees that language is Chinese.
// Routing it through the pair would add a database read per hover to choose
// between one option and itself.
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)
}