It logged dictionary.Langs(), which is a compile-time constant of the languages DreamDict *supports*. The database deployed until today supported Spanish and contained none of it, so the line printed a confident "[en fr pt-PT es zh]" over a file where every Spanish lookup came back empty — the exact failure the line exists to catch, reported as success. Contents() counts rows per language instead. For a file somebody has to copy onto the box by hand, "what is in it" is the only question worth asking, and the answer is now en=136615 es=102971 fr=56096 pt-PT=136300 zh=120883. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
266 lines
8.9 KiB
Go
266 lines
8.9 KiB
Go
package lexicon
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"sort"
|
|
"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()
|
|
}
|
|
|
|
// Contents reports how many words the open dict.db holds per language, so
|
|
// startup can log what it actually got.
|
|
//
|
|
// It counts rows rather than returning DreamDict's list of supported languages.
|
|
// Those are not the same thing and the difference is the whole point: a
|
|
// database built before Spanish existed still *supports* Spanish, and a log
|
|
// line naming the supported set would have said so cheerfully while every
|
|
// Spanish lookup came back empty. Counting rows is the question worth asking of
|
|
// a file somebody had to copy onto the box by hand.
|
|
func (dd *DreamDict) Contents() string {
|
|
counts, err := dd.d.WordCount()
|
|
if err != nil {
|
|
return "unreadable: " + err.Error()
|
|
}
|
|
langs := make([]string, 0, len(counts))
|
|
for lang := range counts {
|
|
langs = append(langs, lang)
|
|
}
|
|
sort.Strings(langs)
|
|
parts := make([]string, 0, len(langs))
|
|
for _, lang := range langs {
|
|
parts = append(parts, fmt.Sprintf("%s=%d", lang, counts[lang]))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "no words"
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// 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 was precisely this until the database was rebuilt
|
|
// with it on 2026-07-27; the code path did not change, the file did.
|
|
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 + "…"
|
|
}
|