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
239 lines
8.0 KiB
Go
239 lines
8.0 KiB
Go
package lexicon
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/prosolis/dreamdict/dictionary"
|
|
)
|
|
|
|
// DreamDict is a read-only handle on a built dict.db — one SQLite file holding
|
|
// English, French, European Portuguese, Spanish and Mandarin.
|
|
//
|
|
// It is a second database beside petal.db and is never written to: the file is
|
|
// built by DreamDict's own import CLI a few times a year, and Petal only reads
|
|
// it. That is what makes importing the package the right shape rather than
|
|
// running DreamDict as a service — a hover gloss should not depend on a second
|
|
// process being up, still less on one reachable across a VPN.
|
|
type DreamDict struct {
|
|
d *dictionary.Dictionary
|
|
}
|
|
|
|
// OpenDreamDict opens dict.db read-only.
|
|
//
|
|
// A missing file returns (nil, nil), not an error. Petal is expected to run
|
|
// without dict.db — a laptop checkout has never had one, and the zh pair does
|
|
// not need one — so "the file isn't there" is a deployment state the caller
|
|
// handles by carrying on. A file that is *present but unusable* (corrupt, or
|
|
// never imported) does return an error, because that one is a mistake someone
|
|
// should hear about.
|
|
func OpenDreamDict(path string) (*DreamDict, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, nil
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
d, err := dictionary.NewReadOnly(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DreamDict{d: d}, nil
|
|
}
|
|
|
|
// Close releases the dict.db handle. Safe on a nil DreamDict, so a caller that
|
|
// never got one can defer it unconditionally.
|
|
func (dd *DreamDict) Close() error {
|
|
if dd == nil {
|
|
return nil
|
|
}
|
|
return dd.d.Close()
|
|
}
|
|
|
|
// Langs returns the language codes dict.db was built with, so startup can log
|
|
// what it actually got rather than what it hoped for.
|
|
func (dd *DreamDict) Langs() []string { return dictionary.Langs() }
|
|
|
|
// dreamProvider serves one writer: English lookups from dict.db, glossed into
|
|
// native. The struct is a value, created per request by [Set.For] — it holds no
|
|
// state beyond the shared handle and the language to translate into.
|
|
type dreamProvider struct {
|
|
dict *DreamDict
|
|
native string // the writer's language, e.g. "pt-PT"
|
|
}
|
|
|
|
// maxEtymology caps the free-form Wiktionary etymology. It is the one field
|
|
// with no natural length: some entries are a clause, some are four paragraphs
|
|
// tracing a word through three dead languages. The popover wants a line.
|
|
const maxEtymology = 220
|
|
|
|
// Lookup fills a Result from dict.db.
|
|
//
|
|
// The word is de-inflected with the same [candidates] walk the embedded
|
|
// datasets use, because dict.db stores headwords: "running" has no definitions
|
|
// row of its own. The first candidate that *has* definitions becomes the
|
|
// headword every other field is then read from, so a single popover never
|
|
// mixes "running"'s frequency with "run"'s definitions.
|
|
//
|
|
// The gloss is walked separately. A word can be absent from the definitions
|
|
// table and still have a translation (and vice versa), and the hover tooltip
|
|
// asks for the gloss alone — so tying it to the definition headword would lose
|
|
// glosses for no benefit.
|
|
func (p dreamProvider) Lookup(word string) (Result, error) {
|
|
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}, Difficulty: unknownDifficulty}
|
|
norm := strings.ToLower(strings.TrimSpace(word))
|
|
if norm == "" {
|
|
return res, nil
|
|
}
|
|
|
|
head := norm
|
|
for _, c := range candidates(norm) {
|
|
defs, err := p.dict.d.Define(c, langEN)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if len(defs) == 0 {
|
|
continue
|
|
}
|
|
head = c
|
|
for _, d := range defs {
|
|
// DreamDict orders by source priority, so the curated senses
|
|
// (WordNet, WOLF) are already ahead of the Wiktionary tail — taking
|
|
// the first few is taking the best few.
|
|
res.Definitions = append(res.Definitions, Meaning{PartOfSpeech: d.POS, Definition: d.Gloss})
|
|
if len(res.Definitions) >= maxDefinitions {
|
|
break
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
gloss, err := p.translate(norm)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
res.Gloss = gloss
|
|
|
|
syns, err := p.dict.d.Synonyms(head, langEN)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if len(syns) > maxSynonyms {
|
|
syns = syns[:maxSynonyms]
|
|
}
|
|
res.Synonyms = append(res.Synonyms, syns...)
|
|
|
|
prons, err := p.dict.d.Pronunciation(head, langEN)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
res.Phonetic = pickIPA(prons)
|
|
|
|
if res.Frequency, err = p.dict.d.Frequency(head, langEN); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if res.Difficulty, err = p.dict.d.Difficulty(head, langEN); err != nil {
|
|
return Result{}, err
|
|
}
|
|
ety, err := p.dict.d.Etymology(head, langEN)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
res.Etymology = trimEtymology(ety)
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// Gloss returns the writer's-language translation alone — the hover tooltip's
|
|
// fast path, one indexed query per candidate form and nothing else.
|
|
func (p dreamProvider) Gloss(word string) (GlossResult, error) {
|
|
norm := strings.ToLower(strings.TrimSpace(word))
|
|
if norm == "" {
|
|
return GlossResult{Word: word}, nil
|
|
}
|
|
gloss, err := p.translate(norm)
|
|
if err != nil {
|
|
return GlossResult{}, err
|
|
}
|
|
return GlossResult{Word: word, Gloss: gloss}, nil
|
|
}
|
|
|
|
// maxGlossSenses caps how many translations are strung together. One is often
|
|
// too thin to disambiguate; the whole list is a wall of words in a tooltip.
|
|
const maxGlossSenses = 3
|
|
|
|
// translate walks the candidate forms and returns the first that has an
|
|
// equivalent in the writer's language, joined into one line.
|
|
//
|
|
// It asks for Equivalents rather than Translate on the strength of measuring
|
|
// both against the real dict.db: Wiktionary's en→pt-PT translation table
|
|
// answers for 17% of the 2,000 commonest English words, and the shared-synset
|
|
// path answers for 62%. The plan assumed Translate would do — the database
|
|
// says otherwise, and a gloss that is absent five times out of six is not a
|
|
// gloss. Equivalents falls back to Translate internally, so nothing is lost.
|
|
//
|
|
// A language dict.db was built without simply has no rows, so this returns "" —
|
|
// which is exactly what an unglossed word returns, and the popover already
|
|
// renders that case. Spanish today is precisely this: supported by DreamDict,
|
|
// absent from the deployed database until it is rebuilt.
|
|
func (p dreamProvider) translate(norm string) (string, error) {
|
|
for _, c := range candidates(norm) {
|
|
trs, err := p.dict.d.Equivalents(c, langEN, p.native)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(trs) == 0 {
|
|
continue
|
|
}
|
|
if len(trs) > maxGlossSenses {
|
|
trs = trs[:maxGlossSenses]
|
|
}
|
|
return strings.Join(trs, "; "), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// pickIPA chooses what to show beside the read-aloud button. IPA is the only
|
|
// form worth showing a learner — CMU's "IH0 F EH1 M ER0 AH0 L" is a machine
|
|
// format, and printing it would be noise dressed up as help. If there's no IPA,
|
|
// there's no phonetic line.
|
|
func pickIPA(prons []dictionary.Pronunciation) string {
|
|
for _, p := range prons {
|
|
if strings.EqualFold(p.Format, "ipa") && strings.TrimSpace(p.Value) != "" {
|
|
return strings.Trim(strings.TrimSpace(p.Value), "/[]")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// trimEtymology cuts Wiktionary's prose down to a line, preferring to stop at a
|
|
// sentence boundary so the result reads as a finished thought rather than a
|
|
// truncation.
|
|
func trimEtymology(text string) string {
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
if utf8.RuneCountInString(text) <= maxEtymology {
|
|
return text
|
|
}
|
|
// Counted and cut in runes, not bytes. An etymology is the one field that
|
|
// is *mostly* not English — ἐφήμερος, ephemerus, 短暫 — and a byte slice
|
|
// through the middle of one of those characters is invalid UTF-8 in the
|
|
// JSON response.
|
|
cut := string([]rune(text)[:maxEtymology-1])
|
|
// Stop at a sentence when one ends late enough to be worth keeping. An
|
|
// early full stop ("From Latin. …") is not a summary, it's a discarded
|
|
// paragraph, so that case falls through to the word-boundary cut.
|
|
if i := strings.LastIndex(cut, ". "); i > len(cut)/2 {
|
|
return cut[:i+1]
|
|
}
|
|
if i := strings.LastIndex(cut, " "); i > 0 {
|
|
cut = cut[:i]
|
|
}
|
|
return cut + "…"
|
|
}
|