management server listener (#17)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #17 Co-authored-by: David Ashby <delta.mu.alpha@gmail.com> Co-committed-by: David Ashby <delta.mu.alpha@gmail.com>
This commit is contained in:
113
cmd/cli/events.go
Normal file
113
cmd/cli/events.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// error message
|
||||
type EventError struct {
|
||||
tcell.EventTime
|
||||
err error
|
||||
}
|
||||
|
||||
func NewEventError(err error) *EventError {
|
||||
e := &EventError{err: err}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// save change to book
|
||||
type EventBookUpdate struct {
|
||||
tcell.EventTime
|
||||
book *media.Book
|
||||
}
|
||||
|
||||
func NewEventBookUpdate(b *media.Book) *EventBookUpdate {
|
||||
e := &EventBookUpdate{book: b}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *EventBookUpdate) Book() *media.Book {
|
||||
return e.book
|
||||
}
|
||||
|
||||
// open new book in display
|
||||
type EventLoadBook struct {
|
||||
tcell.EventTime
|
||||
ID int
|
||||
}
|
||||
|
||||
func NewEventLoadBook(id int) *EventLoadBook {
|
||||
e := &EventLoadBook{ID: id}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// open new book in display
|
||||
type EventEnterBook struct {
|
||||
tcell.EventTime
|
||||
}
|
||||
|
||||
func NewEventEnterBook() *EventEnterBook {
|
||||
e := &EventEnterBook{}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// switch back to menu control
|
||||
type EventExitBook struct {
|
||||
tcell.EventTime
|
||||
}
|
||||
|
||||
func NewEventExitBook() *EventExitBook {
|
||||
e := &EventExitBook{}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// open import window
|
||||
type EventOpenImport struct {
|
||||
tcell.EventTime
|
||||
}
|
||||
|
||||
func NewEventOpenImport() *EventOpenImport {
|
||||
e := &EventOpenImport{}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// attempt to import given filename.csv
|
||||
type EventAttemptImport struct {
|
||||
tcell.EventTime
|
||||
filename string
|
||||
}
|
||||
|
||||
func NewEventAttemptImport(f string) *EventAttemptImport {
|
||||
e := &EventAttemptImport{filename: f}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// close import window
|
||||
type EventCloseImport struct {
|
||||
tcell.EventTime
|
||||
}
|
||||
|
||||
func NewEventCloseImport() *EventCloseImport {
|
||||
e := &EventCloseImport{}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
||||
|
||||
// quit
|
||||
type EventQuit struct {
|
||||
tcell.EventTime
|
||||
}
|
||||
|
||||
func NewEventQuit() *EventQuit {
|
||||
e := &EventQuit{}
|
||||
e.SetEventNow()
|
||||
return e
|
||||
}
|
336
cmd/cli/main.go
Normal file
336
cmd/cli/main.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/config"
|
||||
"git.yetaga.in/alazyreader/library/database"
|
||||
"git.yetaga.in/alazyreader/library/importer"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
"git.yetaga.in/alazyreader/library/ui"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
// State holds the UI state keys=>value map and manages access to the map with a mutex
|
||||
type State struct {
|
||||
m sync.Mutex
|
||||
stateMap map[string]interface{}
|
||||
}
|
||||
|
||||
// key, present
|
||||
func (s *State) Get(key string) interface{} {
|
||||
s.m.Lock()
|
||||
defer s.m.Unlock()
|
||||
if s.stateMap == nil {
|
||||
s.stateMap = make(map[string]interface{})
|
||||
}
|
||||
k, ok := s.stateMap[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// key, value
|
||||
func (s *State) Set(key string, value interface{}) {
|
||||
s.m.Lock()
|
||||
defer s.m.Unlock()
|
||||
if s.stateMap == nil {
|
||||
s.stateMap = make(map[string]interface{})
|
||||
}
|
||||
s.stateMap[key] = value
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// UI states
|
||||
const (
|
||||
IN_MENU = iota
|
||||
IN_BOOK
|
||||
IN_IMPORT
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
err := envconfig.Process("library", &c)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
// create state
|
||||
state := State{}
|
||||
|
||||
// set up DB connection
|
||||
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
|
||||
if c.DBPass != "" { // obscure password
|
||||
c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3))
|
||||
}
|
||||
log.Fatalf("vars: %+v", c)
|
||||
}
|
||||
lib, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
err = lib.PrepareDatabase(context.Background())
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
_, _, err = lib.RunMigrations(context.Background())
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
books, err := lib.GetAllBooks(context.Background())
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
state.Set("library", books)
|
||||
|
||||
screen, err := tcell.NewScreen()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
err = screen.Init()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
// cleanup our screen and log if we panic and crash out somewhere
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if screen != nil {
|
||||
screen.Fini()
|
||||
}
|
||||
fmt.Println("fatal panic;", r)
|
||||
if c.Debug {
|
||||
fmt.Println("stacktrace: \n" + string(debug.Stack()))
|
||||
}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// book list and options menu (left column)
|
||||
l := ui.NewList(Titles(state.Get("library").([]media.Book)), 0)
|
||||
menu := ui.NewBox(
|
||||
"library",
|
||||
[]string{"˄˅ select", "⏎ edit", "(n)ew", "(i)mport", "(q)uit"},
|
||||
ui.Contents{{
|
||||
Offsets: ui.Offsets{Top: 1, Left: 2, Bottom: -2, Right: -2},
|
||||
Container: l,
|
||||
}},
|
||||
ui.StyleActive,
|
||||
false,
|
||||
)
|
||||
activeBookDetails := ui.NewBookDetails(&media.Book{})
|
||||
|
||||
// book display (right column)
|
||||
activeBook := ui.NewBox(
|
||||
"book",
|
||||
[]string{"˄˅ select", "⏎ edit", "(esc) close"},
|
||||
ui.Contents{{
|
||||
Offsets: ui.Offsets{Top: 1, Left: 2, Bottom: 0, Right: 0},
|
||||
Container: activeBookDetails,
|
||||
}},
|
||||
ui.StyleInactive,
|
||||
false,
|
||||
)
|
||||
|
||||
// parent container
|
||||
container := ui.NewContainer(
|
||||
ui.Contents{
|
||||
{Container: menu, Offsets: ui.Offsets{Percent: 1}},
|
||||
{Container: activeBook, Offsets: ui.Offsets{Percent: 2}},
|
||||
},
|
||||
ui.LayoutHorizontalPercent,
|
||||
)
|
||||
|
||||
// import pop-up
|
||||
wd, _ := os.Getwd()
|
||||
fileSelector := ui.NewEditableTextLine(wd)
|
||||
fileSelector.ResetCursor(false)
|
||||
fileSelector.SetStyle(ui.StyleActive.Underline(true))
|
||||
popup := ui.NewBox(
|
||||
"import csv file",
|
||||
[]string{"⏎ submit", "(esc)close"},
|
||||
ui.Contents{
|
||||
{Container: fileSelector, Offsets: ui.Offsets{Top: 2, Left: 2}},
|
||||
},
|
||||
ui.StyleActive,
|
||||
false,
|
||||
)
|
||||
popup.SetVisible(false)
|
||||
|
||||
// error pop-up
|
||||
errorMessage := ui.NewEditableTextLine("")
|
||||
errorPopup := ui.NewBox(
|
||||
"error",
|
||||
[]string{"⏎ close"},
|
||||
ui.Contents{
|
||||
{Container: errorMessage, Offsets: ui.Offsets{Top: 1, Left: 1}},
|
||||
},
|
||||
ui.StyleActive.Bold(true).Foreground(tcell.ColorRed),
|
||||
false,
|
||||
)
|
||||
errorPopup.SetVisible(false)
|
||||
|
||||
// init
|
||||
screen.Clear()
|
||||
w, h := screen.Size()
|
||||
container.SetSize(0, 0, h, w)
|
||||
container.Draw(screen)
|
||||
screen.Sync()
|
||||
|
||||
// init UI state
|
||||
state.Set("ui_state", IN_MENU)
|
||||
screen.PostEvent(NewEventLoadBook(l.SelectedID()))
|
||||
|
||||
// UI loop
|
||||
for {
|
||||
e := screen.PollEvent()
|
||||
switch v := e.(type) {
|
||||
case *tcell.EventError:
|
||||
fmt.Fprintf(os.Stderr, "%v", v)
|
||||
screen.Beep()
|
||||
case *tcell.EventKey: // input handling
|
||||
curr := state.Get("ui_state").(int)
|
||||
if curr == IN_MENU {
|
||||
if v.Key() == tcell.KeyUp && l.Selected() > 0 {
|
||||
l.SetSelected(l.Selected() - 1)
|
||||
screen.PostEvent(NewEventLoadBook(l.SelectedID()))
|
||||
}
|
||||
if v.Key() == tcell.KeyDown && l.Selected() < len(l.ListMembers())-1 {
|
||||
l.SetSelected(l.Selected() + 1)
|
||||
screen.PostEvent(NewEventLoadBook(l.SelectedID()))
|
||||
}
|
||||
if v.Key() == tcell.KeyEnter {
|
||||
screen.PostEvent(NewEventEnterBook())
|
||||
}
|
||||
if v.Rune() == 'i' {
|
||||
screen.PostEvent(NewEventOpenImport())
|
||||
}
|
||||
if v.Rune() == 'q' {
|
||||
screen.PostEvent(NewEventQuit())
|
||||
}
|
||||
} else if curr == IN_BOOK {
|
||||
if v.Key() == tcell.KeyEsc {
|
||||
screen.PostEvent(NewEventExitBook())
|
||||
}
|
||||
} else if curr == IN_IMPORT {
|
||||
if v.Key() == tcell.KeyEsc {
|
||||
fileSelector.SetText(wd)
|
||||
fileSelector.ResetCursor(false)
|
||||
screen.PostEvent(NewEventCloseImport())
|
||||
}
|
||||
if v.Key() == tcell.KeyBackspace || v.Key() == tcell.KeyBackspace2 {
|
||||
fileSelector.DeleteAtCursor()
|
||||
} else if v.Key() == tcell.KeyRight {
|
||||
fileSelector.MoveCursor(1)
|
||||
|
||||
} else if v.Key() == tcell.KeyLeft {
|
||||
fileSelector.MoveCursor(-1)
|
||||
} else if v.Key() == tcell.KeyEnter {
|
||||
screen.PostEvent(NewEventAttemptImport(fileSelector.Text()))
|
||||
} else if v.Rune() != 0 {
|
||||
fileSelector.InsertAtCursor(v.Rune())
|
||||
}
|
||||
}
|
||||
case *tcell.EventResize: // screen redraw
|
||||
w, h := screen.Size()
|
||||
container.SetSize(0, 0, h, w)
|
||||
case *EventBookUpdate:
|
||||
// TK
|
||||
case *EventEnterBook:
|
||||
activeBook.SetStyle(ui.StyleActive)
|
||||
menu.SetStyle(ui.StyleInactive)
|
||||
state.Set("ui_state", IN_BOOK)
|
||||
case *EventExitBook:
|
||||
state.Set("ui_state", IN_MENU)
|
||||
activeBook.SetStyle(ui.StyleInactive)
|
||||
menu.SetStyle(ui.StyleActive)
|
||||
case *EventLoadBook:
|
||||
activeBookDetails.SetBook(GetBookByID(v.ID, books))
|
||||
case *EventOpenImport:
|
||||
state.Set("ui_state", IN_IMPORT)
|
||||
menu.SetStyle(ui.StyleInactive)
|
||||
popup.SetVisible(true)
|
||||
popup.SetSize(6, 3, 5, 80)
|
||||
case *EventAttemptImport:
|
||||
// this will block other events, but it shouldn't take too long...
|
||||
f, err := os.Open(v.filename)
|
||||
if err != nil {
|
||||
screen.PostEvent(NewEventError(err))
|
||||
continue
|
||||
}
|
||||
books, err := importer.CSVToBooks(f)
|
||||
if err != nil {
|
||||
screen.PostEvent(NewEventError(err))
|
||||
continue
|
||||
}
|
||||
for b := range books {
|
||||
err = lib.AddBook(context.Background(), &books[b])
|
||||
if err != nil {
|
||||
screen.PostEvent(NewEventError(err))
|
||||
}
|
||||
}
|
||||
screen.PostEvent(NewEventCloseImport())
|
||||
allbooks, err := lib.GetAllBooks(context.Background())
|
||||
if err != nil {
|
||||
screen.PostEvent(NewEventError(err))
|
||||
}
|
||||
state.Set("library", allbooks)
|
||||
state.Set("ui_state", IN_MENU)
|
||||
case *EventCloseImport:
|
||||
state.Set("ui_state", IN_MENU)
|
||||
screen.HideCursor()
|
||||
menu.SetStyle(ui.StyleActive)
|
||||
popup.SetVisible(false)
|
||||
case *EventError:
|
||||
errorMessage.SetText(v.err.Error())
|
||||
errorPopup.SetVisible(true)
|
||||
case *EventQuit:
|
||||
screen.Fini()
|
||||
fmt.Printf("Thank you for playing Wing Commander!\n\n")
|
||||
return
|
||||
case *tcell.EventInterrupt:
|
||||
case *tcell.EventMouse:
|
||||
case *tcell.EventTime:
|
||||
default:
|
||||
}
|
||||
// repaint
|
||||
l.SetMembers(Titles(state.Get("library").([]media.Book)))
|
||||
container.Draw(screen)
|
||||
popup.Draw(screen)
|
||||
errorPopup.Draw(screen)
|
||||
screen.Show()
|
||||
}
|
||||
}
|
||||
|
||||
func Titles(lb []media.Book) []ui.ListKeyValue {
|
||||
r := []ui.ListKeyValue{}
|
||||
for i := range lb {
|
||||
r = append(r, ui.ListKeyValue{
|
||||
Key: lb[i].ID,
|
||||
Value: lb[i].Title,
|
||||
})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func GetBookByID(id int, lb []media.Book) *media.Book {
|
||||
for i := range lb {
|
||||
if lb[i].ID == id {
|
||||
return &lb[i]
|
||||
}
|
||||
}
|
||||
return &media.Book{}
|
||||
}
|
Reference in New Issue
Block a user