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 }