package main import ( "database/sql" "fmt" "html" "net/http" "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/microcosm-cc/bluemonday" "github.com/muesli/reflow/wordwrap" ) type model struct { db *sql.DB c *http.Client p bluemonday.Policy dp dictionaryPopulator wordInput textinput.Model definitionViewport viewport.Model importProgress progress.Model ankiDeck string ankiModel string apiURL string rawDictionaryPath string dictionaryReady bool currentWord string currentDefinition string statusString string importFraction float64 err error } type ( errMsg error definitionMsg string wordAddedMsg string isDictionaryEmptyMsg bool populatingDictionaryMsg *dictionaryPopulator ) func initialModel(c *http.Client, db *sql.DB, apiURL, ankiDeck, ankiModel, rawDictionary, firstWord string) model { input := textinput.New() input.Placeholder = "" input.Focus() input.CharLimit = 156 input.Width = 36 if firstWord != "" { input.SetValue(firstWord) } textbox := viewport.New(80, 30) textbox.KeyMap = viewport.KeyMap{ PageDown: key.NewBinding( key.WithKeys("pgdown", " ", "f"), key.WithHelp("f/pgdn", "page down"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), key.WithHelp("b/pgup", "page up"), ), } dp := dictionaryPopulator{ db: db, rawDictionaryPath: rawDictionary, langCode: "fr", } return model{ db: db, c: c, p: *bluemonday.StrictPolicy(), dp: dp, wordInput: input, definitionViewport: textbox, importProgress: progress.New(progress.WithDefaultGradient()), ankiDeck: ankiDeck, ankiModel: ankiModel, apiURL: apiURL, rawDictionaryPath: rawDictionary, currentWord: firstWord, } } func (m model) Init() tea.Cmd { cmds := []tea.Cmd{textinput.Blink} cmds = append(cmds, isDatabaseEmpty(m.db)) return tea.Batch(cmds...) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var textPickerLongerCmds []tea.Cmd switch msg := msg.(type) { case isDictionaryEmptyMsg: if bool(msg) { // We need to populate the dictionary return m, setupPopulator(&m.dp) } else { // The dictionary is ready m.dictionaryReady = true m.statusString = "" if m.currentWord != "" { textPickerLongerCmds = append(textPickerLongerCmds, lookupWord(m.db, m.currentWord)) } } case populatingDictionaryMsg: m.importFraction = float64(msg.currentLine) / float64(msg.totalLines) return m, populateDictionary((*dictionaryPopulator)(msg)) case definitionMsg: m.currentDefinition = string(msg) m.err = nil m.definitionViewport.SetContent(formatDefinitionForDisplay(m.p, m.currentDefinition, m.definitionViewport.Width)) return m, nil case wordAddedMsg: m.statusString = fmt.Sprintf("✅ Added '%s' to Anki", string(msg)) m.currentWord = "" m.currentDefinition = "" m.wordInput.SetValue("") m.definitionViewport.SetContent("") m.err = nil case tea.WindowSizeMsg: m.importProgress.Width = msg.Width m.wordInput.Width = msg.Width m.definitionViewport.Width = msg.Width // headerHeight is the height of everything above the definition window. headerHeight := 11 m.definitionViewport.Height = msg.Height - headerHeight m.definitionViewport.SetContent( formatDefinitionForDisplay(m.p, m.currentDefinition, m.definitionViewport.Width), ) case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyCtrlD: return m, tea.Quit case tea.KeyEsc: m.currentWord = "" m.currentDefinition = "" m.wordInput.SetValue("") m.definitionViewport.SetContent("") case tea.KeyEnter: return m, addCard(m.c, m.apiURL, m.ankiDeck, m.ankiModel, m.currentWord, m.currentDefinition) } case errMsg: m.err = msg return m, nil } if m.wordInput.Value() != m.currentWord { m.currentWord = m.wordInput.Value() textPickerLongerCmds = append(textPickerLongerCmds, lookupWord(m.db, m.currentWord)) } var vpCmd tea.Cmd m.definitionViewport, vpCmd = m.definitionViewport.Update(msg) textPickerLongerCmds = append(textPickerLongerCmds, vpCmd) m.wordInput, cmd = m.wordInput.Update(msg) textPickerLongerCmds = append(textPickerLongerCmds, cmd) return m, tea.Batch(textPickerLongerCmds...) } func (m model) View() string { 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) } if m.importFraction == 0 { return "\nWe'll start preparing the dictionary in a few seconds...\n\n\n\n" } return fmt.Sprintf("\nPreparing dictionary...\n\n%s\n\n", m.importProgress.ViewAs(m.importFraction)) } func formatDefinitionForDisplay(policy bluemonday.Policy, definition string, maxWidth int) string { // Add a hyphen to the start of each definition str := strings.ReplaceAll(definition, "
  • ", "
  • - ") // Italicize examples str = strings.ReplaceAll(str, "\t
  • \x1b[0m") // Remove all HTML tags str = policy.Sanitize(str) // Some Wiktionary entries have HTML entities in them. That's okay for Anki, // but it's not okay for displaying the plain text in the console interface. str = html.UnescapeString(str) // Add some colour to the start of each definition str = strings.ReplaceAll(str, "\t- ", "\x1b[0;33;49m•\x1b[0m ") // Limit the width of the displayed definition to 80 characters, or the // width of the viewport (whichever is smaller). width := min(maxWidth, 80) return wordwrap.String(str, width) } func formatStatus(lastError error, lastSuccess string) string { if lastError == nil { return lastSuccess } return fmt.Sprintf("\x1b[0;31;49m%s\x1b[0m", lastError.Error()) }