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:
98
internal/dictionary/db.go
Normal file
98
internal/dictionary/db.go
Normal 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
|
||||
}
|
||||
93
internal/dictionary/dict.go
Normal file
93
internal/dictionary/dict.go
Normal 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
|
||||
}
|
||||
313
internal/dictionary/dict_test.go
Normal file
313
internal/dictionary/dict_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
193
internal/dictionary/query.go
Normal file
193
internal/dictionary/query.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user