-
Notifications
You must be signed in to change notification settings - Fork 2
/
main.go
472 lines (403 loc) · 11.8 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
package main
import (
"errors"
"flag"
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/bhutch29/abv/cache"
"github.com/bhutch29/abv/config"
"github.com/bhutch29/abv/model"
"github.com/jroimartin/gocui"
aur "github.com/logrusorgru/aurora"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/text/unicode/norm"
)
var (
g *gocui.Gui
c ModalController
drinks []model.Drink
quantity int
conf *viper.Viper
version = "undefined"
)
func init() {
quantity = 1
initializekeys()
//Setup loggers
f := logrus.TextFormatter{}
f.ForceColors = true
f.DisableTimestamp = true
f.DisableLevelTruncation = true
logGui.Formatter = &f
logGui.SetLevel(logrus.InfoLevel)
logFile.SetLevel(logrus.DebugLevel)
}
func main() {
// Save the state of terminal, so we can restore it after a panic
fd := int(os.Stdout.Fd())
oldState, err := terminal.GetState(fd)
if err == nil {
defer terminal.Restore(fd, oldState)
}
//Get Configuration
if conf, err = config.New(); err != nil {
log.Fatal("Error getting configuration info: ", err)
}
// Redirect stderr to log file
file := redirectStderr(logFile)
defer file.Close()
//Create Controller
if c, err = New(); err != nil {
logFile.Fatal("Error creating controller: ", err)
}
//Command Line flags
handleFlags()
//Setup GUI
setupGui()
defer g.Close()
// Start Gui
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
logFile.Fatal(err)
}
}
func handleFlags() {
backup := flag.String("backup", "", "Backs up the sqlite database to specified file")
reset := flag.Bool("reset", false, "Backs up the database to the working directory and wipes out the Input and Output tables")
ver := flag.Bool("version", false, "Prints the version")
verbose := flag.Bool("v", false, "Increases the logging verbosity in the GUI")
flag.Parse()
if *ver {
fmt.Println(version)
os.Exit(0)
}
if *backup != "" {
backupDatabase(*backup)
os.Exit(0)
}
if *reset {
//TODO: backup to configPath
backupDatabase("backup.sqlite")
if err := c.ClearInputOutputRecords(); err != nil {
log.Print("Error clearing Input and Output records" + err.Error())
logFile.Fatal(err)
}
os.Exit(0)
}
if *verbose {
logGui.SetLevel(logrus.DebugLevel)
}
}
// backupDatabase backs up the abv drinks database to the given destination.
func backupDatabase(destination string) {
log.Print("Backup up database to " + destination)
cmd := exec.Command("sqlite3", "abv.sqlite", ".backup "+destination)
if err := cmd.Run(); err != nil {
log.Print("Failed to backup database: " + err.Error())
logFile.Fatal(err)
}
}
// setupGui creates the main gui object with keybindings initialized.
func setupGui() {
var err error
g, err = gocui.NewGui(gocui.Output256)
if err != nil {
logFile.Fatal(err)
}
vd := viewDrawer{}
g.SetManagerFunc(vd.layout)
g.Cursor = true
if err := configureKeys(); err != nil {
logFile.Fatal(err)
}
}
// refreshInventory displays the sorted inventory in the inventory view.
//
// The inventory is sorted first by drink brand, then by drink name.
func refreshInventory() error {
view, err := g.View(info)
if err != nil {
logAllError(err)
}
view.Clear()
inventory := c.GetInventorySorted([]string{"brand", "name"})
total := c.GetInventoryTotalQuantity()
variety := c.GetInventoryTotalVariety()
fmt.Fprintf(view, "Total Drinks: %d Total Varieties: %d\n\n", total, variety)
for _, drink := range inventory {
//TODO: Make this more robust to handle arbitrary length Brand and Name strings
nfcBytes := norm.NFC.Bytes([]byte(drink.Name))
nfcRunes := []rune(string(nfcBytes))
visualLen := len(nfcRunes)
if visualLen < 30 {
fmt.Fprintf(view, "%-4d%-35s%-30s\n", drink.Quantity, drink.Brand, drink.Name)
} else {
const wsPad = " " // strings.Repeat(" ", 39)
fmt.Fprintf(view, "%-4d%-35s%-30s...\n", drink.Quantity, drink.Brand, string(nfcRunes[:30]))
fmt.Fprintf(view, "%s...%s\n", wsPad, string(nfcRunes[30:]))
}
}
return nil
}
// parseInput handles all input to the user interface and determines whether
// it should be handled as a barcode or as a predefined action. (i.e. undo/redo)
func parseInput(_ *gocui.Gui, v *gocui.View) error {
bc := strings.TrimSuffix(v.Buffer(), "\n")
clearView(input)
if bc == "" {
return nil
}
id, barcode := parseIDFromBarcode(bc)
undoCode := conf.GetString("undoBarcode")
redoCode := conf.GetString("redoBarcode")
if barcode == undoCode {
c.Undo(id)
refreshInventory()
} else if barcode == redoCode {
c.Redo(id)
refreshInventory()
} else {
handleBarcodeEntry(id, barcode)
}
return nil
}
// parseIDFromBarcode returns the input device ID from a line of text.
//
// If the input device is a keyboard, there is no corresponding ID. However
// if the input device is a scanner, the scanner is assumed to add a prefix
// "{c}_" where {c} is a single byte character unique to the scanner.
func parseIDFromBarcode(bc string) (string, string) {
// If the second character is an _, treat the first character as a scanner ID and the rest of the input as a barcode
if len(bc) == 1 {
return "", bc
}
if bc[1] == []byte("_")[0] {
return string(bc[0]), bc[2:]
}
return "", bc
}
// handleBarcodeEntry determines whether a barcode should result in the creation
// of a new drink model, or otherwise be handled as a stocking or serving event.
func handleBarcodeEntry(id string, bc string) {
logAllDebug("Scanned barcode: ", bc, " with ID=", id)
exists, err := c.HandleBarcode(id, bc, quantity)
if err != nil {
logAllError("Failed to search database for barcode", err)
return
}
if !exists {
handleNewBarcode()
}
refreshInventory()
}
// handleNewBarcode determines whether an unrecognized barcode should initiate
// the creation of a new drink model.
//
// In serving mode, the attempt is logged and no action is taken.
func handleNewBarcode() {
if c.GetMode() != stocking {
logGui.Warn("Barcode not recognized while serving. Drink will not be recorded")
return
}
logAllInfo("Barcode not recognized. Please enter drink brand and name.")
clearView(popup)
togglePopup()
}
func handleSearch(_ *gocui.Gui, v *gocui.View) error {
text := v.Buffer()
logFile.WithFields(logrus.Fields{
"category": "userEntry",
"entry": text,
}).Info("User searched for a drink")
setTitle(searchOutline, "")
clearView(search)
updatePopup(text)
setTitle(popup, "Select desired drink...")
return nil
}
func cancelSearch(_ *gocui.Gui, _ *gocui.View) error {
togglePopup()
logAllInfo("Canceled entering information for new barcode")
return nil
}
// cancelPopup hides the drink selection popup and returns to the normal
// user interface.
func cancelPopup(_ *gocui.Gui, _ *gocui.View) error {
togglePopup()
logAllInfo("Canceled selecting drink from list")
return nil
}
// updatePopup produces a popup to select the desired drink. It is populated
// with all of the results that match the provided query to the Untappd service.
func updatePopup(name string) {
v, _ := g.View(popup)
var err error
drinks, err = SearchUntappdByName(name)
if err != nil {
logFile.Error(err)
displayError(err)
return
}
v.Clear()
for _, drink := range drinks {
fmt.Fprintf(v, "%s:: %s\n", drink.Brand, drink.Name)
}
g.SetCurrentView(popup)
return
}
// popupSelectItem takes the user's selected drink, creates a new drink model
// from the Untappd response, caches a brand image if not already cached,
// and finally refreshes the displayed inventory.
func popupSelectItem(_ *gocui.Gui, v *gocui.View) error {
line, err := getViewLine(v)
if err != nil {
logAllError(err)
return nil
}
togglePopup()
resetViewCursor(v)
logFile.WithFields(logrus.Fields{
"category": "userEntry",
"entry": line,
}).Debug("User selected a drink")
logGui.Debug("You selected: " + line)
d, err := findDrinkFromSelection(line)
if err != nil {
logAllError(err)
return nil
}
err = cache.Image(d.Logo)
if err != nil {
logAllError("Failed HTTP request while caching image for drink: ", d.Brand, " ", d.Name)
}
d.Barcode = c.LastBarcode()
d.Shorttype = shortenType(d.Type)
id := c.LastID()
logAllDebug("Adding new drink", d)
if err = c.NewDrink(id, d, quantity); err != nil {
logAllError(err)
}
refreshInventory()
return nil
}
// shortenType produces an abbreviated drink type by truncating anything
// following the first hyphen.
//
// For example, "IPA - Double" would become just "IPA"
func shortenType(in string) string {
split := strings.SplitN(in, " - ", 2)
return split[0]
}
// findDrinkFromSelection takes the user's drink selection and associates it
// with the corresponding drink as queried from Untappd.
func findDrinkFromSelection(line string) (model.Drink, error) {
logFile.Debug("Finding drink from selected text: ", line)
var d model.Drink
s := strings.Split(line, "::")
brand := s[0]
name := strings.TrimSpace(s[1])
logFile.Debug("Determined that brand = " + brand + " and name = " + name)
for _, drink := range drinks {
if drink.Brand == brand && drink.Name == name {
return drink, nil
}
}
return d, errors.New("Could not parse brand and drink name from selected text: " + line)
}
// setInputMode prepares the modal controller for stocking mode.
func setInputMode(_ *gocui.Gui, _ *gocui.View) error {
if c.GetMode() == stocking {
return nil
}
c.SetMode(stocking)
updatePromptSymbol()
logGui.Infof("Changed to %s Mode", aur.Brown("Stocking"))
logFile.WithField("mode", stocking).Info("Changed Mode")
return nil
}
// setOutputMode prepares the modal controller for serving mode.
func setOutputMode(g *gocui.Gui, v *gocui.View) error {
if c.GetMode() == serving {
return nil
}
c.SetMode(serving)
setQuantity1(g, v)
updatePromptSymbol()
logGui.Infof("Changed to %s Mode", aur.Green("Serving"))
logFile.WithField("mode", serving).Info("Changed Mode")
return nil
}
// undoLastKeyboardAction reverts the previously performed action by the keyboard.
func undoLastKeyboardAction(_ *gocui.Gui, _ *gocui.View) error {
c.Undo("")
refreshInventory()
return nil
}
// redoLastKeyboardAction performs the previously reverted action by the keyboard.
func redoLastKeyboardAction(_ *gocui.Gui, _ *gocui.View) error {
c.Redo("")
refreshInventory()
return nil
}
// scrollInventoryUp retreats the cursor to the previous row in the inventory view.
//
// If no previous row exists, no action is taken.
func scrollInventoryUp(g *gocui.Gui, _ *gocui.View) error {
vi, _ := g.View(info)
scrollView(vi, -1)
return nil
}
// scrollInventoryDown advances the cursor to the next row in the inventory view.
//
// If no next row exists, no action is taken.
func scrollInventoryDown(g *gocui.Gui, _ *gocui.View) error {
vi, _ := g.View(info)
scrollView(vi, 1)
return nil
}
// trySetQuantity sets the quantity-per-scan to the given quantity q.
//
// If the user is in serving mode, the quantity is set to 1.
func trySetQuantity(q int) {
if q != 1 && c.GetMode() != stocking {
logAllInfo("Serving of multiple drinks at once is not supported")
return
}
if q == quantity {
return
}
quantity = q
logAllInfo("Quantity of drinks per scan changed to ", quantity)
v, _ := g.View(prompt)
v.Clear()
fmt.Fprintf(v, generateKeybindString(q))
}
// setQuantity1 prepares the controller for either the scanning or serving
// of single beverages.
func setQuantity1(_ *gocui.Gui, _ *gocui.View) error {
trySetQuantity(1)
return nil
}
// setQuantity4 prepares the controller for scanning of 4-packs.
func setQuantity4(_ *gocui.Gui, _ *gocui.View) error {
trySetQuantity(4)
return nil
}
// setQuantity6 prepares the controller for scanning of 6-packs.
func setQuantity6(_ *gocui.Gui, _ *gocui.View) error {
trySetQuantity(6)
return nil
}
// setQuantity12 prepares the controller for scanning of 12-packs.
func setQuantity12(_ *gocui.Gui, _ *gocui.View) error {
trySetQuantity(12)
return nil
}
// quit provides a clean escape from the main gocui loop.
func quit(_ *gocui.Gui, _ *gocui.View) error {
return gocui.ErrQuit
}