Browse Source

Merge branch 'next'

master
David Ashby 4 weeks ago
parent
commit
dcdff94712
  1. 7
      .gitignore
  2. 8
      Dockerfile
  3. 34
      Makefile
  4. 21
      book/book.go
  5. 113
      cmd/manage/events.go
  6. 336
      cmd/manage/main.go
  7. 100
      cmd/serve/main.go
  8. 10
      config/config.go
  9. 48
      css/reset.css
  10. 79
      database/memory.go
  11. 20
      database/migrations/mysql/01-init.sql
  12. 301
      database/mysql.go
  13. 8
      docker-compose.yml
  14. BIN
      favicon.ico
  15. BIN
      frontend/files/favicon.ico
  16. BIN
      frontend/files/favicon.png
  17. 243
      frontend/files/index.html
  18. 144
      frontend/files/style.css
  19. 13
      frontend/frontend.go
  20. 9
      go.mod
  21. 16
      go.sum
  22. 65
      importer/csv.go
  23. 188
      index.html
  24. 174
      index.js
  25. 4
      js/jquery.js
  26. 136
      js/lodash.min.js
  27. 1
      js/mustache.js
  28. 24
      package.json
  29. 30
      readme.md
  30. 116
      ui/mock.go
  31. 617
      ui/ui.go
  32. 239
      ui/ui_test.go

7
.gitignore

@ -1,2 +1,5 @@
node_modules
.credentials
/server
/manager
*.properties
.DS_Store
*.csv

8
Dockerfile

@ -0,0 +1,8 @@
FROM golang:1.16
WORKDIR /src
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/serve
FROM scratch
COPY --from=0 /src/server ./
CMD ["/server"]

34
Makefile

@ -0,0 +1,34 @@
.PHONY: up down run-server run-manager test
GOFILES=$(shell find . -name '*.go' -o -name 'go.*')
STATICFILES=$(shell find . -name '*.js' -o -name '*.css' -o -name '*.html')
SQLFILES=$(shell find . -name '*.sql')
ifneq (,$(wildcard ./local.properties))
include local.properties
export
endif
build: server manager
run-server: build
./server
run-manager: build
./manager
server: $(GOFILES) $(STATICFILES)
go build -o server ./cmd/serve
manager: $(GOFILES) $(SQLFILES)
go build -o manager ./cmd/manage
test:
go test ./... -cover
# dev dependencies
up:
docker compose up -d
down:
docker compose down

21
book/book.go

@ -0,0 +1,21 @@
package book
type Book struct {
ID int `json:"-"`
Title string `json:"title"`
Authors []string `json:"authors"`
SortAuthor string `json:"sortAuthor"`
ISBN10 string `json:"isbn-10"`
ISBN13 string `json:"isbn-13"`
Format string `json:"format"`
Genre string `json:"genre"`
Publisher string `json:"publisher"`
Series string `json:"series"`
Volume string `json:"volume"`
Year string `json:"year"`
Signed bool `json:"signed"`
Description string `json:"description"`
Notes string `json:"notes"`
OnLoan string `json:"onLoan"`
CoverURL string `json:"coverURL"`
}

113
cmd/manage/events.go

@ -0,0 +1,113 @@
package main
import (
"git.yetaga.in/alazyreader/library/book"
"github.com/gdamore/tcell"
)
// 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 *book.Book
}
func NewEventBookUpdate(b *book.Book) *EventBookUpdate {
e := &EventBookUpdate{book: b}
e.SetEventNow()
return e
}
func (e *EventBookUpdate) Book() *book.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/manage/main.go

@ -0,0 +1,336 @@
package main
import (
"context"
"fmt"
"log"
"os"
"runtime/debug"
"strings"
"sync"
"git.yetaga.in/alazyreader/library/book"
"git.yetaga.in/alazyreader/library/config"
"git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/importer"
"git.yetaga.in/alazyreader/library/ui"
"github.com/gdamore/tcell"
"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").([]book.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(&book.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").([]book.Book)))
container.Draw(screen)
popup.Draw(screen)
errorPopup.Draw(screen)
screen.Show()
}
}
func Titles(lb []book.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 []book.Book) *book.Book {
for i := range lb {
if lb[i].ID == id {
return &lb[i]
}
}
return &book.Book{}
}

100
cmd/serve/main.go

@ -0,0 +1,100 @@
package main
import (
"context"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
"git.yetaga.in/alazyreader/library/book"
"git.yetaga.in/alazyreader/library/config"
"git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/frontend"
"github.com/kelseyhightower/envconfig"
)
func max(a, b int) int {
if a > b {
return a
}
return b
}
type Library interface {
GetAllBooks(context.Context) ([]book.Book, error)
}
type Router struct {
static fs.FS
lib Library
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/api" {
APIHandler(r.lib).ServeHTTP(w, req)
return
}
StaticHandler(r.static).ServeHTTP(w, req)
}
func APIHandler(l Library) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
w.Write([]byte("\n"))
})
}
func StaticHandler(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}
func main() {
var c config.Config
err := envconfig.Process("library", &c)
if err != nil {
log.Fatalln(err)
}
f, err := frontend.Root()
if err != nil {
log.Fatalln(err)
}
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)
}
latest, run, err := lib.RunMigrations(context.Background())
if err != nil {
log.Fatalln(err)
}
log.Printf("latest migration: %d; migrations run: %d", latest, run)
r := &Router{
static: f,
lib: lib,
}
log.Println("Listening on http://localhost:8080/")
log.Fatalln(http.ListenAndServe(":8080", r))
}

10
config/config.go

@ -0,0 +1,10 @@
package config
type Config struct {
DBUser string
DBPass string
DBHost string
DBPort string
DBName string
Debug bool
}

48
css/reset.css

@ -1,48 +0,0 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

79
database/memory.go

@ -0,0 +1,79 @@
package database
import (
"context"
"fmt"
"sync"
"git.yetaga.in/alazyreader/library/book"
)
type Memory struct {
lock sync.Mutex
shelf []book.Book
}
func (m *Memory) GetAllBooks(_ context.Context) ([]book.Book, error) {
m.lock.Lock()
defer m.lock.Unlock()
if m.shelf == nil {
m.shelf = []book.Book{}
}
return m.shelf, nil
}
func (m *Memory) AddBook(_ context.Context, b *book.Book) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.shelf == nil {
m.shelf = []book.Book{}
}
m.shelf = append(m.shelf, *b)
return nil
}
func (m *Memory) UpdateBook(_ context.Context, old, new *book.Book) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.shelf == nil {
m.shelf = []book.Book{}
return fmt.Errorf("book does not exist")
}
if old.ID != new.ID {
return fmt.Errorf("cannot change book ID")
}
for i := range m.shelf {
if m.shelf[i].ID == old.ID {
m.shelf[i] = *new
return nil
}
}
return fmt.Errorf("book does not exist")
}
func (m *Memory) DeleteBook(_ context.Context, b *book.Book) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.shelf == nil {
m.shelf = []book.Book{}
return fmt.Errorf("book does not exist")
}
for i := range m.shelf {
if m.shelf[i].ID == b.ID {
// reorder slice to remove book quickly
m.shelf[i] = m.shelf[len(m.shelf)-1]
m.shelf = m.shelf[:len(m.shelf)-1]
return nil
}
}
return fmt.Errorf("book does not exist")
}

20
database/migrations/mysql/01-init.sql

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS books(
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(1024),
authors VARCHAR(1024),
sortauthor VARCHAR(1024),
isbn10 VARCHAR(10),
isbn13 VARCHAR(13),
format VARCHAR(255),
genre VARCHAR(255),
publisher VARCHAR(255),
series VARCHAR(255),
volume VARCHAR(255),
year VARCHAR(10),
signed BOOLEAN,
description TEXT,
notes TEXT,
onloan VARCHAR(255),
coverurl VARCHAR(1024),
PRIMARY KEY (id)
)

301
database/mysql.go

@ -0,0 +1,301 @@
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"strconv"
"strings"
"time"
"git.yetaga.in/alazyreader/library/book"
_ "github.com/go-sql-driver/mysql"
)
//go:embed migrations/mysql
var migrationsFS embed.FS
type migration struct {
id int
name string
content []byte
}
type MySQL struct {
connection *sql.DB
tableName string
versionTable string
migrationsDirectory string
}
func NewMySQLConnection(user, pass, host, port, db string) (*MySQL, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, pass, host, port, db) // what a strange syntax
connection, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
return &MySQL{
connection: connection,
tableName: "books",
versionTable: "migrations",
migrationsDirectory: "migrations/mysql",
}, nil
}
func (m *MySQL) PrepareDatabase(ctx context.Context) error {
if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
return fmt.Errorf("uninitialized mysql client")
}
tablecheck := `SELECT count(*) AS count
FROM information_schema.TABLES
WHERE TABLE_NAME = '` + m.versionTable + `'
AND TABLE_SCHEMA in (SELECT DATABASE());`
tableschema := `CREATE TABLE ` + m.versionTable + `(
id INT NOT NULL,
name VARCHAR(100) NOT NULL,
datetime DATE,
PRIMARY KEY (id))`
var versionTableExists int
m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists)
if versionTableExists != 0 {
return nil
}
_, err := m.connection.ExecContext(ctx, tableschema)
return err
}
func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
return 0, fmt.Errorf("uninitialized mysql client")
}
var latestMigration int
err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration)
return latestMigration, err
}
func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
return 0, 0, fmt.Errorf("uninitialized mysql client")
}
migrations := map[int]migration{}
dir, err := migrationsFS.ReadDir(m.migrationsDirectory)
if err != nil {
return 0, 0, err
}
for f := range dir {
if dir[f].Type().IsRegular() {
mig := migration{}
id, name, err := parseMigrationFileName(dir[f].Name())
if err != nil {
return 0, 0, err
}
mig.id, mig.name = id, name
mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
migrations[mig.id] = mig
}
}
latestMigrationRan, err := m.GetLatestMigration(ctx)
if err != nil {
return 0, 0, err
}
// exit if nothing to do (that is, there's no greater migration ID)
if _, ok := migrations[latestMigrationRan+1]; !ok {
return latestMigrationRan, 0, nil
}
// loop over and apply migrations if required
tx, err := m.connection.BeginTx(ctx, nil)
if err != nil {
return latestMigrationRan, 0, err
}
migrationsRun := 0
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
mig := migrations[latestMigrationRan+1]
_, err := tx.ExecContext(ctx, string(mig.content))
if err != nil {
nestederr := tx.Rollback()
if nestederr != nil {
return latestMigrationRan, migrationsRun, nestederr
}
return latestMigrationRan, migrationsRun, err
}
_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now())
if err != nil {
nestederr := tx.Rollback()
if nestederr != nil {
return latestMigrationRan, migrationsRun, nestederr
}
return latestMigrationRan, migrationsRun, err
}
latestMigrationRan = latestMigrationRan + 1
migrationsRun = migrationsRun + 1
}
err = tx.Commit()
return latestMigrationRan, migrationsRun, err
}
func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
if m.connection == nil {
return nil, fmt.Errorf("uninitialized mysql client")
}
books := []book.Book{}
rows, err := m.connection.QueryContext(ctx, `
SELECT id,
title,
authors,
sortauthor,
isbn10,
isbn13,
format,
genre,
publisher,
series,
volume,
year,
signed,
description,
notes,
onloan,
coverurl
FROM `+m.tableName)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
b := book.Book{}
var authors string
err := rows.Scan(
&b.ID, &b.Title, &authors,
&b.SortAuthor, &b.ISBN10, &b.ISBN13,
&b.Format, &b.Genre, &b.Publisher,
&b.Series, &b.Volume, &b.Year,
&b.Signed, &b.Description, &b.Notes,
&b.OnLoan, &b.CoverURL)
if err != nil {
return nil, err
}
b.Authors = strings.Split(authors, ";")
books = append(books, b)
}
return books, nil
}
func (m *MySQL) AddBook(ctx context.Context, b *book.Book) error {
if m.connection == nil {
return fmt.Errorf("uninitialized mysql client")
}
res, err := m.connection.ExecContext(ctx, `
INSERT INTO `+m.tableName+`
(title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, onloan, coverurl)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.Title,
strings.Join(b.Authors, ";"),
b.SortAuthor,
b.ISBN10,
b.ISBN13,
b.Format,
b.Genre,
b.Publisher,
b.Series,
b.Volume,
b.Year,
b.Signed,
b.Description,
b.Notes,
b.OnLoan,
b.CoverURL,
)
if err != nil {
return err
}
i, err := res.RowsAffected()
if err != nil {
return err
}
if i != 1 {
return fmt.Errorf("unexpectedly updated more than one row: %d", i)
}
return nil
}
func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
if m.connection == nil {
return fmt.Errorf("uninitialized mysql client")
}
if old.ID != new.ID {
return fmt.Errorf("cannot change book ID")
}
res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+`
SET id=?
title=?
authors=?
sortauthor=?
isbn10=?
isbn13=?
format=?
genre=?
publisher=?
series=?
volume=?
year=?
signed=?
description=?
notes=?
onloan=?
coverurl=?
WHERE id=?`,
new.Title,
strings.Join(new.Authors, ";"),
new.SortAuthor,
new.ISBN10,
new.ISBN13,
new.Format,
new.Genre,
new.Publisher,
new.Series,
new.Volume,
new.Year,
new.Signed,
new.Description,
new.Notes,
new.OnLoan,
new.CoverURL,
old.ID)
if err != nil {
return err
}
i, err := res.RowsAffected()
if err != nil {
return err
}
if i != 1 {
return fmt.Errorf("unexpectedly updated more than one row: %d", i)
}
return nil
}
func parseMigrationFileName(filename string) (int, string, error) {
sp := strings.SplitN(filename, "-", 2)
i, err := strconv.Atoi(sp[0])
if err != nil {
return 0, "", err
}
tr := strings.TrimSuffix(sp[1], ".sql")
return i, tr, nil
}

8
docker-compose.yml

@ -0,0 +1,8 @@
version: "3.8"
services:
mysql:
image: mysql:5.7
ports:
- 3306:3306
environment:
- MYSQL_ROOT_PASSWORD=KigYBNCT9IU5XyB3ehzMLFWyI # for dev testing only, obviously.

BIN
favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

BIN
frontend/files/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
frontend/files/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

243
frontend/files/index.html

@ -0,0 +1,243 @@
<!DOCTYPE html>
<html>
<head>
<title>Library</title>
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link
href="https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700&display=swap"
as="style"
rel="stylesheet preload prefetch"
/>
<script type="text/javascript">
var sortState = {
sortBy: "sortAuthor",
sortOrder: "asc",
};
function init() {
fetch("/api")
.then((response) => response.json())
.then((books) => {
// prepare response
books.forEach((book) => {
book.sortTitle = titleCleaner(book.title);
if (!book["isbn-10"] && book["isbn-13"]) {
book["isbn-10"] = ISBNfromEAN(book["isbn-13"]);
}
if (!book.coverurl && book["isbn-10"]) {
book.coverurl =
`https://images-na.ssl-images-amazon.com/images/P/` +
book["isbn-10"] +
`.01.LZZ.jpg`;
}
});
return books;
})
.then((books) => {
document.getElementById("search").addEventListener("input", (e) => {
search(books, e.target.value);
});
return books;
})
.then(renderTable);
}
function search(books, searchBy) {
searchBy = searchCleaner(searchBy);
if (searchBy !== "") {
books = books.filter(
({ title, authors, genre, publisher, series, year }) => {
return Object.values({
title,
authors: authors.join(" "),
genre,
publisher,
series,
year,
}).find((field) => searchCleaner(field).indexOf(searchBy) !== -1);
}
);
}
renderTable(books);
}
function renderTable(books, sortField) {
if (sortField) {
if (sortState.sortBy === sortField && sortState.sortOrder === "asc") {
sortState.sortOrder = "desc";
} else {
sortState.sortOrder = "asc";
}
sortState.sortBy = sortField;
}
books.sort((one, two) =>
(one[sortState.sortBy] + one["sortTitle"]).localeCompare(
two[sortState.sortBy] + two["sortTitle"]
)
);
if (sortState.sortOrder === "desc") {
books.reverse();
}
books.forEach((e, i) => (e.rowNumber = i)); // re-key
// rendering
var bookElement = document.getElementById("books");
bookElement.innerHTML = TableTemplate(books);
// add listeners for selecting book to view
Array.from(bookElement.querySelectorAll("tbody tr"))
.slice(1) // remove header from Array
.forEach((row) => {
row.addEventListener("click", (e) => {
// add listener to swap current book
document.getElementById("current").innerHTML = BookTemplate(
books[e.currentTarget.id]
);
});
});
// add sorting callbacks
Array.from(
bookElement.querySelectorAll("tbody tr th[data-sort-by]")
).forEach((row) => {
row.addEventListener("click", function (e) {
renderTable(books, e.target.dataset.sortBy); // only add callback when there's a sortBy attribute
});
});
// mark currently active column
bookElement
.querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]")
.classList.add(sortState.sortOrder);
}
function titleCleaner(title) {
return title
.replace('"', "")
.replace(":", "")
.replace(/^(An?|The)\s/i, "");
}
function searchCleaner(str) {
return str
.toLowerCase()
.replace('"', "")
.replace(":", "")
.replace("'", "")
.replace(" ", "");
}
function ISBNfromEAN(EAN) {
ISBN = EAN.slice(3, 12);
var checkdigit =
(11 -
(ISBN.split("").reduce((s, n, k) => s + n * (10 - k), 0) % 11)) %
11;
return ISBN + (checkdigit === 10 ? "X" : checkdigit);
}
function BookTemplate({
"isbn-13": isbn13,
authors,
coverurl,
description,
format,
notes,
onLoan,
publisher,
series,
signed,
title,
volume,
year,
}) {
return `${coverurl ? `<img src="${coverurl}"/>` : ""}
<h1 ${onLoan ? "class='onLoan' " : ""}>${title}</h1>
<h2>${authors}</h2>
<span>${isbn13}</span><br/>
<span>${publisher}, ${year}</span><br/>
${
series
? `<span>${series}${volume ? `, Volume ${volume}` : ""}</span><br/>`
: ""
}
${signed ? "<span>Signed by the author ✒</span><br/>" : ""}
<span>${format}</span>
${onLoan ? `<h2 class="onLoan">On loan to ${onLoan}</h2>` : ""}
<div class="description">
<p>${description}</p>
${notes ? `<span>Notes:</span><p>${notes}</p>` : ""}
</div>`;
}
function TableRowTemplate({
"isbn-13": isbn13,
authors,
onLoan,
publisher,
rowNumber,
signed,
title,
year,
}) {
return `<tr class="tRow ${onLoan ? "onLoan" : ""}" id="${rowNumber}">
<td class="title">
${title} ${
signed
? '<span class="signed" title="Signed by the author" ></span>︎'
: ""
}
</td>
<td class="author">${authors}</td>
<td class="publisher">${publisher}</td>
<td class="year">${year}</td>
<td class="isbn">${isbn13}</td>
</tr>`;
}
function TableTemplate(books) {
return `<table class="bookTable">
<tr>
<th data-sort-by="sortTitle" class="tHeader title">Title</th>
<th data-sort-by="sortAuthor" class="tHeader author">Author</th>
<th data-sort-by="publisher" class="tHeader publisher">Publisher</th>
<th data-sort-by="year" class="tHeader year">Year</th>
<th class="tHeader isbn">ISBN</th>
</tr>${books.reduce((acc, book) => {
return acc.concat(TableRowTemplate(book));
}, "")} </table>`;
}
window.addEventListener("DOMContentLoaded", () => {
init();
});
</script>
</head>
<body>
<div class="wrapper">
<div id="header">
<h1>Library</h1>
<a
target="_blank"
rel="noreferrer"
href="https://git.yetaga.in/alazyreader/library"
>git</a
>
<div id="searchBox">
<input
id="search"
type="text"
name="search"
placeholder="Search..."
/>
</div>
</div>
<div id="current">No Book Selected</div>
<div id="books"></div>
<!-- Table goes here -->
</div>
</body>
</html>

144
css/style.css → frontend/files/style.css

@ -1,3 +1,134 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* site CSS starts here */
body {
overflow: hidden;
}
@ -8,10 +139,10 @@ body {
padding: 4px 10px;
background-color: #f7f3dc;
border-bottom: 2px solid #d8d0a0;
font-family: 'Libre Baskerville', sans-serif;
font-family: "Libre Baskerville", sans-serif;
}
#header h1{
#header h1 {
font-size: xx-large;
display: inline;
}
@ -31,7 +162,7 @@ body {
padding: 2px 5px;
border: none;
border-bottom: 2px solid #d8d0a0;
font-family: 'Libre Baskerville', sans-serif;
font-family: "Libre Baskerville", sans-serif;
}
#searchBox input:focus {
@ -39,7 +170,7 @@ body {
}
#searchBox input::placeholder {
font-family: 'Libre Baskerville', sans-serif;
font-family: "Libre Baskerville", sans-serif;
color: #d8d0a0;
}
@ -63,7 +194,7 @@ body {
.bookTable th {
font-weight: bold;
text-align: left;
font-family: 'Libre Baskerville', sans-serif;
font-family: "Libre Baskerville", sans-serif;
}
.bookTable th[data-sort-by] {
@ -97,7 +228,8 @@ body {
bottom: 2px;
}
.bookTable td, .bookTable th {
.bookTable td,
.bookTable th {
padding: 5px;
min-width: 50px;
}

13
frontend/frontend.go

@ -0,0 +1,13 @@
package frontend
import (
"embed"
"io/fs"
)
//go:embed files
var static embed.FS
func Root() (fs.FS, error) {
return fs.Sub(static, "files")
}

9
go.mod

@ -0,0 +1,9 @@
module git.yetaga.in/alazyreader/library
go 1.16
require (
github.com/gdamore/tcell v1.4.0
github.com/go-sql-driver/mysql v1.6.0
github.com/kelseyhightower/envconfig v1.4.0
)

16
go.sum

@ -0,0 +1,16 @@
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

65
importer/csv.go

@ -0,0 +1,65 @@
package importer
import (
"encoding/csv"
"io"
"strings"
"git.yetaga.in/alazyreader/library/book"
)
func CSVToBooks(r io.Reader) ([]book.Book, error) {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return nil, err
}
hmap := parseHeader(header)
books := []book.Book{}
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return books, err
}
b := book.Book{
Title: row[hmap["title"]],
Authors: parseAuthors(row[hmap["author"]]),
SortAuthor: row[hmap["authorlast"]],
ISBN10: row[hmap["isbn-10"]],
ISBN13: row[hmap["isbn-13"]],