package lexicon import ( "bytes" "compress/gzip" "encoding/json" "fmt" "io" "strings" "sync" ) // Meaning is one sense of a word: its part of speech, the gloss, and an optional // usage example. The frontend renders a few of these in the word popover. type Meaning struct { PartOfSpeech string `json:"part_of_speech"` Definition string `json:"definition"` Example string `json:"example,omitempty"` } // Result is the full lookup for one word. Either list may be empty (the word // isn't a headword in that dataset); the frontend handles a partial or empty // result gracefully. Gloss is the Chinese translation (empty when the word isn't // in the gloss dataset) — shown first in the popover for the Mandarin-speaking // writer. type Result struct { Word string `json:"word"` Gloss string `json:"gloss"` Phonetic string `json:"phonetic"` // IPA for the English word; "" when absent Definitions []Meaning `json:"definitions"` Synonyms []string `json:"synonyms"` } // GlossResult is the lightweight payload for the inline hover/select gloss: just // the word and its Chinese translation, no definitions or synonyms. Kept small // so the hover tooltip is instant and trivially cacheable. type GlossResult struct { Word string `json:"word"` Gloss string `json:"gloss"` } // maxSynonyms caps how many synonyms we hand the popover, even though the dataset // stores up to ~50 per word — a long flat wall of words overwhelms more than it // helps, especially for an ESL reader scanning for the right fit. const maxSynonyms = 16 // maxDefinitions caps the senses shown so the popover stays a glance, not an // essay. const maxDefinitions = 4 // Lexicon holds the lazily-loaded datasets. The maps are populated once, on the // first Lookup, behind a sync.Once so startup stays instant and a load error is // remembered rather than retried on every request. type Lexicon struct { once sync.Once loadErr error defs map[string][][]string // word → [[pos, def, example], …] synonyms map[string][]string // word → [synonym, …] gloss map[string]string // word → Chinese gloss phonetic map[string]string // word → IPA } // New returns a Lexicon. The datasets aren't read until the first Lookup. func New() *Lexicon { return &Lexicon{} } func (l *Lexicon) load() { l.once.Do(func() { if err := gunzipJSON(definitionsGz, &l.defs); err != nil { l.loadErr = fmt.Errorf("load definitions: %w", err) return } if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil { l.loadErr = fmt.Errorf("load synonyms: %w", err) return } if err := gunzipJSON(glossGz, &l.gloss); err != nil { l.loadErr = fmt.Errorf("load gloss: %w", err) return } if err := gunzipJSON(phoneticGz, &l.phonetic); err != nil { l.loadErr = fmt.Errorf("load phonetic: %w", err) return } }) } // Lookup returns the definition senses and synonyms for word. It first tries the // word as written (lowercased), then a few simple morphological reductions // (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found // in neither dataset yields a Result with empty lists (not an error). func (l *Lexicon) Lookup(word string) (Result, error) { l.load() if l.loadErr != nil { return Result{}, l.loadErr } norm := strings.ToLower(strings.TrimSpace(word)) res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}} if norm == "" { return res, nil } res.Gloss = lookupGloss(l.gloss, norm) // Phonetic is a word→string map like the gloss, so the same de-inflecting // candidate walk resolves "running" → "run", etc. res.Phonetic = lookupGloss(l.phonetic, norm) if raw := lookupDefs(l.defs, norm); raw != nil { for _, m := range raw { res.Definitions = append(res.Definitions, toMeaning(m)) if len(res.Definitions) >= maxDefinitions { break } } } if syns := lookupSyns(l.synonyms, norm); syns != nil { if len(syns) > maxSynonyms { syns = syns[:maxSynonyms] } res.Synonyms = append(res.Synonyms, syns...) } return res, nil } // Gloss returns just the Chinese translation for word (empty when absent). This // is the fast path behind the inline hover/select gloss — it skips the // definition and synonym datasets entirely. func (l *Lexicon) Gloss(word string) (GlossResult, error) { l.load() if l.loadErr != nil { return GlossResult{}, l.loadErr } norm := strings.ToLower(strings.TrimSpace(word)) return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil } // lookupGloss walks the candidate forms of a word and returns the first gloss // hit (so "running"/"studies" resolve via the same de-inflection as defs/syns). func lookupGloss(m map[string]string, word string) string { if word == "" { return "" } for _, c := range candidates(word) { if v, ok := m[c]; ok { return v } } return "" } // lookupDefs / lookupSyns walk the candidate forms of a word and return the // first dataset hit. They're separate (rather than a generic helper) only // because the two maps have different value types. func lookupDefs(m map[string][][]string, word string) [][]string { for _, c := range candidates(word) { if v, ok := m[c]; ok { return v } } return nil } func lookupSyns(m map[string][]string, word string) []string { for _, c := range candidates(word) { if v, ok := m[c]; ok { return v } } return nil } // candidates returns the lemma forms to try, in priority order: the word itself, // then conservative de-inflections. This is deliberately lightweight — a full // stemmer would over-reduce ("business" → "busy") and surface wrong entries; a // handful of common English suffix rules covers the everyday cases without a // dependency. Duplicates are fine (map lookup is cheap); order is what matters. func candidates(word string) []string { out := []string{word} add := func(s string) { if len(s) >= 2 && s != word { out = append(out, s) } } switch { case strings.HasSuffix(word, "ies"): // studies → study add(word[:len(word)-3] + "y") case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish add(word[:len(word)-2]) add(word[:len(word)-1]) case strings.HasSuffix(word, "s"): // cats → cat add(word[:len(word)-1]) } if strings.HasSuffix(word, "ing") { // running → run, making → make stem := word[:len(word)-3] add(stem) add(stem + "e") add(undouble(stem)) } if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop stem := word[:len(word)-2] add(stem) add(word[:len(word)-1]) add(undouble(stem)) } if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick add(word[:len(word)-2]) } if strings.HasSuffix(word, "ily") { // happily → happy add(word[:len(word)-3] + "y") } return out } // undouble collapses a doubled final consonant (stopp → stop, runn → run) so the // -ed/-ing stems of doubled-consonant verbs resolve to their base form. func undouble(stem string) string { n := len(stem) if n >= 2 && stem[n-1] == stem[n-2] { return stem[:n-1] } return stem } // toMeaning maps a compact [pos, def, example] triple from the dataset onto the // JSON-friendly Meaning. The dataset always stores three elements, but we guard // the length so a malformed row can't panic. func toMeaning(m []string) Meaning { var out Meaning if len(m) > 0 { out.PartOfSpeech = m[0] } if len(m) > 1 { out.Definition = m[1] } if len(m) > 2 { out.Example = m[2] } return out } // gunzipJSON decompresses gz and decodes the JSON into v. func gunzipJSON(gz []byte, v any) error { r, err := gzip.NewReader(bytes.NewReader(gz)) if err != nil { return err } defer r.Close() data, err := io.ReadAll(r) if err != nil { return err } return json.Unmarshal(data, v) }