initial sketch of how this will work
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				ci/woodpecker/push/woodpecker Pipeline was successful
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	ci/woodpecker/push/woodpecker Pipeline was successful
				
			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