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 + "…" }