french-wiktionary-flashcards

Easily create Anki flashcards from Wiktionary data.
Log | Files | Refs | README

ui.go (6683B)


      1 package main
      2 
      3 import (
      4 	"database/sql"
      5 	"fmt"
      6 	"html"
      7 	"net/http"
      8 	"strings"
      9 
     10 	"github.com/charmbracelet/bubbles/key"
     11 	"github.com/charmbracelet/bubbles/progress"
     12 	"github.com/charmbracelet/bubbles/textinput"
     13 	"github.com/charmbracelet/bubbles/viewport"
     14 	tea "github.com/charmbracelet/bubbletea"
     15 	"github.com/microcosm-cc/bluemonday"
     16 	"github.com/muesli/reflow/wordwrap"
     17 )
     18 
     19 type model struct {
     20 	db *sql.DB
     21 	c  *http.Client
     22 	p  bluemonday.Policy
     23 	dp dictionaryPopulator
     24 
     25 	wordInput          textinput.Model
     26 	definitionViewport viewport.Model
     27 	importProgress     progress.Model
     28 
     29 	ankiDeck          string
     30 	ankiModel         string
     31 	apiURL            string
     32 	rawDictionaryPath string
     33 
     34 	dictionaryReady bool
     35 
     36 	currentWord       string
     37 	currentDefinition string
     38 	statusString      string
     39 	importFraction    float64
     40 	err               error
     41 }
     42 
     43 type (
     44 	errMsg                  error
     45 	definitionMsg           string
     46 	wordAddedMsg            string
     47 	isDictionaryEmptyMsg    bool
     48 	populatingDictionaryMsg *dictionaryPopulator
     49 )
     50 
     51 func initialModel(
     52 	c *http.Client,
     53 	db *sql.DB,
     54 	apiURL, ankiDeck, ankiModel, rawDictionary, firstWord string,
     55 ) model {
     56 	input := textinput.New()
     57 	input.Placeholder = ""
     58 	input.Focus()
     59 	input.CharLimit = 156
     60 	input.Width = 36
     61 
     62 	if firstWord != "" {
     63 		input.SetValue(firstWord)
     64 	}
     65 
     66 	textbox := viewport.New(80, 30)
     67 	textbox.KeyMap = viewport.KeyMap{
     68 		PageDown: key.NewBinding(
     69 			key.WithKeys("pgdown", " ", "f"),
     70 			key.WithHelp("f/pgdn", "page down"),
     71 		),
     72 		PageUp: key.NewBinding(
     73 			key.WithKeys("pgup", "b"),
     74 			key.WithHelp("b/pgup", "page up"),
     75 		),
     76 	}
     77 
     78 	dp := dictionaryPopulator{
     79 		db:                db,
     80 		rawDictionaryPath: rawDictionary,
     81 		langCode:          "fr",
     82 	}
     83 
     84 	return model{
     85 		db: db,
     86 		c:  c,
     87 		p:  *bluemonday.StrictPolicy(),
     88 		dp: dp,
     89 
     90 		wordInput:          input,
     91 		definitionViewport: textbox,
     92 
     93 		ankiDeck:          ankiDeck,
     94 		ankiModel:         ankiModel,
     95 		apiURL:            apiURL,
     96 		rawDictionaryPath: rawDictionary,
     97 
     98 		currentWord: firstWord,
     99 	}
    100 }
    101 
    102 func (m model) Init() tea.Cmd {
    103 	cmds := []tea.Cmd{textinput.Blink}
    104 	cmds = append(cmds, isDatabaseEmpty(m.db))
    105 	return tea.Batch(cmds...)
    106 }
    107 
    108 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    109 	var cmd tea.Cmd
    110 
    111 	var textPickerLongerCmds []tea.Cmd
    112 
    113 	switch msg := msg.(type) {
    114 	case isDictionaryEmptyMsg:
    115 		if bool(msg) { // We need to populate the dictionary
    116 			m.importProgress = progress.New(progress.WithDefaultGradient())
    117 			return m, setupPopulator(&m.dp)
    118 		} else { // The dictionary is ready
    119 			m.dictionaryReady = true
    120 			m.statusString = ""
    121 			if m.currentWord != "" {
    122 				textPickerLongerCmds = append(
    123 					textPickerLongerCmds,
    124 					lookupWord(m.db, m.currentWord),
    125 				)
    126 			}
    127 		}
    128 	case populatingDictionaryMsg:
    129 		m.importFraction = float64(msg.currentLine) / float64(msg.totalLines)
    130 		return m, populateDictionary((*dictionaryPopulator)(msg))
    131 	case definitionMsg:
    132 		m.currentDefinition = string(msg)
    133 		m.err = nil
    134 		m.definitionViewport.SetContent(
    135 			formatDefinitionForDisplay(
    136 				m.p,
    137 				m.currentDefinition,
    138 				m.definitionViewport.Width,
    139 			),
    140 		)
    141 		return m, nil
    142 	case wordAddedMsg:
    143 		m.statusString = fmt.Sprintf("✅ Added '%s' to Anki", string(msg))
    144 		m.currentWord = ""
    145 		m.currentDefinition = ""
    146 		m.wordInput.SetValue("")
    147 		m.definitionViewport.SetContent("")
    148 		m.err = nil
    149 	case tea.WindowSizeMsg:
    150 		m.importProgress.Width = msg.Width
    151 		m.wordInput.Width = msg.Width
    152 		m.definitionViewport.Width = msg.Width
    153 
    154 		// headerHeight is the height of everything above the definition window.
    155 		headerHeight := 11
    156 
    157 		m.definitionViewport.Height = msg.Height - headerHeight
    158 		m.definitionViewport.SetContent(
    159 			formatDefinitionForDisplay(
    160 				m.p,
    161 				m.currentDefinition,
    162 				m.definitionViewport.Width,
    163 			),
    164 		)
    165 	case tea.KeyMsg:
    166 		switch msg.Type {
    167 		case tea.KeyCtrlC, tea.KeyCtrlD:
    168 			fmt.Print("\033[H\033[2J") // clear screen
    169 			return m, tea.Quit
    170 		case tea.KeyEsc:
    171 			m.currentWord = ""
    172 			m.currentDefinition = ""
    173 			m.wordInput.SetValue("")
    174 			m.definitionViewport.SetContent("")
    175 		case tea.KeyEnter:
    176 			return m, addCard(
    177 				m.c,
    178 				m.apiURL,
    179 				m.ankiDeck,
    180 				m.ankiModel,
    181 				m.currentWord,
    182 				m.currentDefinition,
    183 			)
    184 		}
    185 
    186 	case errMsg:
    187 		m.err = msg
    188 		return m, nil
    189 	}
    190 
    191 	if m.wordInput.Value() != m.currentWord {
    192 		m.currentWord = m.wordInput.Value()
    193 		textPickerLongerCmds = append(
    194 			textPickerLongerCmds,
    195 			lookupWord(m.db, m.currentWord),
    196 		)
    197 	}
    198 
    199 	var vpCmd tea.Cmd
    200 	m.definitionViewport, vpCmd = m.definitionViewport.Update(msg)
    201 	textPickerLongerCmds = append(textPickerLongerCmds, vpCmd)
    202 
    203 	m.wordInput, cmd = m.wordInput.Update(msg)
    204 	textPickerLongerCmds = append(textPickerLongerCmds, cmd)
    205 	return m, tea.Batch(textPickerLongerCmds...)
    206 }
    207 
    208 func (m model) View() string {
    209 	if m.dictionaryReady {
    210 		return fmt.Sprintf(
    211 			"\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",
    212 			m.wordInput.View(),
    213 			formatStatus(m.err, m.statusString),
    214 			"(Ctrl-C to quit, Esc to clear, Enter to add to Anki, PgUp/Down to scroll)",
    215 			"\x1b[1;30;42mCurrent definition:\x1b[0m\n",
    216 			m.definitionViewport.View(),
    217 		) + "\n"
    218 	}
    219 	if m.err != nil {
    220 		return fmt.Sprintf("Failed to load dictionary! Error:\n\n%s\n\nExit with Control-C.\n", m.err)
    221 	}
    222 	if m.importFraction == 0 {
    223 		return "\nWe'll start preparing the dictionary in a few seconds...\n\n\n\n"
    224 	}
    225 	return fmt.Sprintf("\nPreparing dictionary...\n\n%s\n\n", m.importProgress.ViewAs(m.importFraction))
    226 }
    227 
    228 func formatDefinitionForDisplay(
    229 	policy bluemonday.Policy,
    230 	definition string,
    231 	maxWidth int,
    232 ) string {
    233 	// Add a hyphen to the start of each definition
    234 	str := strings.ReplaceAll(definition, "<li class=sense>", "<li class=sense>- ")
    235 
    236 	// Italicize examples
    237 	str = strings.ReplaceAll(str, "\t<ul><li><i>", "\n\t<ul><li><i>\x1b[3;39;49m")
    238 	str = strings.ReplaceAll(str, "</i></li></ul></li>", "</i></li></ul></li>\x1b[0m")
    239 
    240 	// Remove all HTML tags
    241 	str = policy.Sanitize(str)
    242 	// Some Wiktionary entries have HTML entities in them. That's okay for Anki,
    243 	// but it's not okay for displaying the plain text in the console interface.
    244 	str = html.UnescapeString(str)
    245 
    246 	// Add some colour to the start of each definition
    247 	str = strings.ReplaceAll(str, "\t- ", "\x1b[0;33;49m•\x1b[0m ")
    248 
    249 	// Limit the width of the displayed definition to 80 characters, or the
    250 	// width of the viewport (whichever is smaller). (Add one character of
    251 	// padding too -- seems to prevent weird line breaks)
    252 	width := min(maxWidth-1, 80-1)
    253 	return wordwrap.String(str, width)
    254 }
    255 
    256 func formatStatus(lastError error, lastSuccess string) string {
    257 	if lastError == nil {
    258 		return lastSuccess
    259 	}
    260 	return fmt.Sprintf("\x1b[0;31;49m%s\x1b[0m", lastError.Error())
    261 }