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
281 lines
8.8 KiB
Go
281 lines
8.8 KiB
Go
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"`
|
|
|
|
// The fields below only ever come from DreamDict; the embedded datasets
|
|
// leave them at their unknown values, and the popover hides them.
|
|
|
|
// Frequency is how common the word is (higher = more common). 0 means
|
|
// unknown, which is DreamDict's own convention — a word it carries but has
|
|
// no corpus count for is indistinguishable from a word it doesn't carry,
|
|
// and the popover treats both the same way.
|
|
Frequency int `json:"frequency"`
|
|
// Difficulty runs 0.0 (easiest) to 1.0 (hardest); -1 means unknown. It is a
|
|
// sentinel rather than an omitted field because 0.0 is a real, meaningful
|
|
// score and `omitempty` would erase it.
|
|
Difficulty float64 `json:"difficulty"`
|
|
// Etymology is free-form Wiktionary prose, trimmed to a line. Where the
|
|
// word came from is a real hook for a writer whose own language shares
|
|
// Latin roots with English — "ephemeral" is much easier to keep once you
|
|
// have seen efémero next to it.
|
|
Etymology string `json:"etymology"`
|
|
}
|
|
|
|
// unknownDifficulty is the [Result.Difficulty] value meaning "no score",
|
|
// matching DreamDict's own -1 return.
|
|
const unknownDifficulty = -1
|
|
|
|
// 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{}, Difficulty: unknownDifficulty}
|
|
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)
|
|
}
|