french-wiktionary-flashcards

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

ui.go (7100B)


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