Files
petal/internal/lexicon/dreamdict.go
T
prosolis 74bf600593 Make the dictionary startup line report rows, not capabilities
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
2026-07-27 10:51:09 -07:00

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