Initial commit: DreamDict HTTP dictionary service

Go-based dictionary backend providing word validation, definitions, synonyms,
and cross-language translation for English, French, and Portuguese. Designed
as a self-hosted replacement for Wordnik, backing GogoBee.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-28 16:57:40 -07:00
commit eeea8d4d34
21 changed files with 3256 additions and 0 deletions

98
internal/dictionary/db.go Normal file
View File

@@ -0,0 +1,98 @@
package dictionary
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
const schemaVersion = "1"
const schemaSQL = `
CREATE TABLE IF NOT EXISTS words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL,
lang TEXT NOT NULL,
pos TEXT,
frequency INTEGER DEFAULT 0,
UNIQUE(word, lang)
);
CREATE INDEX IF NOT EXISTS idx_words_lang ON words(lang);
CREATE INDEX IF NOT EXISTS idx_words_lang_pos ON words(lang, pos);
CREATE INDEX IF NOT EXISTS idx_words_word ON words(word);
CREATE TABLE IF NOT EXISTS definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
pos TEXT,
gloss TEXT NOT NULL,
source TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 99,
UNIQUE(word_id, gloss)
);
CREATE INDEX IF NOT EXISTS idx_definitions_word_id ON definitions(word_id);
CREATE INDEX IF NOT EXISTS idx_definitions_word_id_priority ON definitions(word_id, priority);
CREATE TABLE IF NOT EXISTS synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
synonym TEXT NOT NULL,
source TEXT NOT NULL,
UNIQUE(word_id, synonym)
);
CREATE INDEX IF NOT EXISTS idx_synonyms_word_id ON synonyms(word_id);
CREATE TABLE IF NOT EXISTS translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
translation TEXT NOT NULL,
target_lang TEXT NOT NULL,
source TEXT NOT NULL,
UNIQUE(word_id, translation, target_lang)
);
CREATE INDEX IF NOT EXISTS idx_translations_word_id ON translations(word_id);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
func openDB(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)")
if err != nil {
return nil, fmt.Errorf("dictionary: open db: %w", err)
}
db.SetMaxOpenConns(1) // SQLite write serialization for import
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("dictionary: ping db: %w", err)
}
return db, nil
}
func openReadOnlyDB(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dbPath+"?mode=ro&_pragma=journal_mode(wal)&_pragma=foreign_keys(on)")
if err != nil {
return nil, fmt.Errorf("dictionary: open db (ro): %w", err)
}
// No MaxOpenConns limit — WAL mode supports concurrent reads
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("dictionary: ping db: %w", err)
}
return db, nil
}
func BootstrapSchema(db *sql.DB) error {
_, err := db.Exec(schemaSQL)
if err != nil {
return fmt.Errorf("dictionary: bootstrap schema: %w", err)
}
return nil
}

View File

@@ -0,0 +1,93 @@
package dictionary
import (
"database/sql"
"errors"
)
var (
ErrNoMatch = errors.New("dictionary: no word matches filters")
ErrNotSeeded = errors.New("dictionary: database not seeded, run: go run ./cmd/dictimport")
)
var supportedLangs = []string{"en", "fr", "pt-PT"}
func ValidLang(lang string) bool {
for _, l := range supportedLangs {
if l == lang {
return true
}
}
return false
}
type Options struct {
MinLength int
MaxLength int
POS string // "", "noun", "verb", "adjective", "adverb"
MinFrequency int // 0 = no filter
}
type Definition struct {
POS string `json:"pos"`
Gloss string `json:"gloss"`
Source string `json:"source"`
Priority int `json:"priority"`
}
type Dictionary struct {
db *sql.DB
}
// New opens the database read-write (for import CLI).
func New(dbPath string) (*Dictionary, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
d := &Dictionary{db: db}
if err := d.checkSeeded(); err != nil {
db.Close()
return nil, err
}
return d, nil
}
// NewReadOnly opens the database read-only with concurrent read support (for server).
func NewReadOnly(dbPath string) (*Dictionary, error) {
db, err := openReadOnlyDB(dbPath)
if err != nil {
return nil, err
}
d := &Dictionary{db: db}
if err := d.checkSeeded(); err != nil {
db.Close()
return nil, err
}
return d, nil
}
// NewFromDB creates a Dictionary from an existing *sql.DB (useful for testing).
func NewFromDB(db *sql.DB) *Dictionary {
return &Dictionary{db: db}
}
func (d *Dictionary) Close() error {
return d.db.Close()
}
func (d *Dictionary) checkSeeded() error {
var count int
err := d.db.QueryRow("SELECT COUNT(*) FROM meta").Scan(&count)
if err != nil {
return ErrNotSeeded
}
if count == 0 {
return ErrNotSeeded
}
return nil
}
func (d *Dictionary) SupportedLangs() []string {
return supportedLangs
}

View File

@@ -0,0 +1,313 @@
package dictionary
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(on)")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
if err := BootstrapSchema(db); err != nil {
t.Fatal(err)
}
return db
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("mustExec: %s: %v", query, err)
}
}
func seedTestData(t *testing.T, db *sql.DB) {
t.Helper()
// Meta
mustExec(t, db, "INSERT INTO meta (key, value) VALUES ('schema_version', '1')")
mustExec(t, db, "INSERT INTO meta (key, value) VALUES ('imported_at', '2025-01-01T00:00:00Z')")
// Words
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (1, 'happy', 'en', 'adjective', 0)")
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (2, 'run', 'en', 'verb', 0)")
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (3, 'chat', 'fr', 'noun', 5000)")
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (4, 'gato', 'pt-PT', 'noun', 0)")
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (5, 'cat', 'en', 'noun', 0)")
// Definitions
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling pleasure', 'wordnet', 10)")
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling joy or contentment', 'wiktionary', 20)")
// Synonyms
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'glad', 'wordnet')")
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'joyful', 'wordnet')")
// Translations
mustExec(t, db, "INSERT INTO translations (word_id, translation, target_lang, source) VALUES (5, 'chat', 'fr', 'wiktionary')")
mustExec(t, db, "INSERT INTO translations (word_id, translation, target_lang, source) VALUES (5, 'gato', 'pt-PT', 'wiktionary')")
}
func TestIsValidWord(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
tests := []struct {
word, lang string
want bool
}{
{"happy", "en", true},
{"Happy", "en", true}, // case-insensitive
{"nonexistent", "en", false},
{"chat", "fr", true},
{"chat", "en", false},
}
for _, tt := range tests {
got, err := d.IsValidWord(tt.word, tt.lang)
if err != nil {
t.Errorf("IsValidWord(%q, %q) error: %v", tt.word, tt.lang, err)
continue
}
if got != tt.want {
t.Errorf("IsValidWord(%q, %q) = %v, want %v", tt.word, tt.lang, got, tt.want)
}
}
}
func TestRandomWord(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
// Basic random word
word, err := d.RandomWord("en", Options{})
if err != nil {
t.Fatalf("RandomWord: %v", err)
}
if word == "" {
t.Error("RandomWord returned empty string")
}
// With POS filter
word, err = d.RandomWord("en", Options{POS: "adjective"})
if err != nil {
t.Fatalf("RandomWord with POS: %v", err)
}
if word != "happy" {
t.Errorf("RandomWord(adjective) = %q, want happy", word)
}
// With length filters
word, err = d.RandomWord("en", Options{MinLength: 4, MaxLength: 5})
if err != nil {
t.Fatalf("RandomWord with length: %v", err)
}
if len(word) < 4 || len(word) > 5 {
t.Errorf("RandomWord length %d not in [4,5]", len(word))
}
// No match
_, err = d.RandomWord("en", Options{MinLength: 100})
if err != ErrNoMatch {
t.Errorf("expected ErrNoMatch, got %v", err)
}
}
func TestDefine(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
defs, err := d.Define("happy", "en")
if err != nil {
t.Fatalf("Define: %v", err)
}
if len(defs) != 2 {
t.Fatalf("Define returned %d defs, want 2", len(defs))
}
// Priority ordering: wordnet (10) before wiktionary (20)
if defs[0].Source != "wordnet" {
t.Errorf("first def source = %q, want wordnet", defs[0].Source)
}
if defs[1].Source != "wiktionary" {
t.Errorf("second def source = %q, want wiktionary", defs[1].Source)
}
// No definitions
defs, err = d.Define("run", "en")
if err != nil {
t.Fatalf("Define(run): %v", err)
}
if len(defs) != 0 {
t.Errorf("Define(run) returned %d defs, want 0", len(defs))
}
}
func TestSynonyms(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
syns, err := d.Synonyms("happy", "en")
if err != nil {
t.Fatalf("Synonyms: %v", err)
}
if len(syns) != 2 {
t.Fatalf("Synonyms returned %d, want 2", len(syns))
}
// No synonyms
syns, err = d.Synonyms("run", "en")
if err != nil {
t.Fatalf("Synonyms(run): %v", err)
}
if len(syns) != 0 {
t.Errorf("Synonyms(run) returned %d, want 0", len(syns))
}
}
func TestTranslate(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
trans, err := d.Translate("cat", "en", "fr")
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(trans) != 1 || trans[0] != "chat" {
t.Errorf("Translate(cat, en, fr) = %v, want [chat]", trans)
}
// No translations
trans, err = d.Translate("happy", "en", "fr")
if err != nil {
t.Fatalf("Translate(happy): %v", err)
}
if len(trans) != 0 {
t.Errorf("Translate(happy) returned %d, want 0", len(trans))
}
}
func TestErrNotSeeded(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Don't seed — meta table is empty
d := NewFromDB(db)
err := d.checkSeeded()
if err != ErrNotSeeded {
t.Errorf("expected ErrNotSeeded, got %v", err)
}
}
func TestGlossDeduplication(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Insert duplicate gloss — should be ignored by UNIQUE constraint
_, err := db.Exec("INSERT OR IGNORE INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling pleasure', 'wiktionary', 20)")
if err != nil {
t.Fatal(err)
}
d := NewFromDB(db)
defs, err := d.Define("happy", "en")
if err != nil {
t.Fatal(err)
}
if len(defs) != 2 {
t.Errorf("expected 2 defs after dedup, got %d", len(defs))
}
}
func TestSynonymDeduplication(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
_, err := db.Exec("INSERT OR IGNORE INTO synonyms (word_id, synonym, source) VALUES (1, 'glad', 'wiktionary')")
if err != nil {
t.Fatal(err)
}
d := NewFromDB(db)
syns, err := d.Synonyms("happy", "en")
if err != nil {
t.Fatal(err)
}
if len(syns) != 2 {
t.Errorf("expected 2 synonyms after dedup, got %d", len(syns))
}
}
func TestTranslationDeduplication(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
_, err := db.Exec("INSERT OR IGNORE INTO translations (word_id, translation, target_lang, source) VALUES (5, 'chat', 'fr', 'other')")
if err != nil {
t.Fatal(err)
}
d := NewFromDB(db)
trans, err := d.Translate("cat", "en", "fr")
if err != nil {
t.Fatal(err)
}
if len(trans) != 1 {
t.Errorf("expected 1 translation after dedup, got %d", len(trans))
}
}
func TestMeta(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
val, err := d.Meta("schema_version")
if err != nil {
t.Fatal(err)
}
if val != "1" {
t.Errorf("Meta(schema_version) = %q, want 1", val)
}
// Missing key returns empty string, no error
val, err = d.Meta("nonexistent")
if err != nil {
t.Fatal(err)
}
if val != "" {
t.Errorf("Meta(nonexistent) = %q, want empty", val)
}
}
func TestWordCount(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
d := NewFromDB(db)
counts, err := d.WordCount()
if err != nil {
t.Fatal(err)
}
if counts["en"] != 3 {
t.Errorf("en word count = %d, want 3", counts["en"])
}
if counts["fr"] != 1 {
t.Errorf("fr word count = %d, want 1", counts["fr"])
}
}

View File

@@ -0,0 +1,193 @@
package dictionary
import (
"database/sql"
"fmt"
"strings"
)
func (d *Dictionary) IsValidWord(word, lang string) (bool, error) {
var exists bool
err := d.db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM words WHERE word = ? AND lang = ?)",
strings.ToLower(word), lang,
).Scan(&exists)
if err != nil {
return false, fmt.Errorf("dictionary: is valid word: %w", err)
}
return exists, nil
}
func (d *Dictionary) RandomWord(lang string, opts Options) (string, error) {
query := "SELECT word FROM words WHERE lang = ?"
args := []any{lang}
if opts.POS != "" {
query += " AND pos = ?"
args = append(args, opts.POS)
}
if opts.MinLength > 0 {
query += " AND LENGTH(word) >= ?"
args = append(args, opts.MinLength)
}
if opts.MaxLength > 0 {
query += " AND LENGTH(word) <= ?"
args = append(args, opts.MaxLength)
}
if opts.MinFrequency > 0 {
query += " AND frequency >= ?"
args = append(args, opts.MinFrequency)
}
query += " ORDER BY RANDOM() LIMIT 1"
var word string
err := d.db.QueryRow(query, args...).Scan(&word)
if err == sql.ErrNoRows {
return "", ErrNoMatch
}
if err != nil {
return "", fmt.Errorf("dictionary: random word: %w", err)
}
return word, nil
}
func (d *Dictionary) Define(word, lang string) ([]Definition, error) {
rows, err := d.db.Query(`
SELECT d.pos, d.gloss, d.source, d.priority
FROM definitions d
JOIN words w ON w.id = d.word_id
WHERE w.word = ? AND w.lang = ?
ORDER BY d.priority ASC, d.id ASC`,
strings.ToLower(word), lang,
)
if err != nil {
return nil, fmt.Errorf("dictionary: define: %w", err)
}
defer rows.Close()
var defs []Definition
for rows.Next() {
var def Definition
var pos sql.NullString
if err := rows.Scan(&pos, &def.Gloss, &def.Source, &def.Priority); err != nil {
return nil, fmt.Errorf("dictionary: define scan: %w", err)
}
def.POS = pos.String
defs = append(defs, def)
}
if defs == nil {
defs = []Definition{}
}
return defs, rows.Err()
}
func (d *Dictionary) Synonyms(word, lang string) ([]string, error) {
rows, err := d.db.Query(`
SELECT DISTINCT s.synonym
FROM synonyms s
JOIN words w ON w.id = s.word_id
WHERE w.word = ? AND w.lang = ?
ORDER BY s.synonym`,
strings.ToLower(word), lang,
)
if err != nil {
return nil, fmt.Errorf("dictionary: synonyms: %w", err)
}
defer rows.Close()
var syns []string
for rows.Next() {
var syn string
if err := rows.Scan(&syn); err != nil {
return nil, fmt.Errorf("dictionary: synonyms scan: %w", err)
}
syns = append(syns, syn)
}
if syns == nil {
syns = []string{}
}
return syns, rows.Err()
}
func (d *Dictionary) Translate(word, fromLang, toLang string) ([]string, error) {
rows, err := d.db.Query(`
SELECT DISTINCT t.translation
FROM translations t
JOIN words w ON w.id = t.word_id
WHERE w.word = ? AND w.lang = ? AND t.target_lang = ?
ORDER BY t.translation`,
strings.ToLower(word), fromLang, toLang,
)
if err != nil {
return nil, fmt.Errorf("dictionary: translate: %w", err)
}
defer rows.Close()
var trans []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, fmt.Errorf("dictionary: translate scan: %w", err)
}
trans = append(trans, t)
}
if trans == nil {
trans = []string{}
}
return trans, rows.Err()
}
func (d *Dictionary) WordCount() (map[string]int, error) {
rows, err := d.db.Query("SELECT lang, COUNT(*) FROM words GROUP BY lang")
if err != nil {
return nil, fmt.Errorf("dictionary: word count: %w", err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var lang string
var count int
if err := rows.Scan(&lang, &count); err != nil {
return nil, fmt.Errorf("dictionary: word count scan: %w", err)
}
counts[lang] = count
}
return counts, rows.Err()
}
func (d *Dictionary) Meta(key string) (string, error) {
var val string
err := d.db.QueryRow("SELECT value FROM meta WHERE key = ?", key).Scan(&val)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("dictionary: meta %s: %w", key, err)
}
return val, nil
}
func (d *Dictionary) DefCount() (map[string]int, error) {
rows, err := d.db.Query(`
SELECT w.lang, COUNT(*)
FROM definitions d
JOIN words w ON w.id = d.word_id
GROUP BY w.lang`)
if err != nil {
return nil, fmt.Errorf("dictionary: def count: %w", err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var lang string
var count int
if err := rows.Scan(&lang, &count); err != nil {
return nil, fmt.Errorf("dictionary: def count scan: %w", err)
}
counts[lang] = count
}
return counts, rows.Err()
}