Word lookups now come from DreamDict's dict.db for every pair but Chinese — opened read-only beside petal.db, no service, nothing over the VPN, because a hover gloss has to answer in milliseconds. `Provider` is the two questions the popover and the tooltip already asked, so the embedded *Lexicon satisfies it with no changes at all; Set.For(lang) is the single place the choice between them is made. The prerequisite in the dreamdict repo turned out to be two things, not one: the module path was unfetchable *and* the query layer sat in internal/, which no other module may import whatever the module is called. Both fixed upstream. The plan's central assumption did not survive the data. It mapped Gloss ← Translate(word, "en", L1) one-to-one; against the real 452 MB database that table answers for 17% of the 2,000 commonest English words into pt-PT. Wiktionary's translation sections are thin in that direction — "ephemeral", "think" and "quickly" have no en→pt-PT row at all. Shared WordNet synsets answer for 61%, so DreamDict gained Equivalents() and Petal glosses through it. Ordering those was wrong in an instructive way too: sorting by frequency glosses "think" as lembrar, "remember", because lembrar is the commoner Portuguese word even though pensar shares six of think's synsets to lembrar's one. Counting sense agreement first asks the right question. The same measurement is why zh stays on ECDICT: DreamDict reaches a Chinese gloss for 53% of those words, ECDICT for nearly all of them. The plan said converge only if quality holds. It didn't, so nothing converged. Two decisions about failure worth keeping. A missing dict.db is not an error — a laptop checkout has never had one — but a present-and-never-imported one is, because that is a half-finished deploy. And a pt-PT writer with no dictionary falls back to the embedded datasets with the gloss suppressed, keeping definitions, synonyms and phonetics rather than blanking the popover: an empty field reads as "not found", the wrong language reads as broken. The new fields surface as an etymology line and a three-band chip. Three, not five: the difficulty score separates "everyday" from "you'll have to explain this" but cannot rank obfuscate against serendipity, and a finer scale would be a confident-looking lie. An unscored word gets no chip. Writing the tests found two bugs first — trimEtymology sliced by byte, which would have emitted invalid UTF-8 for exactly the Greek and Latin etymologies the feature exists for, and its ellipsis path overran its own cap. go build/vet/test, tsc, vite, vitest 96/96 clean; live smoke against the real dict.db with one instance flipped from zh to pt-PT mid-run. Not deployed: go.mod still replaces github.com/prosolis/dreamdict with ../dreamdict, so the Docker build needs the two upstream commits pushed and the replace dropped. The deployed dict.db also predates DreamDict's Spanish data. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
114 lines
3.8 KiB
Go
114 lines
3.8 KiB
Go
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)
|
|
}
|