package lexicon import ( "context" "database/sql" "encoding/json" "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 } // 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) } func writeLookupErr(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) } 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) }