diff options
| -rw-r--r-- | add.go | 106 | ||||
| -rw-r--r-- | go.mod | 28 | ||||
| -rw-r--r-- | go.sum | 55 | ||||
| -rw-r--r-- | main.go | 135 | ||||
| -rw-r--r-- | setup.go | 15 |
5 files changed, 252 insertions, 87 deletions
@@ -6,54 +6,86 @@ import ( "fmt" "io" "net/http" + + tea "github.com/charmbracelet/bubbletea" ) const apiVersion = 6 -func addCard(c *http.Client, front, back string) error { - noteRequest := addNote{ - Action: "addNote", - Version: apiVersion, - Params: addNoteParams{ - Note: note{ - DeckName: deckName, - ModelName: modelName, - Fields: fields{ - Front: front, - Back: back, - }, - Options: options{ - AllowDuplicate: false, - DuplicateScope: "deck", +type addNote struct { + Action string `json:"action"` + Version int `json:"version"` + Params addNoteParams `json:"params"` +} + +type addNoteParams struct { + Note note `json:"note"` +} + +type note struct { + DeckName string `json:"deckName"` + ModelName string `json:"modelName"` + // Fields will not be trivial to generalize + Fields fields `json:"fields"` + Options options `json:"options"` +} + +type fields struct { + Front string `json:"Front"` + Back string `json:"Back"` +} + +type options struct { + AllowDuplicate bool `json:"allowDuplicate"` + DuplicateScope string `json:"duplicateScope"` +} + +func addCard(c *http.Client, front, back string) tea.Cmd { + return func() tea.Msg { + noteRequest := addNote{ + Action: "addNote", + Version: apiVersion, + Params: addNoteParams{ + Note: note{ + DeckName: deckName, + ModelName: modelName, + Fields: fields{ + Front: front, + Back: back, + }, + Options: options{ + AllowDuplicate: false, + DuplicateScope: "deck", + }, }, }, - }, - } + } - jsonBytes, err := json.Marshal(noteRequest) - if err != nil { - return fmt.Errorf("marshaling JSON: %s", err) - } + jsonBytes, err := json.Marshal(noteRequest) + if err != nil { + return errMsg(fmt.Errorf("marshaling JSON: %s", err)) + } - req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBytes)) - req.Header.Set("Content-Type", "application/json") + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBytes)) + req.Header.Set("Content-Type", "application/json") - resp, err := c.Do(req) - if err != nil { - return fmt.Errorf("making request: %s", err) - } - defer resp.Body.Close() + resp, err := c.Do(req) + if err != nil { + return errMsg(fmt.Errorf("making request: %s", err)) + } + defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, _ := io.ReadAll(resp.Body) - var jsonResp struct { - Error string `json:"error"` - } + var jsonResp struct { + Error string `json:"error"` + } - json.Unmarshal(body, &jsonResp) - if jsonResp.Error != "" { - return fmt.Errorf("creating card: %s", jsonResp.Error) - } + json.Unmarshal(body, &jsonResp) + if jsonResp.Error != "" { + return errMsg(fmt.Errorf("creating card: %s", jsonResp.Error)) + } - return nil + return wordAddedMsg(front) + } } @@ -3,6 +3,34 @@ module davidschlachter.com/french-wiktionary-flashcards go 1.24.1 require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/goccy/go-json v0.10.5 github.com/mattn/go-sqlite3 v1.14.33 + github.com/microcosm-cc/bluemonday v1.0.27 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.16.0 // indirect ) @@ -1,4 +1,59 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= @@ -3,12 +3,16 @@ package main import ( + "database/sql" + "fmt" "log" "net/http" - "os" "time" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" _ "github.com/mattn/go-sqlite3" + "github.com/microcosm-cc/bluemonday" ) const ( @@ -20,32 +24,99 @@ const ( modelName = "Basic-830ae" ) -type addNote struct { - Action string `json:"action"` - Version int `json:"version"` - Params addNoteParams `json:"params"` +type model struct { + wordInput textinput.Model + err error + currentWord string + currentDefinition string + db *sql.DB + c *http.Client + wordAddStatus string + p bluemonday.Policy } -type addNoteParams struct { - Note note `json:"note"` +type ( + errMsg error + definitionMsg string + wordAddedMsg string +) + +func initialModel(c *http.Client, db *sql.DB) model { + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 36 + + return model{ + wordInput: ti, + err: nil, + db: db, + c: c, + wordAddStatus: "(Press 'Enter' to add this word and its definition to Anki)", + p: *bluemonday.StrictPolicy(), + } } -type note struct { - DeckName string `json:"deckName"` - ModelName string `json:"modelName"` - // Fields will not be trivial to generalize - Fields fields `json:"fields"` - Options options `json:"options"` +func (m model) Init() tea.Cmd { + return textinput.Blink } -type fields struct { - Front string `json:"Front"` - Back string `json:"Back"` +func lookupWord(db *sql.DB, word string) tea.Cmd { + return func() tea.Msg { + var definition string + row := db.QueryRow(`select definition from words where word = ? limit 1`, word) + err := row.Scan(&definition) + if err != nil { + return errMsg(fmt.Errorf("looking up '%s': %s", word, err)) + } + return definitionMsg(definition) + } } -type options struct { - AllowDuplicate bool `json:"allowDuplicate"` - DuplicateScope string `json:"duplicateScope"` +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + var textPickerLongerCmds []tea.Cmd + + switch msg := msg.(type) { + case definitionMsg: + m.currentDefinition = string(msg) + return m, nil + case wordAddedMsg: + m.wordAddStatus = fmt.Sprintf("✅ Added '%s' to Anki", string(msg)) + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyEnter: + return m, addCard(m.c, 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)) + } + + m.wordInput, cmd = m.wordInput.Update(msg) + textPickerLongerCmds = append(textPickerLongerCmds, cmd) + return m, tea.Batch(textPickerLongerCmds...) +} + +func (m model) View() string { + return fmt.Sprintf( + "Look up a word:\n\n%s\n\n%s\n%s\n\n%s\n%s", + m.wordInput.View(), + m.wordAddStatus, + "(esc to quit)", + "Current definition:\n", + m.p.Sanitize(m.currentDefinition), + ) + "\n" } func main() { @@ -55,31 +126,11 @@ func main() { } defer db.Close() - // We're going to start this app very simply! The first iteration will take - // a word as its first command-line argument. We will search for the word in - // the dictionary and create a new Anki card using the first exact match. - // - // In the future, we may make it easy for the user to edit cards (flag?), - // and possibly implement a TUI to choose definitions more interactively as - // well (e.g. search with partial matches). - // - // I would also like to make more things less hard-coded. - if len(os.Args) < 2 { - log.Fatalf("no word was provided") - } - word := os.Args[1] - - var definition string - row := db.QueryRow(`select definition from words where word = ? limit 1`, word) - err = row.Scan(&definition) - if err != nil { - log.Fatalf("looking up '%s': %s", word, err) - } - c := http.DefaultClient c.Timeout = 5 * time.Second - if err := addCard(c, word, definition); err != nil { - log.Fatalf("creating card: %s", err) + p := tea.NewProgram(initialModel(c, db)) + if _, err := p.Run(); err != nil { + log.Fatal(err) } } @@ -88,15 +88,14 @@ func populateDictionary(db *sql.DB) error { log.Printf("preparing list of dictionary words...") // Set up the template - tmpl, err := template.New("entry").Parse(`<p>{{ .Word }} {{ .Sound }} <i>{{ .POS }} {{ .Gender }}</i></p> - <ol> - {{ range .Senses}} - <li>{{ .Sense }}<br> - {{ if .Example }} - <ul><li><i>{{ .Example }}</i></li></ul></li> - {{ end }} + tmpl, err := template.New("entry").Parse( + `<p>{{ .Word }} {{ .Sound }} <i>{{ .POS }} {{ .Gender }}</i></p> +<ol>{{ range .Senses}} + <li>{{ .Sense }}<br> + {{ if .Example }} + <ul><li><i>{{ .Example }}</i></li></ul></li> {{ end }} - </ol>`) +{{ end }}</ol>`) if err != nil { panic(err) } |
