diff options
| author | David Schlachter <t480-debian-git@schlachter.ca> | 2026-01-11 23:10:26 -0500 |
|---|---|---|
| committer | David Schlachter <t480-debian-git@schlachter.ca> | 2026-01-11 23:10:26 -0500 |
| commit | 068ef1f9ac1fe551b97b9d5aec224369ebe015fd (patch) | |
| tree | 9efc20ddfc2471e2096c0de14203a6e58abdfb57 | |
| parent | 18a0b77981bc1590f558341870f8d35f8aec23c9 (diff) | |
Move dictionary preparation into the bubbletea app
| -rw-r--r-- | main.go | 8 | ||||
| -rw-r--r-- | setup.go | 234 | ||||
| -rw-r--r-- | ui.go | 55 |
3 files changed, 154 insertions, 143 deletions
@@ -41,16 +41,14 @@ func main() { log.Fatalf("Failed to create or open dictionary at '%s': %s", *dict, err) } defer db.Close() - - err = setupDatabase(*rawDict, db) - if err != nil { - log.Fatalf("Failed to set up database: %s", err) + if err = setupTables(db); err != nil { + log.Fatalf("Failed to create database tables in dictionary '%s': %s", *dict, err) } c := http.DefaultClient c.Timeout = 5 * time.Second - p := tea.NewProgram(initialModel(c, db, *apiURL, *deckName, *modelName, *initialWord)) + p := tea.NewProgram(initialModel(c, db, *apiURL, *deckName, *modelName, *rawDict, *initialWord)) if _, err := p.Run(); err != nil { log.Fatalf("Unexpected error encountered while running program: %s", err) } @@ -5,43 +5,42 @@ import ( "database/sql" "fmt" "html/template" - "log" "os" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/goccy/go-json" ) -func setupDatabase(rawDictionary string, db *sql.DB) error { +func setupTables(db *sql.DB) error { _, err := db.Exec("create table IF NOT EXISTS words (word text not null, definition text);") if err != nil { return fmt.Errorf("creating table: %s", err) } - row := db.QueryRow(`SELECT count(*) as count from words`) - var count int - err = row.Scan(&count) - if err != nil { - return fmt.Errorf("counting rows: %s", err) - } - - // Only populate the database if it is empty. - if count > 0 { - return nil - } - // Faster import performance. _, err = db.Exec("PRAGMA synchronous = OFF;") if err != nil { return fmt.Errorf("setting risky writes: %s", err) } - if err = populateDictionary(rawDictionary, db); err != nil { - return fmt.Errorf("failed to prepare dictionary: %s", err) - } return nil } +func isDatabaseEmpty(db *sql.DB) tea.Cmd { + return func() tea.Msg { + row := db.QueryRow(`SELECT count(*) as count from words`) + var count int + err := row.Scan(&count) + if err != nil { + return errMsg(fmt.Errorf("counting rows: %s", err)) + } + + // Only populate the database if it is empty. + return isDBEmptyMsg(count == 0) + } +} + type rawDictionaryEntry struct { Word string `json:"word"` LangCode string `json:"lang_code"` @@ -79,131 +78,126 @@ type SenseForDictionaryEntry struct { Example string } -func populateDictionary(rawDictionary string, db *sql.DB) error { - log.Printf("preparing sqlite database from raw dictionary data...") - - // Set up the template - tmpl, err := template.New("entry").Parse( - `<p>{{ .Word }} {{ .Sound }} <i>{{ .POS }} {{ .Gender }}</i></p> +func populateDictionary(rawDictionary string, db *sql.DB) tea.Cmd { + return func() tea.Msg { + // Set up the template + tmpl, err := template.New("entry").Parse( + `<p>{{ .Word }} {{ .Sound }} <i>{{ .POS }} {{ .Gender }}</i></p> <ol>{{ range .Senses}} <li class=sense>{{ .Sense }}<br> {{ if .Example }}<ul><li><i>{{ .Example }}</i></li></ul></li>{{ end }} {{ end }}</ol> {{ if .Etymology }}<p><i>Étymologie: {{ .Etymology }}</i>{{ end }}`) - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("starting transaction: %w", err) - } + tx, err := db.Begin() + if err != nil { + return errMsg(fmt.Errorf("starting transaction: %w", err)) + } - // Set up a prepared statement - stmt, err := tx.Prepare("insert into words(word, definition) values(?, ?)") - if err != nil { - return fmt.Errorf("preparing statement: %w", err) - } - defer stmt.Close() + // Set up a prepared statement + stmt, err := tx.Prepare("insert into words(word, definition) values(?, ?)") + if err != nil { + return errMsg(fmt.Errorf("preparing statement: %w", err)) + } + defer stmt.Close() - file, err := os.Open(rawDictionary) - if err != nil { - return fmt.Errorf("opening: %w", err) - } - defer file.Close() + file, err := os.Open(rawDictionary) + if err != nil { + return errMsg(fmt.Errorf("opening: %w", err)) + } + defer file.Close() - var wordsAdded int - scanner := bufio.NewScanner(file) + var wordsAdded int + scanner := bufio.NewScanner(file) - maxCapacity := 2_000_000 - buf := make([]byte, maxCapacity) - scanner.Buffer(buf, maxCapacity) + maxCapacity := 2_000_000 + buf := make([]byte, maxCapacity) + scanner.Buffer(buf, maxCapacity) - for scanner.Scan() { - var result rawDictionaryEntry - json.Unmarshal([]byte(scanner.Text()), &result) - if result.LangCode != "fr" { - continue - } + for scanner.Scan() { + var result rawDictionaryEntry + json.Unmarshal([]byte(scanner.Text()), &result) + if result.LangCode != "fr" { + continue + } - // Clean up the word. Replace apostrophes (common in phrases) with - // single quotes (more likely to be typed by a user). - result.Word = strings.ReplaceAll(result.Word, `’`, `'`) + // Clean up the word. Replace apostrophes (common in phrases) with + // single quotes (more likely to be typed by a user). + result.Word = strings.ReplaceAll(result.Word, `’`, `'`) - // Create the definition text. - entry := templateReadyDictionaryEntry{ - Word: result.Word, - POS: strings.ToLower(result.POS), - } - if len(result.Etymology) > 0 { - entry.Etymology = result.Etymology[0] - } - if len(result.Sounds) > 0 { - entry.Sound = result.Sounds[0].IPA - } + // Create the definition text. + entry := templateReadyDictionaryEntry{ + Word: result.Word, + POS: strings.ToLower(result.POS), + } + if len(result.Etymology) > 0 { + entry.Etymology = result.Etymology[0] + } + if len(result.Sounds) > 0 { + entry.Sound = result.Sounds[0].IPA + } - var genders, numbers []string - for _, r := range result.Tags { - switch r { - case "masculine": - genders = append(genders, "masculin") - case "feminine": - genders = append(genders, "féminin") - case "plural": - numbers = append(numbers, "pluriel") - case "singular": - numbers = append(numbers, "singulier") + var genders, numbers []string + for _, r := range result.Tags { + switch r { + case "masculine": + genders = append(genders, "masculin") + case "feminine": + genders = append(genders, "féminin") + case "plural": + numbers = append(numbers, "pluriel") + case "singular": + numbers = append(numbers, "singulier") + } } - } - entry.Gender = strings.Join( - []string{ - strings.Join(genders, " / "), - strings.Join(numbers, " et "), - }, - " ", - ) - - for _, s := range result.Senses { - var example string - if len(s.Examples) > 0 { - example = s.Examples[0].Text + entry.Gender = strings.Join( + []string{ + strings.Join(genders, " / "), + strings.Join(numbers, " et "), + }, + " ", + ) + + for _, s := range result.Senses { + var example string + if len(s.Examples) > 0 { + example = s.Examples[0].Text + } + sense := strings.Join(s.Glosses, "; ") + entry.Senses = append(entry.Senses, SenseForDictionaryEntry{Sense: sense, Example: example}) } - sense := strings.Join(s.Glosses, "; ") - entry.Senses = append(entry.Senses, SenseForDictionaryEntry{Sense: sense, Example: example}) - } - formattedDefinition := strings.Builder{} - err := tmpl.Execute(&formattedDefinition, entry) - if err != nil { - return fmt.Errorf("failed to render: %w", err) - } + formattedDefinition := strings.Builder{} + err := tmpl.Execute(&formattedDefinition, entry) + if err != nil { + return errMsg(fmt.Errorf("failed to render: %w", err)) + } - // Insert the entry - _, err = stmt.Exec(entry.Word, formattedDefinition.String()) - if err != nil { - return fmt.Errorf("inserting '%s': %w", entry.Word, err) - } + // Insert the entry + _, err = stmt.Exec(entry.Word, formattedDefinition.String()) + if err != nil { + return errMsg(fmt.Errorf("inserting '%s': %w", entry.Word, err)) + } - wordsAdded++ - if wordsAdded%100_000 == 0 && wordsAdded > 1 { - log.Printf("processed %d lines (most recent word was '%s')", wordsAdded, entry.Word) + wordsAdded++ + + } + if err := scanner.Err(); err != nil { + return errMsg(fmt.Errorf("scanning: %w", err)) } - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("scanning: %w", err) - } + if err := tx.Commit(); err != nil { + return errMsg(fmt.Errorf("committing: %w", err)) + } - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing: %w", err) - } + _, err = db.Exec("create index wordindex on words(word);") + if err != nil { + return errMsg(fmt.Errorf("creating index: %s", err)) + } - _, err = db.Exec("create index wordindex on words(word);") - if err != nil { - return fmt.Errorf("creating index: %s", err) + return isDBEmptyMsg(false) } - - log.Printf("prepared %d dictionary entries", wordsAdded) - - return nil } @@ -23,9 +23,12 @@ type model struct { wordInput textinput.Model definitionViewport viewport.Model - ankiDeck string - ankiModel string - apiURL string + ankiDeck string + ankiModel string + apiURL string + rawDictionaryPath string + + dictionaryReady bool currentWord string currentDefinition string @@ -37,9 +40,10 @@ type ( errMsg error definitionMsg string wordAddedMsg string + isDBEmptyMsg bool ) -func initialModel(c *http.Client, db *sql.DB, apiURL, ankiDeck, ankiModel, firstWord string) model { +func initialModel(c *http.Client, db *sql.DB, apiURL, ankiDeck, ankiModel, rawDictionary, firstWord string) model { input := textinput.New() input.Placeholder = "" input.Focus() @@ -70,9 +74,10 @@ func initialModel(c *http.Client, db *sql.DB, apiURL, ankiDeck, ankiModel, first wordInput: input, definitionViewport: textbox, - ankiDeck: ankiDeck, - ankiModel: ankiModel, - apiURL: apiURL, + ankiDeck: ankiDeck, + ankiModel: ankiModel, + apiURL: apiURL, + rawDictionaryPath: rawDictionary, currentWord: firstWord, } @@ -80,9 +85,7 @@ func initialModel(c *http.Client, db *sql.DB, apiURL, ankiDeck, ankiModel, first func (m model) Init() tea.Cmd { cmds := []tea.Cmd{textinput.Blink} - if m.currentWord != "" { - cmds = append(cmds, lookupWord(m.db, m.currentWord)) - } + cmds = append(cmds, isDatabaseEmpty(m.db)) return tea.Batch(cmds...) } @@ -92,6 +95,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var textPickerLongerCmds []tea.Cmd switch msg := msg.(type) { + case isDBEmptyMsg: + if bool(msg) { + // set up the database + return m, populateDictionary(m.rawDictionaryPath, m.db) + } else { + m.dictionaryReady = true + if m.currentWord != "" { + textPickerLongerCmds = append(textPickerLongerCmds, lookupWord(m.db, m.currentWord)) + } + } case definitionMsg: m.currentDefinition = string(msg) m.err = nil @@ -144,14 +157,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - return fmt.Sprintf( - "\x1b[1;30;42mLook up a word:\x1b[0m\n\n%s\n\n\x1b[1;30;42mStatus:\x1b[0m %s\n\n%s\n\n%s\n%s", - m.wordInput.View(), - formatStatus(m.err, m.statusString), - "(Ctrl-C to quit, Esc to clear, Enter to add to Anki, PgUp/Down to scroll)", - "\x1b[1;30;42mCurrent definition:\x1b[0m\n", - m.definitionViewport.View(), - ) + "\n" + if m.dictionaryReady { + return fmt.Sprintf( + "\x1b[1;30;42mLook up a word:\x1b[0m\n\n%s\n\n\x1b[1;30;42mStatus:\x1b[0m %s\n\n%s\n\n%s\n%s", + m.wordInput.View(), + formatStatus(m.err, m.statusString), + "(Ctrl-C to quit, Esc to clear, Enter to add to Anki, PgUp/Down to scroll)", + "\x1b[1;30;42mCurrent definition:\x1b[0m\n", + m.definitionViewport.View(), + ) + "\n" + } + if m.err != nil { + return fmt.Sprintf("Failed to load dictionary! Error:\n\n%s\n\nExit with Control-C.\n", m.err) + } + return fmt.Sprintln("Preparing dictionary...") } func formatDefinitionForDisplay(policy bluemonday.Policy, definition string, maxWidth int) string { |
