Compare commits
208 Commits
913ec3d05e
...
master
Author | SHA1 | Date | |
---|---|---|---|
2fcc42597d | |||
f3f8995d53 | |||
6c9320efae | |||
c9131bded8 | |||
e1fe23bcba | |||
d3febf1f6c | |||
0fe9679891 | |||
03c835139f | |||
af42df691b | |||
95b4fc1802 | |||
3874b2cc9d | |||
049e55c9c9 | |||
a370265e2e | |||
535dd86aba | |||
b0896539c8 | |||
28d9c9b659 | |||
6f9e1ec589 | |||
2adbe3004f | |||
da9fbaf347 | |||
5bca510e83 | |||
e76c63ccdc | |||
288c345a22 | |||
151265cc0e | |||
91cd6a1810 | |||
64b767d0b5 | |||
94de300e16 | |||
813f8a43b2 | |||
d219f2b142 | |||
bbc4a5fecf | |||
20de1eccbb | |||
7076c40e63 | |||
7fafd997ca | |||
007c4a5954 | |||
c39837fa0a | |||
956051a8b5 | |||
81bdb3d4d8 | |||
33354dc21a | |||
37682d66c4 | |||
bd6831233e | |||
dcf5508576 | |||
c996dcaef2 | |||
0e09e47694 | |||
3d4d10f11c | |||
ae0eb5f001 | |||
d36a89f61c | |||
441809573e | |||
671bffdb2f | |||
a3413934b8 | |||
5fb1786b5a | |||
8293b7b384 | |||
db543392d9 | |||
405063d20b | |||
030a403641 | |||
935956c7bf | |||
d9d57c4b2b | |||
155f5a967f | |||
de3a121b3d | |||
7175484edb | |||
9a1974a538 | |||
757112837d | |||
990d18bc64 | |||
cd9c4e72ac | |||
2ee8278652 | |||
d4f021ff89 | |||
89291c1c0a | |||
bd7cf08fcb | |||
dc556e210e | |||
734f2d094f | |||
aca9b1f672 | |||
b5f04fb3dc | |||
eef2505285 | |||
a6887df550 | |||
c5b4464a59 | |||
b641ac0ca6 | |||
29434549ce | |||
c086a0ccb1 | |||
7bb73b8023 | |||
cdeb5e2d84 | |||
551f1ef203 | |||
5ef60f70f1 | |||
f5d8ad7c8c | |||
b1708ec1a8 | |||
94a9a0b77e | |||
0ab60f1297 | |||
b68ac7f643 | |||
5f4da44bf0 | |||
0a337e42dd | |||
165a913828 | |||
aec3b2dff4 | |||
cf8b07725b | |||
467d8202e2 | |||
2b6024e229 | |||
62d03849a0 | |||
7610034240 | |||
7a6f5740e0 | |||
09478cbd5b | |||
609d6cf166 | |||
55b6c0689d | |||
363dc85eb4 | |||
c9b9805cee | |||
bf3c3d1dd7 | |||
e3e8d68c95 | |||
960b0b8766 | |||
f7b2e32651 | |||
1554b151cb | |||
a191e41453 | |||
0848406a85 | |||
0ce38e5453 | |||
bd1bf93ea0 | |||
c26e9513ad | |||
cb53a35de6 | |||
d69d416c49 | |||
caba03e58a | |||
315fb4e9d2 | |||
dc7218131d | |||
878635450c | |||
138a4a62c1 | |||
6658edfd09 | |||
4a13dc5e7f | |||
43c3a25758 | |||
c30052bac7 | |||
e8c3da4ac8 | |||
f282b10c05 | |||
95b269ca05 | |||
3d2c9964dc | |||
9d3a6fc876 | |||
77ddc7ec8e | |||
1069dadd10 | |||
27c3a5c881 | |||
899ad531a4 | |||
d2b68c1889 | |||
727dd7ae6f | |||
c2d9bde962 | |||
905c596491 | |||
f8dcf14346 | |||
b031bca91f | |||
5938a693c9 | |||
25bc263c62 | |||
25b1ced464 | |||
91eafafd84 | |||
c55c9116d0 | |||
ef55ec0663 | |||
0a5cea9fb1 | |||
2118e8b790 | |||
1ab5a20fb2 | |||
c1085924f7 | |||
3736096531 | |||
7dea7afd86 | |||
fa2f48a4a3 | |||
2e3359df7a | |||
db3387aac9 | |||
e6e20a32f8 | |||
26b6ce6157 | |||
8bdf848cbf | |||
3b2e8cc79b | |||
727e4e7867 | |||
b11316abe5 | |||
f802943316 | |||
20ea787617 | |||
06f9f864c4 | |||
fff582aaf0 | |||
b692fac091 | |||
3fdc4bf509 | |||
0e878dac97 | |||
3e34199f3b | |||
d45c3ebf33 | |||
e8388b979c | |||
2814c5dc68 | |||
98f4c4b0c1 | |||
48fc2970ad | |||
f9d1cf744e | |||
2346f17edd | |||
886e28f348 | |||
6a42420d36 | |||
ce194c1418 | |||
ca27b9f1aa | |||
efd624bd7d | |||
c27751dd93 | |||
e56b6da79d | |||
63c334a40f | |||
a38fdaef91 | |||
5f766a0efb | |||
5fed609b13 | |||
7b7e8d0058 | |||
ab6de21418 | |||
729ed30450 | |||
2116fbb15c | |||
4b78201bd1 | |||
ad262c5fde | |||
79c718153d | |||
c492eba657 | |||
8a1e5f2b17 | |||
83b00b69b1 | |||
5e101c236a | |||
759835993e | |||
04aadf1d10 | |||
88d9c4f2f8 | |||
781d96ca14 | |||
8cff0ec6ab | |||
832e2025a0 | |||
474ea9b57c | |||
84803f1e3d | |||
98584bbef6 | |||
c9b70f02e7 | |||
7ee118c1cd | |||
3e1b06e95a | |||
b52949f3e9 | |||
4a02014bef |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/server
|
||||
/manager
|
||||
*.properties
|
||||
.DS_Store
|
||||
*.csv
|
||||
/vendor
|
||||
.recordsCache
|
||||
.config
|
23
.woodpecker.yml
Normal file
23
.woodpecker.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
steps:
|
||||
test:
|
||||
image: golang:1.24
|
||||
commands:
|
||||
- go test ./...
|
||||
build:
|
||||
image: docker
|
||||
commands:
|
||||
- apk add curl
|
||||
- docker login -u docker -p $DOCKER_PASSWORD registry.yetaga.in
|
||||
- docker build -t registry.yetaga.in/library:latest .
|
||||
- docker push registry.yetaga.in/library:latest
|
||||
- 'curl http://100.113.98.36:4000/api/fetch -H "Authorization: Bearer $COMPOSE_TOKEN"'
|
||||
- 'curl http://100.113.98.36:4000/api/update -H "Authorization: Bearer $COMPOSE_TOKEN"'
|
||||
environment:
|
||||
DOCKER_PASSWORD:
|
||||
from_secret: docker_password
|
||||
COMPOSE_TOKEN:
|
||||
from_secret: compose_token
|
||||
when:
|
||||
branch: "master"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
@@ -1,8 +1,9 @@
|
||||
FROM golang:1.16
|
||||
FROM golang:1.24
|
||||
WORKDIR /src
|
||||
COPY . ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/serve
|
||||
|
||||
FROM scratch
|
||||
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=0 /src/server ./
|
||||
CMD ["/server"]
|
17
Makefile
17
Makefile
@@ -1,34 +1,27 @@
|
||||
.PHONY: up down run-server run-manager test
|
||||
.PHONY: up down run 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
|
||||
build: server
|
||||
|
||||
run-server: build
|
||||
run: 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
|
||||
docker-compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
docker-compose down
|
||||
|
@@ -1,113 +0,0 @@
|
||||
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
|
||||
}
|
@@ -1,336 +0,0 @@
|
||||
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{}
|
||||
}
|
179
cmd/serve/api.go
Normal file
179
cmd/serve/api.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
static fs.FS
|
||||
lib Library
|
||||
rcol RecordCollection
|
||||
query Query
|
||||
ts *tailscale.LocalClient
|
||||
isAdmin bool
|
||||
}
|
||||
|
||||
type path map[string]func()
|
||||
|
||||
func (h path) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if f, ok := h[r.Method]; ok {
|
||||
f()
|
||||
return
|
||||
}
|
||||
writeJSONerror(w, "method not supported", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func writeJSONerror(w http.ResponseWriter, err string, status int) {
|
||||
log.Println(err)
|
||||
writeJSON(w, struct{ Status, Reason string }{Status: "error", Reason: err}, status)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, b any, status int) {
|
||||
bytes, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
w.Write(bytes)
|
||||
w.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/mode":
|
||||
path{
|
||||
http.MethodGet: func() {
|
||||
writeJSON(w, struct{ Admin bool }{Admin: router.isAdmin}, http.StatusOK)
|
||||
},
|
||||
}.ServeHTTP(w, r)
|
||||
case "/api/whoami":
|
||||
if !router.isAdmin {
|
||||
http.NotFoundHandler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
path{
|
||||
http.MethodGet: func() { getWhoAmI(router.ts, w, r) },
|
||||
}.ServeHTTP(w, r)
|
||||
case "/api/records":
|
||||
path{
|
||||
http.MethodGet: func() { getRecords(router.rcol, w, r) },
|
||||
}.ServeHTTP(w, r)
|
||||
case "/api/books":
|
||||
p := path{
|
||||
http.MethodGet: func() { getBooks(router.lib, w, r) },
|
||||
}
|
||||
if router.isAdmin {
|
||||
p[http.MethodPost] = func() { addBook(router.lib, w, r) }
|
||||
p[http.MethodDelete] = func() { deleteBook(router.lib, w, r) }
|
||||
}
|
||||
p.ServeHTTP(w, r)
|
||||
case "/api/query":
|
||||
if !router.isAdmin {
|
||||
http.NotFoundHandler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
path{
|
||||
http.MethodPost: func() { lookupBook(router.query, w, r) },
|
||||
}.ServeHTTP(w, r)
|
||||
default:
|
||||
static(router.static).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getBooks(l Library, w http.ResponseWriter, r *http.Request) {
|
||||
books, err := l.GetAllBooks(r.Context())
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, books, http.StatusOK)
|
||||
}
|
||||
|
||||
func addBook(l Library, w http.ResponseWriter, r *http.Request) {
|
||||
book, err := ReadBody[media.Book](r.Body)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err = l.AddBook(r.Context(), book); err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func deleteBook(l Library, w http.ResponseWriter, r *http.Request) {
|
||||
book, err := ReadBody[media.Book](r.Body)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err = l.DeleteBook(r.Context(), book); err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func getRecords(l RecordCollection, w http.ResponseWriter, r *http.Request) {
|
||||
records, err := l.GetAllRecords(r.Context())
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, records, http.StatusOK)
|
||||
}
|
||||
|
||||
func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request) {
|
||||
whois, err := ts.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, whois.UserProfile, http.StatusOK)
|
||||
}
|
||||
|
||||
func lookupBook(query Query, w http.ResponseWriter, r *http.Request) {
|
||||
req, err := ReadBody[media.Book](r.Body)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
book, err := query.GetByISBN(req.ISBN13)
|
||||
if err != nil {
|
||||
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, book, http.StatusOK)
|
||||
}
|
||||
|
||||
func static(f fs.FS) http.Handler {
|
||||
return http.FileServer(http.FS(f))
|
||||
}
|
||||
|
||||
func ReadBody[T any](r io.ReadCloser) (*T, error) {
|
||||
t := new(T)
|
||||
if r == nil {
|
||||
return t, fmt.Errorf("no body provided")
|
||||
}
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("error reading body: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(b, t)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("error reading body: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
@@ -2,99 +2,169 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/book"
|
||||
"git.yetaga.in/alazyreader/library/config"
|
||||
"git.yetaga.in/alazyreader/library/database"
|
||||
"git.yetaga.in/alazyreader/library/frontend"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
"git.yetaga.in/alazyreader/library/query"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
func obscureStr(in string, l int) string {
|
||||
return in[0:max(l, len(in))] + strings.Repeat("*", max(0, len(in)-l))
|
||||
}
|
||||
|
||||
type Library interface {
|
||||
GetAllBooks(context.Context) ([]book.Book, error)
|
||||
GetAllBooks(context.Context) ([]media.Book, error)
|
||||
AddBook(context.Context, *media.Book) error
|
||||
DeleteBook(context.Context, *media.Book) error
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
static fs.FS
|
||||
lib Library
|
||||
type RecordCollection interface {
|
||||
GetAllRecords(context.Context) ([]media.Record, error)
|
||||
}
|
||||
|
||||
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))
|
||||
type Query interface {
|
||||
GetByISBN(string) (*media.Book, error)
|
||||
}
|
||||
|
||||
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))
|
||||
must.Do(envconfig.Process("library", &c))
|
||||
|
||||
var lib Library
|
||||
if c.DBType == "memory" {
|
||||
lib = &database.Memory{}
|
||||
} else if c.DBType == "sql" {
|
||||
sqllib, latest, run, err := setupSQL(c)
|
||||
if err != nil {
|
||||
log.Fatalf("sql connection err: %v", err)
|
||||
}
|
||||
log.Fatalf("vars: %+v", c)
|
||||
log.Printf("latest migration: %d; migrations run: %d", latest, run)
|
||||
lib = sqllib
|
||||
}
|
||||
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))
|
||||
discogsCache := must.Get(database.NewDiscogsCache(
|
||||
c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
|
||||
))
|
||||
queryProvider := &query.GoogleBooks{}
|
||||
staticRoot := must.Get(frontend.Root())
|
||||
|
||||
servers := make(chan (*http.Server), 3)
|
||||
errGroup := errgroup.Group{}
|
||||
errGroup.Go(func() error {
|
||||
return start(servers)(publicServer(8080, &Router{
|
||||
static: staticRoot,
|
||||
lib: lib,
|
||||
rcol: discogsCache,
|
||||
isAdmin: false,
|
||||
}))
|
||||
})
|
||||
errGroup.Go(func() error {
|
||||
return start(servers)(tailscaleListener("library-admin", &Router{
|
||||
static: staticRoot,
|
||||
lib: lib,
|
||||
rcol: discogsCache,
|
||||
query: queryProvider,
|
||||
isAdmin: true,
|
||||
}))
|
||||
})
|
||||
errGroup.Go(func() error {
|
||||
return shutdown(servers)
|
||||
})
|
||||
log.Println(errGroup.Wait())
|
||||
}
|
||||
|
||||
func setupSQL(c config.Config) (Library, int, int, error) {
|
||||
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
|
||||
if c.DBPass != "" {
|
||||
c.DBPass = obscureStr(c.DBPass, 3)
|
||||
}
|
||||
if c.DiscogsToken != "" {
|
||||
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
|
||||
}
|
||||
return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c)
|
||||
}
|
||||
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
err = sql.PrepareDatabase(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
latest, run, err := sql.RunMigrations(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return sql, latest, run, nil
|
||||
}
|
||||
|
||||
func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error {
|
||||
return func(s *http.Server, l net.Listener, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
servers <- s
|
||||
return s.Serve(l)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown(servers chan (*http.Server)) error {
|
||||
sigint := make(chan os.Signal, 1)
|
||||
signal.Notify(sigint, os.Interrupt)
|
||||
<-sigint
|
||||
close(servers)
|
||||
var err error
|
||||
for server := range servers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
if shutdownerr := server.Shutdown(ctx); shutdownerr != nil {
|
||||
err = shutdownerr
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) {
|
||||
server := &http.Server{Handler: handler}
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log.Printf("public server: http://0.0.0.0:%d/", port)
|
||||
return server, ln, nil
|
||||
}
|
||||
|
||||
func tailscaleListener(hostname string, handler *Router) (*http.Server, net.Listener, error) {
|
||||
s := &tsnet.Server{
|
||||
Dir: ".config/" + hostname,
|
||||
Hostname: hostname,
|
||||
Logf: func(s string, a ...any) { // silence most tsnet logs
|
||||
if strings.HasPrefix(s, "To start this tsnet server") {
|
||||
log.Printf(s, a...)
|
||||
}
|
||||
},
|
||||
}
|
||||
ln, err := s.Listen("tcp", ":80")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
handler.ts, err = s.LocalClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log.Printf("management server: http://%s/", hostname)
|
||||
return &http.Server{Handler: handler}, ln, nil
|
||||
}
|
||||
|
@@ -1,10 +1,15 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
DBUser string
|
||||
DBPass string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBName string
|
||||
Debug bool
|
||||
DBType string `default:"sql"`
|
||||
DBUser string
|
||||
DBPass string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBName string
|
||||
DiscogsToken string
|
||||
DiscogsUser string
|
||||
DiscogsPersist bool
|
||||
DiscogsCacheFile string `default:".recordsCache"`
|
||||
Debug bool
|
||||
}
|
||||
|
@@ -5,43 +5,43 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/book"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
type Memory struct {
|
||||
lock sync.Mutex
|
||||
shelf []book.Book
|
||||
shelf []media.Book
|
||||
}
|
||||
|
||||
func (m *Memory) GetAllBooks(_ context.Context) ([]book.Book, error) {
|
||||
func (m *Memory) GetAllBooks(_ context.Context) ([]media.Book, error) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.shelf == nil {
|
||||
m.shelf = []book.Book{}
|
||||
m.shelf = []media.Book{}
|
||||
}
|
||||
|
||||
return m.shelf, nil
|
||||
}
|
||||
|
||||
func (m *Memory) AddBook(_ context.Context, b *book.Book) error {
|
||||
func (m *Memory) AddBook(_ context.Context, b *media.Book) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.shelf == nil {
|
||||
m.shelf = []book.Book{}
|
||||
m.shelf = []media.Book{}
|
||||
}
|
||||
|
||||
m.shelf = append(m.shelf, *b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateBook(_ context.Context, old, new *book.Book) error {
|
||||
func (m *Memory) UpdateBook(_ context.Context, old, new *media.Book) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.shelf == nil {
|
||||
m.shelf = []book.Book{}
|
||||
m.shelf = []media.Book{}
|
||||
return fmt.Errorf("book does not exist")
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ func (m *Memory) UpdateBook(_ context.Context, old, new *book.Book) error {
|
||||
return fmt.Errorf("book does not exist")
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteBook(_ context.Context, b *book.Book) error {
|
||||
func (m *Memory) DeleteBook(_ context.Context, b *media.Book) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.shelf == nil {
|
||||
m.shelf = []book.Book{}
|
||||
m.shelf = []media.Book{}
|
||||
return fmt.Errorf("book does not exist")
|
||||
}
|
||||
|
||||
|
1
database/migrations/mysql/02-remove-onloan.sql
Normal file
1
database/migrations/mysql/02-remove-onloan.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE books DROP COLUMN onloan;
|
2
database/migrations/mysql/03-add-childrens-column.sql
Normal file
2
database/migrations/mysql/03-add-childrens-column.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `books`
|
||||
ADD COLUMN `childrens` tinyint(1) NOT NULL DEFAULT 0
|
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/book"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
@@ -49,15 +49,15 @@ func (m *MySQL) PrepareDatabase(ctx context.Context) error {
|
||||
return fmt.Errorf("uninitialized mysql client")
|
||||
}
|
||||
|
||||
tablecheck := `SELECT count(*) AS count
|
||||
tablecheck := fmt.Sprintf(`SELECT count(*) AS count
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_NAME = '` + m.versionTable + `'
|
||||
AND TABLE_SCHEMA in (SELECT DATABASE());`
|
||||
tableschema := `CREATE TABLE ` + m.versionTable + `(
|
||||
WHERE TABLE_NAME = '%s'
|
||||
AND TABLE_SCHEMA in (SELECT DATABASE());`, m.versionTable)
|
||||
tableschema := fmt.Sprintf(`CREATE TABLE %s (
|
||||
id INT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
datetime DATE,
|
||||
PRIMARY KEY (id))`
|
||||
PRIMARY KEY (id))`, m.versionTable)
|
||||
|
||||
var versionTableExists int
|
||||
m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists)
|
||||
@@ -73,8 +73,9 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
|
||||
return 0, fmt.Errorf("uninitialized mysql client")
|
||||
}
|
||||
|
||||
migrationCheck := fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", m.versionTable)
|
||||
var latestMigration int
|
||||
err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration)
|
||||
err := m.connection.QueryRowContext(ctx, migrationCheck).Scan(&latestMigration)
|
||||
return latestMigration, err
|
||||
}
|
||||
|
||||
@@ -97,6 +98,9 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
||||
}
|
||||
mig.id, mig.name = id, name
|
||||
mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failure loading migration: %w", err)
|
||||
}
|
||||
migrations[mig.id] = mig
|
||||
}
|
||||
}
|
||||
@@ -116,6 +120,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
||||
if err != nil {
|
||||
return latestMigrationRan, 0, err
|
||||
}
|
||||
migrationLogSql := fmt.Sprintf("INSERT INTO %s (id, name, datetime) VALUES (?, ?, ?)", m.versionTable)
|
||||
migrationsRun := 0
|
||||
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
|
||||
mig := migrations[latestMigrationRan+1]
|
||||
@@ -127,7 +132,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
||||
}
|
||||
return latestMigrationRan, migrationsRun, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now())
|
||||
_, err = tx.ExecContext(ctx, migrationLogSql, mig.id, mig.name, time.Now())
|
||||
if err != nil {
|
||||
nestederr := tx.Rollback()
|
||||
if nestederr != nil {
|
||||
@@ -142,82 +147,55 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
||||
return latestMigrationRan, migrationsRun, err
|
||||
}
|
||||
|
||||
func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
|
||||
func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.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)
|
||||
allBooksQuery := fmt.Sprintf(`SELECT
|
||||
id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher,
|
||||
series, volume, year, signed, description, notes, coverurl, childrens
|
||||
FROM %s`, m.tableName)
|
||||
|
||||
books := []media.Book{}
|
||||
rows, err := m.connection.QueryContext(ctx, allBooksQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
b := book.Book{}
|
||||
b := media.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)
|
||||
&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.CoverURL, &b.Childrens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Authors = strings.Split(authors, ";")
|
||||
b.Notes = strings.TrimSpace(b.Notes)
|
||||
books = append(books, b)
|
||||
}
|
||||
|
||||
return books, nil
|
||||
}
|
||||
|
||||
func (m *MySQL) AddBook(ctx context.Context, b *book.Book) error {
|
||||
func (m *MySQL) AddBook(ctx context.Context, b *media.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)
|
||||
(
|
||||
title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series,
|
||||
volume, year, signed, description, notes, coverurl, childrens
|
||||
)
|
||||
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,
|
||||
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.CoverURL, b.Childrens,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,7 +210,7 @@ func (m *MySQL) AddBook(ctx context.Context, b *book.Book) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
|
||||
func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
|
||||
if m.connection == nil {
|
||||
return fmt.Errorf("uninitialized mysql client")
|
||||
}
|
||||
@@ -242,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
|
||||
|
||||
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=?
|
||||
SET
|
||||
id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=?
|
||||
series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=?
|
||||
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)
|
||||
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.CoverURL, new.Childrens, old.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -290,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationFileName(filename string) (int, string, error) {
|
||||
sp := strings.SplitN(filename, "-", 2)
|
||||
i, err := strconv.Atoi(sp[0])
|
||||
|
182
database/records.go
Normal file
182
database/records.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
"github.com/irlndts/go-discogs"
|
||||
)
|
||||
|
||||
type DiscogsCache struct {
|
||||
authToken string
|
||||
m sync.Mutex
|
||||
cache []media.Record
|
||||
maxCacheAge time.Duration
|
||||
lastRefresh time.Time
|
||||
client discogs.Discogs
|
||||
username string
|
||||
persistence bool
|
||||
persistFile string
|
||||
}
|
||||
|
||||
type persistence struct {
|
||||
CachedRecordSlice []media.Record
|
||||
LastRefresh time.Time
|
||||
}
|
||||
|
||||
func NewDiscogsCache(token string, maxCacheAge time.Duration, username string, persist bool, persistFile string) (*DiscogsCache, error) {
|
||||
client, err := discogs.New(&discogs.Options{
|
||||
UserAgent: "library.yetaga.in personal collection cache",
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache := &DiscogsCache{
|
||||
authToken: token,
|
||||
client: client,
|
||||
maxCacheAge: maxCacheAge,
|
||||
username: username,
|
||||
persistence: persist,
|
||||
persistFile: persistFile,
|
||||
}
|
||||
if cache.persistence && cache.persistFile != "" {
|
||||
cache.cache, cache.lastRefresh, err = cache.loadRecordsFromFS(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache load failed: %w", err)
|
||||
}
|
||||
if time.Now().After(cache.lastRefresh.Add(cache.maxCacheAge)) {
|
||||
log.Printf("cache expired, running refresh...")
|
||||
go func() {
|
||||
err := cache.FlushCache(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("error loading discogs content: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *DiscogsCache) FlushCache(ctx context.Context) error {
|
||||
c.m.Lock()
|
||||
defer c.m.Unlock()
|
||||
records, err := c.fetchRecords(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.saveRecordsToCache(ctx, records)
|
||||
}
|
||||
|
||||
func (c *DiscogsCache) GetAllRecords(ctx context.Context) ([]media.Record, error) {
|
||||
c.m.Lock()
|
||||
defer c.m.Unlock()
|
||||
if time.Now().After(c.lastRefresh.Add(c.maxCacheAge)) {
|
||||
records, err := c.fetchRecords(ctx, nil)
|
||||
if err != nil {
|
||||
return c.cache, err
|
||||
}
|
||||
err = c.saveRecordsToCache(ctx, records)
|
||||
return c.cache, err
|
||||
}
|
||||
return c.cache, nil
|
||||
}
|
||||
|
||||
func (c *DiscogsCache) loadRecordsFromFS(ctx context.Context) ([]media.Record, time.Time, error) {
|
||||
p := &persistence{}
|
||||
f, err := os.Open(c.persistFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("%s not found, skipping file load...", c.persistFile)
|
||||
return nil, time.Time{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("error opening cache file %s: %w", c.persistFile, err)
|
||||
}
|
||||
err = gob.NewDecoder(f).Decode(p)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("error readhing from cache file %s: %w", c.persistFile, err)
|
||||
}
|
||||
log.Printf("loaded %d records from %s", len(p.CachedRecordSlice), c.persistFile)
|
||||
return p.CachedRecordSlice, p.LastRefresh, nil
|
||||
}
|
||||
|
||||
func (c *DiscogsCache) saveRecordsToCache(ctx context.Context, records []media.Record) error {
|
||||
c.cache = records
|
||||
c.lastRefresh = time.Now()
|
||||
if c.persistence && c.persistFile != "" {
|
||||
p := persistence{
|
||||
CachedRecordSlice: c.cache,
|
||||
LastRefresh: c.lastRefresh,
|
||||
}
|
||||
f, err := os.OpenFile(c.persistFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening cache file %s: %w", c.persistFile, err)
|
||||
}
|
||||
err = gob.NewEncoder(f).Encode(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing to cache file %s: %w", c.persistFile, err)
|
||||
}
|
||||
log.Printf("wrote %d records to %s", len(c.cache), c.persistFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DiscogsCache) fetchRecords(ctx context.Context, pagination *discogs.Pagination) ([]media.Record, error) {
|
||||
records := []media.Record{}
|
||||
if pagination == nil {
|
||||
pagination = getPagination(1)
|
||||
}
|
||||
log.Printf("calling discogs API, page %v", pagination.Page)
|
||||
coll, err := c.client.CollectionItemsByFolder(c.username, 0, pagination)
|
||||
if err != nil {
|
||||
return records, fmt.Errorf("error loading collection: %w", err)
|
||||
}
|
||||
log.Printf("length: %v, first item in list: %s", len(coll.Items), coll.Items[0].BasicInformation.Title)
|
||||
for i := range coll.Items {
|
||||
records = append(records, collectionItemToRecord(&coll.Items[i]))
|
||||
}
|
||||
// recurse down the list
|
||||
if coll.Pagination.URLs.Next != "" {
|
||||
coll, err := c.fetchRecords(ctx, getPagination(pagination.Page+1))
|
||||
if err != nil {
|
||||
return records, err
|
||||
}
|
||||
records = append(records, coll...)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func getPagination(page int) *discogs.Pagination {
|
||||
return &discogs.Pagination{Page: page, Sort: "added", SortOrder: "asc", PerPage: 100}
|
||||
}
|
||||
|
||||
func collectionItemToRecord(item *discogs.CollectionItemSource) media.Record {
|
||||
artists := []string{}
|
||||
for _, artist := range item.BasicInformation.Artists {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
year := strconv.Itoa(item.BasicInformation.Year)
|
||||
if year == "0" {
|
||||
year = ""
|
||||
}
|
||||
return media.Record{
|
||||
ID: item.ID,
|
||||
AlbumName: item.BasicInformation.Title,
|
||||
Artists: artists,
|
||||
Identifier: item.BasicInformation.Labels[0].Catno,
|
||||
Format: item.BasicInformation.Formats[0].Name,
|
||||
Genre: item.BasicInformation.Genres[0],
|
||||
Label: item.BasicInformation.Labels[0].Name,
|
||||
Year: year,
|
||||
CoverURL: item.BasicInformation.CoverImage,
|
||||
DiscogsURL: fmt.Sprintf("https://www.discogs.com/release/%v", item.ID),
|
||||
}
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
image: mysql:9.3
|
||||
ports:
|
||||
- 3306:3306
|
||||
environment:
|
||||
|
322
frontend/files/app.js
Normal file
322
frontend/files/app.js
Normal file
@@ -0,0 +1,322 @@
|
||||
var sortState = {
|
||||
sortBy: "sortAuthor",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
|
||||
var admin = false;
|
||||
|
||||
var books;
|
||||
|
||||
function checkAdminMode() {
|
||||
fetch("/api/mode")
|
||||
.then((response) => response.json())
|
||||
.then((resp) => (admin = resp.Admin))
|
||||
.then(() => {
|
||||
if (admin) {
|
||||
var element = document.getElementById("addBook");
|
||||
element.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
renderAddBookView();
|
||||
});
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadBookList() {
|
||||
fetch("/api/books")
|
||||
.then((response) => response.json())
|
||||
.then((list) => {
|
||||
// prepare response
|
||||
list.forEach(apiResponseParsing);
|
||||
books = list;
|
||||
document.getElementById("search").addEventListener("input", rerender);
|
||||
document.getElementById("childrens").addEventListener("change", rerender);
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
var searchValue = document.getElementById("search").value;
|
||||
var childrens = document.getElementById("childrens").checked;
|
||||
renderTable(search(searchValue, childrens));
|
||||
}
|
||||
|
||||
function init() {
|
||||
checkAdminMode();
|
||||
loadBookList();
|
||||
}
|
||||
|
||||
function renderAddBookView() {
|
||||
document.getElementById("current").innerHTML = AddBookTemplate();
|
||||
document.getElementById("lookup").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (document.getElementById("isbn-13").value.length === 13) {
|
||||
getPossibleBooks(document.getElementById("isbn-13").value);
|
||||
} else {
|
||||
console.log("no isbn");
|
||||
}
|
||||
});
|
||||
document.getElementById("save").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
saveBook({
|
||||
title: document.getElementById("title").value,
|
||||
authors: document.getElementById("authors").value.split(";"),
|
||||
sortAuthor: document.getElementById("sortAuthor").value,
|
||||
"isbn-10": document.getElementById("isbn-10").value,
|
||||
"isbn-13": document.getElementById("isbn-13").value,
|
||||
publisher: document.getElementById("publisher").value,
|
||||
format: document.getElementById("format").value,
|
||||
genre: document.getElementById("genre").value,
|
||||
series: document.getElementById("series").value,
|
||||
volume: document.getElementById("volume").value,
|
||||
year: document.getElementById("year").value,
|
||||
coverURL: document.getElementById("coverURL").value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPossibleBooks(isbn) {
|
||||
fetch("/api/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ "isbn-13": isbn }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
Object.keys(json).forEach((key) => {
|
||||
var elem = document.getElementById(key);
|
||||
if (elem !== null) {
|
||||
elem.value = json[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveBook(book) {
|
||||
fetch("/api/books", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(book),
|
||||
}).then(() => {
|
||||
clearAddBookForm();
|
||||
loadBookList();
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(bookList, sortField) {
|
||||
if (sortField) {
|
||||
sortState.sortOrder =
|
||||
sortState.sortBy === sortField && sortState.sortOrder === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
sortState.sortBy = sortField;
|
||||
}
|
||||
bookList.sort((one, two) =>
|
||||
(one[sortState.sortBy] + one["sortTitle"]).localeCompare(
|
||||
two[sortState.sortBy] + two["sortTitle"]
|
||||
)
|
||||
);
|
||||
if (sortState.sortOrder === "desc") {
|
||||
bookList.reverse();
|
||||
}
|
||||
bookList.forEach((e, i) => (e.rowNumber = i)); // re-key
|
||||
|
||||
// rendering
|
||||
var bookElement = document.getElementById("books");
|
||||
bookElement.innerHTML = TableTemplate(bookList);
|
||||
|
||||
document.getElementById("bookCount").innerHTML = `${bookList.length} 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(
|
||||
bookList[e.currentTarget.id]
|
||||
);
|
||||
});
|
||||
});
|
||||
// add sorting callbacks
|
||||
Array.from(bookElement.querySelectorAll("tbody tr th[data-sort-by]")).forEach(
|
||||
(row) => {
|
||||
row.addEventListener("click", function (e) {
|
||||
// only add callback when there's a sortBy attribute
|
||||
renderTable(bookList, e.target.dataset.sortBy);
|
||||
});
|
||||
}
|
||||
);
|
||||
// mark currently active column
|
||||
bookElement
|
||||
.querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]")
|
||||
.classList.add(sortState.sortOrder);
|
||||
}
|
||||
|
||||
function apiResponseParsing(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 book;
|
||||
}
|
||||
|
||||
function search(searchBy, includeChildrensBooks) {
|
||||
searchBy = searchCleaner(searchBy);
|
||||
return books.filter(
|
||||
({ title, authors, genre, publisher, series, year, childrens }) => {
|
||||
var inSearch = true;
|
||||
if (searchBy !== "") {
|
||||
inSearch = Object.values({
|
||||
title,
|
||||
authors: authors.join(" "),
|
||||
genre,
|
||||
publisher,
|
||||
series,
|
||||
year,
|
||||
}).find((field) => searchCleaner(field).indexOf(searchBy) !== -1);
|
||||
}
|
||||
if (!includeChildrensBooks) {
|
||||
return inSearch && !childrens;
|
||||
}
|
||||
return inSearch;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function titleCleaner(title) {
|
||||
return title
|
||||
.replace('"', "")
|
||||
.replace(":", "")
|
||||
.replace(/^(An?|The)\s/i, "");
|
||||
}
|
||||
|
||||
function searchCleaner(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replaceAll('"', "")
|
||||
.replaceAll(":", "")
|
||||
.replaceAll("'", "")
|
||||
.replaceAll(" ", "");
|
||||
}
|
||||
|
||||
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 clearAddBookForm() {
|
||||
document
|
||||
.getElementById("newBookForm")
|
||||
.childNodes.forEach((node) =>
|
||||
node.nodeName === "LABEL" ? (node.lastChild.value = "") : null
|
||||
);
|
||||
}
|
||||
|
||||
function BookTemplate({
|
||||
"isbn-13": isbn13,
|
||||
"isbn-10": isbn10,
|
||||
authors,
|
||||
coverURL,
|
||||
format,
|
||||
publisher,
|
||||
series,
|
||||
signed,
|
||||
title,
|
||||
volume,
|
||||
year,
|
||||
}) {
|
||||
return `<img ${coverURL ? `src="${coverURL}"` : ``}/>
|
||||
<div class="bookDetails">
|
||||
<h1>${title}</h1>
|
||||
<h2>${authors}</h2>
|
||||
<span>${[isbn10, isbn13].filter((v) => v != "").join(" / ")}</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>
|
||||
${admin ? `<a href="#">Edit Book</a>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function TableRowTemplate({
|
||||
"isbn-13": isbn13,
|
||||
"isbn-10": isbn10,
|
||||
authors,
|
||||
publisher,
|
||||
rowNumber,
|
||||
signed,
|
||||
title,
|
||||
year,
|
||||
}) {
|
||||
return `<tr class="tRow" 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 ? isbn13 : isbn10}</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>`;
|
||||
}
|
||||
|
||||
function AddBookTemplate() {
|
||||
return `<div class="addBookView">
|
||||
<div id="newBookForm">
|
||||
${[
|
||||
{ name: "Title", id: "title", type: "text" },
|
||||
{ name: "Authors", id: "authors", type: "text" },
|
||||
{ name: "SortAuthor", id: "sortAuthor", type: "text" },
|
||||
{ name: "ISBN10", id: "isbn-10", type: "text" },
|
||||
{ name: "ISBN13", id: "isbn-13", type: "text" },
|
||||
{ name: "Publisher", id: "publisher", type: "text" },
|
||||
{ name: "Format", id: "format", type: "text" },
|
||||
{ name: "Genre", id: "genre", type: "text" },
|
||||
{ name: "Series", id: "series", type: "text" },
|
||||
{ name: "Volume", id: "volume", type: "text" },
|
||||
{ name: "Year", id: "year", type: "text" },
|
||||
{ name: "CoverURL", id: "coverURL", type: "text" },
|
||||
{ name: "Signed", id: "signed", type: "checkbox" },
|
||||
{ name: "Childrens", id: "childrens", type: "checkbox" },
|
||||
].reduce((acc, field) => {
|
||||
return acc.concat(
|
||||
`<label>${field.name} <input
|
||||
type="${field.type}"
|
||||
name="${field.name.toLowerCase()}"
|
||||
id="${field.id}"
|
||||
/></label><br/>`
|
||||
);
|
||||
}, "")}
|
||||
<input id="lookup" type="submit" value="look up">
|
||||
<input id="save" type="submit" value="save">
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
@@ -1,11 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<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
|
||||
@@ -13,220 +9,35 @@
|
||||
as="style"
|
||||
rel="stylesheet preload prefetch"
|
||||
/>
|
||||
<script type="text/javascript" src="app.js"></script>
|
||||
<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()
|
||||
.replaceAll('"', "")
|
||||
.replaceAll(":", "")
|
||||
.replaceAll("'", "")
|
||||
.replaceAll(" ", "");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
</script>
|
||||
<script
|
||||
defer
|
||||
data-domain="library.yetaga.in"
|
||||
src="https://stats.yetaga.in/js/script.js"
|
||||
></script>
|
||||
<meta name="description" content="A personal library record." />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div id="header">
|
||||
<h1>Library</h1>
|
||||
<a href="/records">records</a>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://git.yetaga.in/alazyreader/library"
|
||||
>git</a
|
||||
>
|
||||
<a href="#" id="addBook" class="hidden">add book</a>
|
||||
<div id="searchBox">
|
||||
<label for="childrens" class="bookCount"
|
||||
>Include Childrens Books?</label
|
||||
>
|
||||
<input id="childrens" type="checkbox" name="childrens" />
|
||||
<span id="bookCount" class="bookCount">_ books</span>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
|
168
frontend/files/records/app.js
Normal file
168
frontend/files/records/app.js
Normal file
@@ -0,0 +1,168 @@
|
||||
var sortState = {
|
||||
sortBy: "sortArtist",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
|
||||
function init() {
|
||||
fetch("/api/records")
|
||||
.then((response) => response.json())
|
||||
.then((records) => {
|
||||
// prepare response
|
||||
records.forEach(apiResponseParsing);
|
||||
document.getElementById("search").addEventListener("input", (e) => {
|
||||
renderTable(search(records, e.target.value));
|
||||
});
|
||||
renderTable(records);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(records, sortField) {
|
||||
if (sortField) {
|
||||
if (sortState.sortBy === sortField && sortState.sortOrder === "asc") {
|
||||
sortState.sortOrder = "desc";
|
||||
} else {
|
||||
sortState.sortOrder = "asc";
|
||||
}
|
||||
sortState.sortBy = sortField;
|
||||
}
|
||||
records.sort((one, two) =>
|
||||
(one[sortState.sortBy] + one["sortName"]).localeCompare(
|
||||
two[sortState.sortBy] + two["sortName"]
|
||||
)
|
||||
);
|
||||
if (sortState.sortOrder === "desc") {
|
||||
records.reverse();
|
||||
}
|
||||
records.forEach((e, i) => (e.rowNumber = i)); // re-key
|
||||
|
||||
// rendering
|
||||
var recordElement = document.getElementById("records");
|
||||
recordElement.innerHTML = TableTemplate(records);
|
||||
|
||||
var recordCount = document.getElementById("recordCount");
|
||||
recordCount.innerHTML = `${records.length} records`;
|
||||
|
||||
// add listeners for selecting record to view
|
||||
Array.from(recordElement.querySelectorAll("tbody tr"))
|
||||
.slice(1) // remove header from Array
|
||||
.forEach((row) => {
|
||||
row.addEventListener("click", (e) => {
|
||||
// add listener to swap current record
|
||||
document.getElementById("current").innerHTML = RecordTemplate(
|
||||
records[e.currentTarget.id]
|
||||
);
|
||||
});
|
||||
});
|
||||
// add sorting callbacks
|
||||
Array.from(
|
||||
recordElement.querySelectorAll("tbody tr th[data-sort-by]")
|
||||
).forEach((row) => {
|
||||
row.addEventListener("click", function (e) {
|
||||
renderTable(records, e.target.dataset.sortBy); // only add callback when there's a sortBy attribute
|
||||
});
|
||||
});
|
||||
// mark currently active column
|
||||
recordElement
|
||||
.querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]")
|
||||
.classList.add(sortState.sortOrder);
|
||||
}
|
||||
|
||||
function apiResponseParsing(record) {
|
||||
record.sortName = titleCleaner(record.name);
|
||||
record.artists = record.artists.map((artist) => {
|
||||
return artist.replace(/ \([0-9]+\)$/, "");
|
||||
});
|
||||
record.label = record.label.replace(/ \([0-9]+\)$/, "");
|
||||
record.sortArtist = record.artists.reduce((acc, curr) => {
|
||||
return (
|
||||
acc +
|
||||
curr
|
||||
.replace(/^(An?|The)\s/i, "")
|
||||
.toLowerCase()
|
||||
.replaceAll('"', "")
|
||||
.replaceAll(":", "")
|
||||
.replaceAll("'", "")
|
||||
.replaceAll(" ", "")
|
||||
);
|
||||
}, "");
|
||||
return record;
|
||||
}
|
||||
|
||||
function search(records, searchBy) {
|
||||
searchBy = searchCleaner(searchBy);
|
||||
if (searchBy !== "") {
|
||||
records = records.filter(({ name, artists, genre, label, year }) => {
|
||||
return Object.values({
|
||||
name,
|
||||
artists: artists.join(" "),
|
||||
genre,
|
||||
label,
|
||||
year,
|
||||
}).find((field) => searchCleaner(field).indexOf(searchBy) !== -1);
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function titleCleaner(title) {
|
||||
return title
|
||||
.replace('"', "")
|
||||
.replace(":", "")
|
||||
.replace(/^(An?|The)\s/i, "");
|
||||
}
|
||||
|
||||
function searchCleaner(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replaceAll('"', "")
|
||||
.replaceAll(":", "")
|
||||
.replaceAll("'", "")
|
||||
.replaceAll(" ", "");
|
||||
}
|
||||
|
||||
function RecordTemplate({
|
||||
name,
|
||||
artists,
|
||||
coverURL,
|
||||
format,
|
||||
genre,
|
||||
identifier,
|
||||
label,
|
||||
year,
|
||||
discogsURL,
|
||||
}) {
|
||||
return `${coverURL ? `<img src="${coverURL}" loading="lazy"/>` : ""}
|
||||
<h1>${name}</h1>
|
||||
<h2>${artists.join(", ")}</h2>
|
||||
<span>${identifier}</span><br/>
|
||||
<span>${genre}, ${label}, ${year}</span><br/>
|
||||
<span>${format}</span><br/>
|
||||
<span>
|
||||
<a
|
||||
target="_blank"
|
||||
href="${discogsURL}"
|
||||
>
|
||||
Data provided by Discogs.
|
||||
</a>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function TableRowTemplate({ name, coverURL, discogsURL }) {
|
||||
return `<div class="record">
|
||||
<img class="cover" src="${coverURL}" loading="lazy"/>
|
||||
<span class="name">${name}</span>
|
||||
<a
|
||||
target="_blank"
|
||||
href="${discogsURL}"
|
||||
class="discogsLink"
|
||||
>
|
||||
Data provided by Discogs.
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function TableTemplate(records) {
|
||||
return `<div class="flow">${records.reduce((acc, record) => {
|
||||
return acc.concat(TableRowTemplate(record));
|
||||
}, "")} </div>`;
|
||||
}
|
BIN
frontend/files/records/favicon.ico
Normal file
BIN
frontend/files/records/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 174 KiB |
BIN
frontend/files/records/favicon.png
Normal file
BIN
frontend/files/records/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
51
frontend/files/records/index.html
Normal file
51
frontend/files/records/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Library</title>
|
||||
<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" src="app.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
</script>
|
||||
<script defer data-domain="library.yetaga.in" src="https://stats.yetaga.in/js/script.js"></script>
|
||||
<meta
|
||||
name="description"
|
||||
content="A scrollable view of all of my records."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div id="header">
|
||||
<h1>Records</h1>
|
||||
<a href="/">books</a>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://git.yetaga.in/alazyreader/library"
|
||||
>git</a
|
||||
>
|
||||
<div id="searchBox">
|
||||
<span id="recordCount" class="recordCount">_ records</span>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="records"></div>
|
||||
<footer>
|
||||
This application uses Discogs’ API but is not affiliated with, sponsored
|
||||
or endorsed by Discogs. ‘Discogs’ is a trademark of Zink Media, LLC.
|
||||
</footer>
|
||||
<!-- Table goes here -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
238
frontend/files/records/style.css
Normal file
238
frontend/files/records/style.css
Normal file
@@ -0,0 +1,238 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
#header {
|
||||
height: 30px;
|
||||
width: calc(100vw - 20px);
|
||||
padding: 4px 10px;
|
||||
background-color: #f7f3dc;
|
||||
border-bottom: 2px solid #d8d0a0;
|
||||
font-family: "Libre Baskerville", sans-serif;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: xx-large;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#header .recordCount {
|
||||
font-size: small;
|
||||
color: #a29c77;
|
||||
}
|
||||
|
||||
#searchBox {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 7px;
|
||||
text-align: right;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
#searchBox input {
|
||||
width: 300px;
|
||||
font-size: 16px;
|
||||
background: #f9f8ed;
|
||||
padding: 2px 5px;
|
||||
border: none;
|
||||
border-bottom: 2px solid #d8d0a0;
|
||||
font-family: "Libre Baskerville", sans-serif;
|
||||
}
|
||||
|
||||
#searchBox input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#searchBox input::placeholder {
|
||||
font-family: "Libre Baskerville", sans-serif;
|
||||
color: #d8d0a0;
|
||||
}
|
||||
|
||||
#records .flow {
|
||||
height: calc(100vh - 35px - 15px - 20px);
|
||||
padding-top: 5px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#records .flow .record {
|
||||
display: inline-block;
|
||||
width: 250px;
|
||||
padding: 15px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#records .flow .record:nth-child(odd) {
|
||||
background: #f9f8ed;
|
||||
}
|
||||
|
||||
#records .flow .record .cover {
|
||||
border-radius: 3px;
|
||||
max-width: 250px;
|
||||
display: block;
|
||||
margin: 0 auto 3px;
|
||||
overflow: unset;
|
||||
}
|
||||
|
||||
#records .flow .record .name {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#records .flow .record a.discogsLink {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: smaller;
|
||||
padding-top: 10px;
|
||||
color: #a29c77;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #f7f3dc;
|
||||
font-size: smaller;
|
||||
text-align: center;
|
||||
vertical-align: bottom;
|
||||
padding: 5px 0px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: calc(100% - 40px);
|
||||
color: #a29c77;
|
||||
border-top: 2px solid #d8d0a0;
|
||||
}
|
@@ -133,6 +133,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#header {
|
||||
height: 30px;
|
||||
width: calc(100vw - 20px);
|
||||
@@ -147,15 +151,20 @@ body {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#header .bookCount {
|
||||
font-size: small;
|
||||
color: #a29c77;
|
||||
}
|
||||
|
||||
#searchBox {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 7px;
|
||||
text-align: right;
|
||||
width: 400px;
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
#searchBox input {
|
||||
#searchBox input#search {
|
||||
width: 300px;
|
||||
font-size: 16px;
|
||||
background: #f9f8ed;
|
||||
@@ -181,6 +190,7 @@ body {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
float: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#books {
|
||||
@@ -202,30 +212,25 @@ body {
|
||||
}
|
||||
|
||||
.bookTable th[data-sort-by]::after {
|
||||
content: "\f0dc";
|
||||
font-family: FontAwesome;
|
||||
font-size: x-small;
|
||||
content: "\2195";
|
||||
position: relative;
|
||||
left: 4px;
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
.bookTable th.asc::after {
|
||||
content: "\f0de";
|
||||
font-family: FontAwesome;
|
||||
font-size: x-small;
|
||||
content: "\2191";
|
||||
font-size: small;
|
||||
position: relative;
|
||||
left: 4px;
|
||||
bottom: 2px;
|
||||
bottom: 1px;
|
||||
}
|
||||
|
||||
.bookTable th.desc::after {
|
||||
content: "\f0dd";
|
||||
font-family: FontAwesome;
|
||||
font-size: x-small;
|
||||
content: "\2193";
|
||||
font-size: small;
|
||||
position: relative;
|
||||
left: 4px;
|
||||
bottom: 2px;
|
||||
bottom: 1px;
|
||||
}
|
||||
|
||||
.bookTable td,
|
||||
@@ -243,10 +248,6 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bookTable .onLoan {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.bookTable .tRow .title {
|
||||
font-style: italic;
|
||||
max-width: 600px;
|
||||
@@ -256,7 +257,7 @@ body {
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
padding: 10px 0;
|
||||
padding: 0 0 5px 0;
|
||||
}
|
||||
|
||||
#current h2 {
|
||||
@@ -265,20 +266,23 @@ body {
|
||||
}
|
||||
|
||||
#current img {
|
||||
max-height: 400px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#current .bookDetails {
|
||||
position: relative;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
width: 75%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#current .description p {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
#current h1.onLoan {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
#current h2.onLoan {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
82
go.mod
82
go.mod
@@ -1,9 +1,85 @@
|
||||
module git.yetaga.in/alazyreader/library
|
||||
|
||||
go 1.16
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell v1.4.0
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
git.yetaga.in/alazyreader/go-openlibrary v0.0.1
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/irlndts/go-discogs v0.3.6
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
golang.org/x/sync v0.15.0
|
||||
tailscale.com v1.84.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/gaissmai/bart v0.18.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/csrf v1.7.3 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/illarion/gonotify/v3 v3.0.2 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/sdnotify v1.0.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // indirect
|
||||
github.com/miekg/dns v1.1.58 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.4.0 // indirect
|
||||
github.com/safchain/ethtool v0.3.0 // indirect
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/mod v0.23.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.10.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
|
||||
)
|
||||
|
270
go.sum
270
go.sum
@@ -1,16 +1,258 @@
|
||||
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=
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
git.yetaga.in/alazyreader/go-openlibrary v0.0.1 h1:5juCi8d7YyNxXFvHytQNBww5E6GmPetM7nz3kVUqNQY=
|
||||
git.yetaga.in/alazyreader/go-openlibrary v0.0.1/go.mod h1:o6zBFJTovdFcpA+As1bRFvk5PDhAs2Lf8iVzUt7dKw8=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w=
|
||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
|
||||
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
|
||||
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
|
||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
|
||||
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
|
||||
github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
|
||||
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
|
||||
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
||||
github.com/irlndts/go-discogs v0.3.6 h1:3oIJEkLGQ1ffJcoo6wvtawPI4/SyHoRpnu25Y51U4wg=
|
||||
github.com/irlndts/go-discogs v0.3.6/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||
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=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
|
||||
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
|
||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
|
||||
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
|
||||
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
|
||||
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
|
||||
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
|
||||
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
|
||||
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.84.1 h1:xtuiYeAIUR+dRztPzzqUsjj+Fv/06vz28zoFaP1k/Os=
|
||||
tailscale.com v1.84.1/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=
|
||||
tailscale.com v1.84.2 h1:v6aM4RWUgYiV52LRAx6ET+dlGnvO/5lnqPXb7/pMnR0=
|
||||
tailscale.com v1.84.2/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=
|
||||
tailscale.com v1.84.3 h1:Ur9LMedSgicwbqpy5xn7t49G8490/s6rqAJOk5Q5AYE=
|
||||
tailscale.com v1.84.3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=
|
||||
|
@@ -5,17 +5,17 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/book"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
func CSVToBooks(r io.Reader) ([]book.Book, error) {
|
||||
func CSVToBooks(r io.Reader) ([]media.Book, error) {
|
||||
reader := csv.NewReader(r)
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hmap := parseHeader(header)
|
||||
books := []book.Book{}
|
||||
books := []media.Book{}
|
||||
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
@@ -25,7 +25,7 @@ func CSVToBooks(r io.Reader) ([]book.Book, error) {
|
||||
if err != nil {
|
||||
return books, err
|
||||
}
|
||||
b := book.Book{
|
||||
b := media.Book{
|
||||
Title: row[hmap["title"]],
|
||||
Authors: parseAuthors(row[hmap["author"]]),
|
||||
SortAuthor: row[hmap["authorlast"]],
|
||||
@@ -40,7 +40,6 @@ func CSVToBooks(r io.Reader) ([]book.Book, error) {
|
||||
Signed: row[hmap["signed"]] == "yes", // convert from known string to bool
|
||||
Description: row[hmap["description"]],
|
||||
Notes: row[hmap["notes"]],
|
||||
OnLoan: row[hmap["onloan"]],
|
||||
CoverURL: row[hmap["coverurl"]],
|
||||
}
|
||||
books = append(books, b)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package book
|
||||
package media
|
||||
|
||||
type Book struct {
|
||||
ID int `json:"-"`
|
||||
@@ -16,6 +16,21 @@ type Book struct {
|
||||
Signed bool `json:"signed"`
|
||||
Description string `json:"description"`
|
||||
Notes string `json:"notes"`
|
||||
OnLoan string `json:"onLoan"`
|
||||
CoverURL string `json:"coverURL"`
|
||||
Childrens bool `json:"childrens"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID int `json:"-"`
|
||||
AlbumName string `json:"name"`
|
||||
Artists []string `json:"artists"`
|
||||
SortArtist string `json:"sortArtist"`
|
||||
Identifier string `json:"identifier"`
|
||||
Format string `json:"format"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Year string `json:"year"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"coverURL"`
|
||||
DiscogsURL string `json:"discogsURL"`
|
||||
}
|
13
query/amazon.go
Normal file
13
query/amazon.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
type Amazon struct{}
|
||||
|
||||
func (o *Amazon) GetByISBN(isbn string) (*media.Book, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
28
query/funcs.go
Normal file
28
query/funcs.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func tryGetFirst(s []string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s[0]
|
||||
}
|
||||
|
||||
func buildTitle(title, subtitle string) string {
|
||||
if subtitle != "" {
|
||||
return fmt.Sprintf("%s: %s", title, subtitle)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func getLastName(author string) string {
|
||||
names := strings.Split(author, " ")
|
||||
if len(names) < 2 {
|
||||
return author
|
||||
}
|
||||
return names[len(names)-1]
|
||||
}
|
158
query/googlebooks.go
Normal file
158
query/googlebooks.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
type GoogleBooks struct{}
|
||||
|
||||
type googleBookResult struct {
|
||||
Kind string `json:"kind"`
|
||||
TotalItems int `json:"totalItems"`
|
||||
Items []item `json:"items"`
|
||||
}
|
||||
|
||||
type industryIdentifier struct {
|
||||
Type string `json:"type"`
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
type readingMode struct {
|
||||
Text bool `json:"text"`
|
||||
Image bool `json:"image"`
|
||||
}
|
||||
|
||||
type panelizationSummary struct {
|
||||
ContainsEpubBubbles bool `json:"containsEpubBubbles"`
|
||||
ContainsImageBubbles bool `json:"containsImageBubbles"`
|
||||
}
|
||||
|
||||
type imageLink struct {
|
||||
SmallThumbnail string `json:"smallThumbnail"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
}
|
||||
|
||||
type volumeInfo struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Publisher string `json:"publisher"`
|
||||
PublishedDate string `json:"publishedDate"`
|
||||
Description string `json:"description"`
|
||||
IndustryIdentifiers []industryIdentifier `json:"industryIdentifiers"`
|
||||
ReadingModes readingMode `json:"readingModes"`
|
||||
PageCount int `json:"pageCount"`
|
||||
PrintType string `json:"printType"`
|
||||
Categories []string `json:"categories"`
|
||||
AverageRating float64 `json:"averageRating"`
|
||||
RatingsCount int `json:"ratingsCount"`
|
||||
MaturityRating string `json:"maturityRating"`
|
||||
AllowAnonLogging bool `json:"allowAnonLogging"`
|
||||
ContentVersion string `json:"contentVersion"`
|
||||
PanelizationSummary panelizationSummary `json:"panelizationSummary"`
|
||||
ImageLinks imageLink `json:"imageLinks"`
|
||||
Language string `json:"language"`
|
||||
PreviewLink string `json:"previewLink"`
|
||||
InfoLink string `json:"infoLink"`
|
||||
CanonicalVolumeLink string `json:"canonicalVolumeLink"`
|
||||
}
|
||||
|
||||
type saleInfo struct {
|
||||
Country string `json:"country"`
|
||||
Saleability string `json:"saleability"`
|
||||
IsEbook bool `json:"isEbook"`
|
||||
}
|
||||
|
||||
type epub struct {
|
||||
IsAvailable bool `json:"isAvailable"`
|
||||
}
|
||||
|
||||
type pdf struct {
|
||||
IsAvailable bool `json:"isAvailable"`
|
||||
}
|
||||
|
||||
type accessInfo struct {
|
||||
Country string `json:"country"`
|
||||
Viewability string `json:"viewability"`
|
||||
Embeddable bool `json:"embeddable"`
|
||||
PublicDomain bool `json:"publicDomain"`
|
||||
TextToSpeechPermission string `json:"textToSpeechPermission"`
|
||||
Epub epub `json:"epub"`
|
||||
Pdf pdf `json:"pdf"`
|
||||
WebReaderLink string `json:"webReaderLink"`
|
||||
AccessViewStatus string `json:"accessViewStatus"`
|
||||
QuoteSharingAllowed bool `json:"quoteSharingAllowed"`
|
||||
}
|
||||
|
||||
type searchInfo struct {
|
||||
TextSnippet string `json:"textSnippet"`
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Etag string `json:"etag"`
|
||||
SelfLink string `json:"selfLink"`
|
||||
VolumeInfo volumeInfo `json:"volumeInfo"`
|
||||
SaleInfo saleInfo `json:"saleInfo"`
|
||||
AccessInfo accessInfo `json:"accessInfo"`
|
||||
SearchInfo searchInfo `json:"searchInfo"`
|
||||
}
|
||||
|
||||
func (g *GoogleBooks) GetByISBN(isbn string) (*media.Book, error) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Get(fmt.Sprintf("https://www.googleapis.com/books/v1/volumes?q=isbn:%s", isbn))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("received non-200 status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result googleBookResult
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(b, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Items) == 0 {
|
||||
return nil, fmt.Errorf("no book found")
|
||||
}
|
||||
|
||||
return googleToBook(result.Items[0]), nil
|
||||
}
|
||||
|
||||
func googleToBook(i item) *media.Book {
|
||||
return &media.Book{
|
||||
Title: buildTitle(i.VolumeInfo.Title, i.VolumeInfo.Subtitle),
|
||||
Authors: i.VolumeInfo.Authors,
|
||||
SortAuthor: strings.ToLower(getLastName(tryGetFirst(i.VolumeInfo.Authors))),
|
||||
ISBN10: getIdentifierType(i.VolumeInfo.IndustryIdentifiers, "ISBN_10"),
|
||||
ISBN13: getIdentifierType(i.VolumeInfo.IndustryIdentifiers, "ISBN_13"),
|
||||
Publisher: i.VolumeInfo.Publisher,
|
||||
Year: strings.Split(i.VolumeInfo.PublishedDate, "-")[0],
|
||||
Description: i.VolumeInfo.Description,
|
||||
Genre: tryGetFirst(i.VolumeInfo.Categories),
|
||||
}
|
||||
}
|
||||
|
||||
func getIdentifierType(iis []industryIdentifier, typename string) string {
|
||||
for _, ident := range iis {
|
||||
if ident.Type == typename {
|
||||
return ident.Identifier
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
11
query/null.go
Normal file
11
query/null.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
type Null struct{}
|
||||
|
||||
func (o *Null) GetByISBN(isbn string) (*media.Book, error) {
|
||||
return nil, nil
|
||||
}
|
46
query/openlibrary.go
Normal file
46
query/openlibrary.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yetaga.in/alazyreader/go-openlibrary/client"
|
||||
"git.yetaga.in/alazyreader/library/media"
|
||||
)
|
||||
|
||||
type OpenLibrary struct {
|
||||
client client.Client
|
||||
}
|
||||
|
||||
func (o *OpenLibrary) GetByISBN(isbn string) (*media.Book, error) {
|
||||
details, err := o.client.GetByISBN(isbn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openLibraryToBook(details), nil
|
||||
}
|
||||
|
||||
func openLibraryToBook(details *client.BookDetails) *media.Book {
|
||||
return &media.Book{
|
||||
Title: details.Title,
|
||||
Authors: getAuthors(details.Authors),
|
||||
SortAuthor: strings.ToLower(getLastName(tryGetFirst(getAuthors(details.Authors)))),
|
||||
Publisher: getPublisher(details.Publishers),
|
||||
ISBN10: tryGetFirst(details.Identifiers.ISBN10),
|
||||
ISBN13: tryGetFirst(details.Identifiers.ISBN13),
|
||||
}
|
||||
}
|
||||
|
||||
func getPublisher(publishers []client.Publishers) string {
|
||||
if len(publishers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return publishers[0].Name
|
||||
}
|
||||
|
||||
func getAuthors(authors []client.Authors) []string {
|
||||
ret := make([]string, len(authors))
|
||||
for _, author := range authors {
|
||||
ret = append(ret, author.Name)
|
||||
}
|
||||
return ret
|
||||
}
|
5
readme.md
Normal file
5
readme.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# library
|
||||
|
||||
[](https://ci.yetaga.in/alazyreader/library)
|
||||
|
||||
A slowly growing list of most of the media I own.
|
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
116
ui/mock.go
116
ui/mock.go
@@ -1,116 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
)
|
||||
|
||||
type coord struct {
|
||||
x, y int
|
||||
}
|
||||
|
||||
type MockScreen struct {
|
||||
x, y, h, w int
|
||||
content map[coord]rune
|
||||
}
|
||||
|
||||
func (m *MockScreen) Init() error {
|
||||
m.content = map[coord]rune{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockScreen) Fini() {}
|
||||
|
||||
func (m *MockScreen) Clear() {
|
||||
m.content = map[coord]rune{}
|
||||
}
|
||||
|
||||
func (m *MockScreen) Fill(rune, tcell.Style) {}
|
||||
|
||||
func (m *MockScreen) SetCell(x int, y int, style tcell.Style, ch ...rune) {}
|
||||
|
||||
func (m *MockScreen) GetContent(x, y int) (mainc rune, combc []rune, style tcell.Style, width int) {
|
||||
return m.content[coord{x, y}], nil, tcell.StyleDefault, 1
|
||||
}
|
||||
|
||||
func (m *MockScreen) SetContent(x int, y int, mainc rune, combc []rune, style tcell.Style) {
|
||||
m.content[coord{x, y}] = mainc
|
||||
}
|
||||
|
||||
func (m *MockScreen) SetStyle(style tcell.Style) {}
|
||||
|
||||
func (m *MockScreen) ShowCursor(x int, y int) {}
|
||||
|
||||
func (m *MockScreen) HideCursor() {}
|
||||
|
||||
func (m *MockScreen) Size() (int, int) {
|
||||
return m.h, m.w
|
||||
}
|
||||
|
||||
func (m *MockScreen) PollEvent() tcell.Event {
|
||||
return tcell.NewEventError(fmt.Errorf("mock error"))
|
||||
}
|
||||
|
||||
func (m *MockScreen) PostEvent(ev tcell.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockScreen) PostEventWait(ev tcell.Event) {}
|
||||
|
||||
func (m *MockScreen) EnableMouse() {}
|
||||
|
||||
func (m *MockScreen) DisableMouse() {}
|
||||
|
||||
func (m *MockScreen) HasMouse() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *MockScreen) Colors() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *MockScreen) Show() {}
|
||||
|
||||
func (m *MockScreen) Sync() {}
|
||||
|
||||
func (m *MockScreen) CharacterSet() string {
|
||||
return "UTF-8"
|
||||
}
|
||||
|
||||
func (m *MockScreen) RegisterRuneFallback(r rune, subst string) {}
|
||||
|
||||
func (m *MockScreen) UnregisterRuneFallback(r rune) {}
|
||||
|
||||
func (m *MockScreen) CanDisplay(r rune, checkFallbacks bool) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MockScreen) Resize(x, y, h, w int) {
|
||||
m.x, m.y, m.h, m.w = x, y, h, w
|
||||
}
|
||||
|
||||
func (m *MockScreen) HasKey(tcell.Key) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MockScreen) Beep() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockScreen) DumpContents() string {
|
||||
var res string
|
||||
for i := m.y; i < m.h; i++ {
|
||||
str := []rune{}
|
||||
for j := m.x; j < m.w; j++ {
|
||||
r, ok := m.content[coord{x: j, y: i}]
|
||||
if ok {
|
||||
str = append(str, r)
|
||||
} else {
|
||||
str = append(str, ' ')
|
||||
}
|
||||
}
|
||||
res = res + string(str) + "\n"
|
||||
}
|
||||
return res
|
||||
}
|
617
ui/ui.go
617
ui/ui.go
@@ -1,617 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yetaga.in/alazyreader/library/book"
|
||||
"github.com/gdamore/tcell"
|
||||
)
|
||||
|
||||
type Drawable interface {
|
||||
Draw(tcell.Screen)
|
||||
SetSize(x, y, h, w int)
|
||||
SetStyle(tcell.Style)
|
||||
SetVisible(bool)
|
||||
}
|
||||
|
||||
type Offsets struct {
|
||||
Top int
|
||||
Bottom int
|
||||
Left int
|
||||
Right int
|
||||
Percent int
|
||||
}
|
||||
|
||||
type Contents []struct {
|
||||
Offsets Offsets
|
||||
Container Drawable
|
||||
}
|
||||
|
||||
const (
|
||||
LayoutUnmanaged = iota
|
||||
LayoutHorizontalEven
|
||||
LayoutVerticalEven
|
||||
LayoutHorizontalPercent
|
||||
LayoutVerticalPercent
|
||||
)
|
||||
|
||||
var (
|
||||
StyleActive = tcell.Style(0).Foreground(tcell.ColorWhite).Background(tcell.ColorBlack)
|
||||
StyleInactive = tcell.Style(0).Foreground(tcell.ColorGray).Background(tcell.ColorBlack)
|
||||
)
|
||||
|
||||
// A Container has no visible UI of its own, but arranges sub-components on the screen.
|
||||
// layoutMethod manages how subcomponents are organized. If `LayoutUnmanaged`, it just uses the offsets
|
||||
// in contents to paint on the screen. Otherwise, `LayoutHorizontalEven` and `LayoutVerticalEven` will
|
||||
// have it compute even distributions of space for all components either horizontally or vertically,
|
||||
// filling the container.
|
||||
type Container struct {
|
||||
x, y int
|
||||
h, w int
|
||||
layoutMethod int
|
||||
contents Contents
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewContainer(contents Contents, layoutMethod int) *Container {
|
||||
return &Container{
|
||||
layoutMethod: layoutMethod,
|
||||
contents: contents,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Draw(s tcell.Screen) {
|
||||
if !c.visible {
|
||||
return
|
||||
}
|
||||
for i := range c.contents {
|
||||
c.contents[i].Container.Draw(s)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) SetSize(x, y, h, w int) {
|
||||
c.x, c.y, c.h, c.w = x, y, h, w
|
||||
carry := 0
|
||||
if c.layoutMethod == LayoutVerticalEven {
|
||||
num := len(c.contents)
|
||||
extra := c.h % num
|
||||
for r := range c.contents {
|
||||
w := c.w
|
||||
h := c.h / num
|
||||
x := c.x
|
||||
y := c.y + (h * r) + carry
|
||||
if extra > 0 { // distribute "extra" space to containers as we have some left
|
||||
h++
|
||||
extra--
|
||||
carry++
|
||||
}
|
||||
c.contents[r].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
} else if c.layoutMethod == LayoutHorizontalEven {
|
||||
num := len(c.contents)
|
||||
extra := c.w % num
|
||||
for r := range c.contents {
|
||||
w := c.w / num
|
||||
h := c.h
|
||||
x := c.x + (w * r) + carry
|
||||
y := c.y
|
||||
if extra > 0 { // distribute "extra" space to containers as we have some left
|
||||
w++
|
||||
extra--
|
||||
carry++
|
||||
}
|
||||
c.contents[r].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
} else if c.layoutMethod == LayoutHorizontalPercent {
|
||||
// first, work out overall distribution
|
||||
total := 0
|
||||
for r := range c.contents {
|
||||
// `0` or negatives are set as minimum
|
||||
if c.contents[r].Offsets.Percent < 1 {
|
||||
total += 1
|
||||
} else {
|
||||
total += c.contents[r].Offsets.Percent
|
||||
}
|
||||
}
|
||||
carry := 0
|
||||
// push around containers
|
||||
for r := range c.contents {
|
||||
ratio := (float64(c.contents[r].Offsets.Percent) / float64(total))
|
||||
w := int(float64(c.w) * ratio)
|
||||
h := c.h
|
||||
x := c.x + carry
|
||||
y := c.y
|
||||
carry += w
|
||||
// and add any remaining space to the last container
|
||||
if r == len(c.contents)-1 {
|
||||
w += (c.w - carry)
|
||||
}
|
||||
c.contents[r].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
} else if c.layoutMethod == LayoutVerticalPercent {
|
||||
// first, work out overall distribution
|
||||
total := 0
|
||||
for r := range c.contents {
|
||||
// `0` or negatives are set as minimum
|
||||
if c.contents[r].Offsets.Percent < 1 {
|
||||
total += 1
|
||||
} else {
|
||||
total += c.contents[r].Offsets.Percent
|
||||
}
|
||||
}
|
||||
carry := 0
|
||||
// push around containers
|
||||
for r := range c.contents {
|
||||
ratio := (float64(c.contents[r].Offsets.Percent) / float64(total))
|
||||
w := c.w
|
||||
h := int(float64(c.h) * ratio)
|
||||
x := c.x
|
||||
y := c.y + carry
|
||||
carry += h
|
||||
// and add any remaining space to the last container
|
||||
if r == len(c.contents)-1 {
|
||||
h += (c.h - carry)
|
||||
}
|
||||
c.contents[r].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
} else {
|
||||
for r := range c.contents {
|
||||
x := c.x + c.contents[r].Offsets.Left
|
||||
y := c.y + c.contents[r].Offsets.Top
|
||||
h := c.h - c.contents[r].Offsets.Bottom
|
||||
w := c.w - c.contents[r].Offsets.Right
|
||||
c.contents[r].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) SetStyle(s tcell.Style) {
|
||||
// containers have no visible elements to style
|
||||
}
|
||||
|
||||
func (c *Container) SetVisible(b bool) {
|
||||
c.visible = b
|
||||
}
|
||||
|
||||
func (c *Container) Contents() Contents {
|
||||
return c.contents
|
||||
}
|
||||
|
||||
func (c *Container) SetContents(con Contents) {
|
||||
c.contents = con
|
||||
}
|
||||
|
||||
// A Box draws a ASCII box around its contents, with an optional title and footer.
|
||||
type Box struct {
|
||||
x, y int
|
||||
h, w int
|
||||
title Drawable
|
||||
menuItems Drawable
|
||||
contents Contents
|
||||
style tcell.Style
|
||||
cascade bool
|
||||
visible bool
|
||||
transparent bool
|
||||
}
|
||||
|
||||
func NewBox(title string, menuItems []string, contents Contents, initialStyle tcell.Style, cascade bool) *Box {
|
||||
return &Box{
|
||||
title: NewPaddedText(title),
|
||||
menuItems: NewPaddedText(strings.Join(menuItems, " ")),
|
||||
contents: contents,
|
||||
style: initialStyle,
|
||||
cascade: cascade,
|
||||
visible: true,
|
||||
transparent: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Box) SetSize(x, y, h, w int) {
|
||||
b.x, b.y, b.h, b.w = x, y, h, w
|
||||
b.title.SetSize(b.x+2, b.y, 0, 0)
|
||||
b.menuItems.SetSize(b.x+2, b.y+b.h-1, 0, 0)
|
||||
for c := range b.contents {
|
||||
x := b.x + b.contents[c].Offsets.Left
|
||||
y := b.y + b.contents[c].Offsets.Top
|
||||
h := b.h - b.contents[c].Offsets.Bottom
|
||||
w := b.w - b.contents[c].Offsets.Right
|
||||
b.contents[c].Container.SetSize(x, y, h, w)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Box) Draw(s tcell.Screen) {
|
||||
if !b.visible {
|
||||
return
|
||||
}
|
||||
// blank out inner area
|
||||
if !b.transparent {
|
||||
for m := b.x + 1; m < b.x+b.w-1; m++ {
|
||||
for n := b.y + 1; n < b.y+b.h-1; n++ {
|
||||
s.SetContent(m, n, ' ', nil, b.style)
|
||||
}
|
||||
}
|
||||
}
|
||||
// draw outside bars
|
||||
for m := b.x + 1; m < b.x+b.w-1; m++ {
|
||||
s.SetContent(m, b.y, tcell.RuneHLine, nil, b.style)
|
||||
s.SetContent(m, b.y+b.h-1, tcell.RuneHLine, nil, b.style)
|
||||
}
|
||||
for m := b.y + 1; m < b.y+b.h-1; m++ {
|
||||
s.SetContent(b.x, m, tcell.RuneVLine, nil, b.style)
|
||||
s.SetContent(b.x+b.w-1, m, tcell.RuneVLine, nil, b.style)
|
||||
}
|
||||
s.SetContent(b.x, b.y, tcell.RuneULCorner, nil, b.style)
|
||||
s.SetContent(b.x+b.w-1, b.y, tcell.RuneURCorner, nil, b.style)
|
||||
s.SetContent(b.x, b.y+b.h-1, tcell.RuneLLCorner, nil, b.style)
|
||||
s.SetContent(b.x+b.w-1, b.y+b.h-1, tcell.RuneLRCorner, nil, b.style)
|
||||
|
||||
if b.title != nil {
|
||||
b.title.Draw(s)
|
||||
}
|
||||
if b.menuItems != nil {
|
||||
b.menuItems.Draw(s)
|
||||
}
|
||||
for c := range b.contents {
|
||||
b.contents[c].Container.Draw(s)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Box) SetStyle(s tcell.Style) {
|
||||
b.style = s
|
||||
b.title.SetStyle(s)
|
||||
b.menuItems.SetStyle(s)
|
||||
if b.cascade {
|
||||
for c := range b.contents {
|
||||
b.contents[c].Container.SetStyle(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Box) SetVisible(v bool) {
|
||||
b.visible = v
|
||||
}
|
||||
|
||||
func (b *Box) SetTransparent(v bool) {
|
||||
b.transparent = v
|
||||
}
|
||||
|
||||
func (b *Box) Contents() Contents {
|
||||
return b.contents
|
||||
}
|
||||
|
||||
func (b *Box) SetContents(c Contents) {
|
||||
b.contents = c
|
||||
}
|
||||
|
||||
// A List is a scrollable, pageable list with a selector token.
|
||||
type List struct {
|
||||
x, y int
|
||||
h, w int
|
||||
selected int
|
||||
listItems []ListKeyValue
|
||||
style tcell.Style
|
||||
visible bool
|
||||
}
|
||||
|
||||
type ListKeyValue struct {
|
||||
Key int
|
||||
Value string
|
||||
}
|
||||
|
||||
func NewList(listItems []ListKeyValue, initialSelected int) *List {
|
||||
return &List{
|
||||
listItems: listItems,
|
||||
selected: initialSelected,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *List) SetSize(x, y, h, w int) {
|
||||
l.x, l.y, l.h, l.w = x, y, h, w
|
||||
}
|
||||
|
||||
func (l *List) Draw(s tcell.Screen) {
|
||||
if !l.visible {
|
||||
return
|
||||
}
|
||||
for i := range l.listItems {
|
||||
for j, r := range l.listItems[i].Value {
|
||||
s.SetContent(l.x+j, l.y+i, r, nil, l.style)
|
||||
}
|
||||
if i == l.selected {
|
||||
s.SetContent(l.x+len(l.listItems[i].Value)+1, l.y+i, '<', nil, l.style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *List) SetVisible(b bool) {
|
||||
l.visible = b
|
||||
}
|
||||
|
||||
func (l *List) SetStyle(s tcell.Style) {
|
||||
l.style = s
|
||||
}
|
||||
|
||||
func (l *List) Selected() int {
|
||||
return l.selected
|
||||
}
|
||||
|
||||
func (l *List) SelectedID() int {
|
||||
if l.listItems == nil || len(l.listItems) == 0 {
|
||||
return 0
|
||||
}
|
||||
return l.listItems[l.selected].Key
|
||||
}
|
||||
|
||||
func (l *List) SetSelected(i int) {
|
||||
l.selected = i
|
||||
}
|
||||
|
||||
func (l *List) ListMembers() []ListKeyValue {
|
||||
return l.listItems
|
||||
}
|
||||
|
||||
func (l *List) SetMembers(lkv []ListKeyValue) {
|
||||
l.listItems = lkv
|
||||
}
|
||||
|
||||
// BookDetails displays an editable list of book details
|
||||
type BookDetails struct {
|
||||
x, y int
|
||||
h, w int
|
||||
book *book.Book
|
||||
style tcell.Style
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewBookDetails(b *book.Book) *BookDetails {
|
||||
return &BookDetails{
|
||||
book: b,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *BookDetails) SetBook(b *book.Book) {
|
||||
l.book = b
|
||||
}
|
||||
|
||||
func (l *BookDetails) SetSize(x, y, h, w int) {
|
||||
l.x, l.y, l.h, l.w = x, y, h, w
|
||||
}
|
||||
|
||||
func (l *BookDetails) Draw(s tcell.Screen) {
|
||||
if l.book == nil {
|
||||
return
|
||||
}
|
||||
if !l.visible {
|
||||
return
|
||||
}
|
||||
items := []struct {
|
||||
label string
|
||||
value string
|
||||
}{
|
||||
{"Title", l.book.Title},
|
||||
{"Authors", strings.Join(l.book.Authors, ", ")},
|
||||
{"Sort Author", l.book.SortAuthor},
|
||||
{"ISBN-10", l.book.ISBN10},
|
||||
{"ISBN-13", l.book.ISBN13},
|
||||
{"Format", l.book.Format},
|
||||
{"Genre", l.book.Genre},
|
||||
{"Publisher", l.book.Publisher},
|
||||
{"Series", l.book.Series},
|
||||
{"Volume", l.book.Volume},
|
||||
{"Year", l.book.Year},
|
||||
{"Signed", strconv.FormatBool(l.book.Signed)},
|
||||
{"On Loan", l.book.OnLoan},
|
||||
{"Cover URL", l.book.CoverURL},
|
||||
{"Notes", l.book.Notes},
|
||||
{"Description", l.book.Description},
|
||||
}
|
||||
for i := range items {
|
||||
if i < l.h-2 {
|
||||
kv := NewKeyValue(items[i].label, ": ", items[i].value)
|
||||
kv.SetSize(l.x, l.y+i, 0, 0)
|
||||
kv.SetStyle(l.style)
|
||||
kv.Draw(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *BookDetails) SetVisible(b bool) {
|
||||
l.visible = b
|
||||
}
|
||||
|
||||
func (l *BookDetails) SetStyle(s tcell.Style) {
|
||||
l.style = s
|
||||
}
|
||||
|
||||
// PaddedText outputs strings with a space on both sides.
|
||||
// Useful for generating headings, footers, etc. Used by Box.
|
||||
type PaddedText struct {
|
||||
x, y int
|
||||
h, w int
|
||||
text string
|
||||
style tcell.Style
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewPaddedText(text string) *PaddedText {
|
||||
return &PaddedText{text: text, visible: true}
|
||||
}
|
||||
|
||||
func (p *PaddedText) SetSize(x, y, _, _ int) {
|
||||
p.x, p.y, p.h, p.w = x, y, 1, len(p.text)+2
|
||||
}
|
||||
|
||||
func (p *PaddedText) SetStyle(s tcell.Style) {
|
||||
p.style = s
|
||||
}
|
||||
|
||||
func (p *PaddedText) Draw(s tcell.Screen) {
|
||||
if p.text == "" {
|
||||
return
|
||||
}
|
||||
if !p.visible {
|
||||
return
|
||||
}
|
||||
t := p.x
|
||||
s.SetContent(t, p.y, ' ', nil, p.style)
|
||||
t++
|
||||
for _, r := range p.text {
|
||||
s.SetContent(t, p.y, r, nil, p.style)
|
||||
t++
|
||||
}
|
||||
s.SetContent(t, p.y, ' ', nil, p.style)
|
||||
}
|
||||
|
||||
func (p *PaddedText) SetVisible(b bool) {
|
||||
p.visible = b
|
||||
}
|
||||
|
||||
type KeyValue struct {
|
||||
x, y int
|
||||
h, w int
|
||||
key string
|
||||
value string
|
||||
separator string
|
||||
style tcell.Style
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewKeyValue(key, separator, value string) *KeyValue {
|
||||
return &KeyValue{
|
||||
key: key,
|
||||
separator: separator,
|
||||
value: value,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *KeyValue) SetSize(x, y, _, _ int) {
|
||||
p.x, p.y, p.h, p.w = x, y, 1, len(p.key)+len(p.separator)+len(p.value)
|
||||
}
|
||||
|
||||
func (p *KeyValue) SetStyle(s tcell.Style) {
|
||||
p.style = s
|
||||
}
|
||||
|
||||
func (p *KeyValue) Draw(s tcell.Screen) {
|
||||
if !p.visible {
|
||||
return
|
||||
}
|
||||
for j, r := range p.key {
|
||||
s.SetContent(p.x+j, p.y, r, nil, p.style)
|
||||
}
|
||||
for j, r := range p.separator {
|
||||
s.SetContent(p.x+len(p.key)+j, p.y, r, nil, p.style)
|
||||
}
|
||||
for j, r := range p.value {
|
||||
s.SetContent(p.x+len(p.key)+len(p.separator)+j, p.y, r, nil, p.style)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *KeyValue) SetVisible(b bool) {
|
||||
p.visible = b
|
||||
}
|
||||
|
||||
func (p *KeyValue) GetValue() string {
|
||||
return p.value
|
||||
}
|
||||
|
||||
type EditableTextLine struct {
|
||||
x, y int
|
||||
h, w int
|
||||
text string
|
||||
style tcell.Style
|
||||
visible bool
|
||||
cursorPos int
|
||||
showCursor bool
|
||||
}
|
||||
|
||||
func NewEditableTextLine(initialText string) *EditableTextLine {
|
||||
return &EditableTextLine{
|
||||
text: initialText,
|
||||
visible: true,
|
||||
showCursor: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) SetSize(x, y, _, _ int) {
|
||||
p.x, p.y, p.h, p.w = x, y, 1, len(p.text)
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) SetStyle(s tcell.Style) {
|
||||
p.style = s
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) Draw(s tcell.Screen) {
|
||||
if !p.visible {
|
||||
return
|
||||
}
|
||||
for j, r := range p.text {
|
||||
s.SetContent(p.x+j, p.y, r, nil, p.style)
|
||||
}
|
||||
s.ShowCursor(p.x+p.cursorPos, p.y)
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) SetVisible(b bool) {
|
||||
p.visible = b
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) SetCursorVisible(b bool) {
|
||||
p.showCursor = b
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) SetText(t string) {
|
||||
p.text = t
|
||||
if len(p.text) == 0 {
|
||||
p.ResetCursor(true)
|
||||
return
|
||||
}
|
||||
p.ResetCursor(false)
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) Text() string {
|
||||
return p.text
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) ResetCursor(beginning bool) {
|
||||
if beginning {
|
||||
p.cursorPos = 0
|
||||
} else {
|
||||
p.cursorPos = len(p.text)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) InsertAtCursor(r rune) {
|
||||
if len(p.text) == 0 {
|
||||
p.text = string(r)
|
||||
p.cursorPos = 1
|
||||
return
|
||||
}
|
||||
p.text = p.text[0:p.cursorPos] + string(r) + p.text[p.cursorPos:len(p.text)]
|
||||
p.cursorPos = p.cursorPos + 1
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) MoveCursor(i int) {
|
||||
if p.cursorPos+i < 0 {
|
||||
p.cursorPos = 0
|
||||
return
|
||||
}
|
||||
if p.cursorPos+i > len(p.text) {
|
||||
p.cursorPos = len(p.text)
|
||||
return
|
||||
}
|
||||
p.cursorPos = p.cursorPos + i
|
||||
}
|
||||
|
||||
func (p *EditableTextLine) DeleteAtCursor() {
|
||||
if len(p.text) == 0 {
|
||||
p.cursorPos = 0
|
||||
return
|
||||
}
|
||||
p.text = p.text[0:p.cursorPos-1] + p.text[p.cursorPos:len(p.text)]
|
||||
p.cursorPos = p.cursorPos - 1
|
||||
}
|
239
ui/ui_test.go
239
ui/ui_test.go
@@ -1,239 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainerOneBox(t *testing.T) {
|
||||
expect := `┌─ box one ────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└──────────────────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("box one", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{{Container: one}},
|
||||
LayoutHorizontalEven,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 5, 20)
|
||||
container.SetSize(0, 0, 5, 20)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerTwoBoxesHStack(t *testing.T) {
|
||||
expect := `┌─ one ──┐┌─ two ──┐
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
└────────┘└────────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("one", nil, Contents{}, 0, false)
|
||||
two := NewBox("two", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{{Container: one}, {Container: two}},
|
||||
LayoutHorizontalEven,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 5, 20)
|
||||
container.SetSize(0, 0, 5, 20)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerThreeBoxesUnevenHStack(t *testing.T) {
|
||||
expect := `┌─ one ──┐┌─ two ──┐┌─ three
|
||||
│ ││ ││ │
|
||||
│ ││ ││ │
|
||||
│ ││ ││ │
|
||||
└────────┘└────────┘└───────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("one", nil, Contents{}, 0, false)
|
||||
two := NewBox("two", nil, Contents{}, 0, false)
|
||||
three := NewBox("three", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{{Container: one}, {Container: two}, {Container: three}},
|
||||
LayoutHorizontalEven,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 5, 29)
|
||||
container.SetSize(0, 0, 5, 29)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerTwoBoxesHPercentStack(t *testing.T) {
|
||||
expect := `┌─ one ──────┐┌─ two ┐
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
└────────────┘└──────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("one", nil, Contents{}, 0, false)
|
||||
two := NewBox("two", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{
|
||||
{Container: one, Offsets: Offsets{Percent: 2}},
|
||||
{Container: two, Offsets: Offsets{Percent: 1}}},
|
||||
LayoutHorizontalPercent,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 5, 22)
|
||||
container.SetSize(0, 0, 5, 22)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerTwoBoxesVStack(t *testing.T) {
|
||||
expect := `┌─ one ──┐
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────┘
|
||||
┌─ two ──┐
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("one", nil, Contents{}, 0, false)
|
||||
two := NewBox("two", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{{Container: one}, {Container: two}},
|
||||
LayoutVerticalEven,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 10, 10)
|
||||
container.SetSize(0, 0, 10, 10)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerTwoBoxesPercentageVStack(t *testing.T) {
|
||||
expect := `┌─ one ──┐
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
└────────┘
|
||||
┌─ two ──┐
|
||||
│ │
|
||||
│ │
|
||||
└────────┘
|
||||
`
|
||||
m := &MockScreen{}
|
||||
one := NewBox("one", nil, Contents{}, 0, false)
|
||||
two := NewBox("two", nil, Contents{}, 0, false)
|
||||
container := NewContainer(
|
||||
Contents{
|
||||
{Container: one, Offsets: Offsets{Percent: 2}},
|
||||
{Container: two, Offsets: Offsets{Percent: 1}}},
|
||||
LayoutVerticalPercent,
|
||||
)
|
||||
m.Init()
|
||||
m.Resize(0, 0, 10, 10)
|
||||
container.SetSize(0, 0, 10, 10)
|
||||
container.Draw(m)
|
||||
result := m.DumpContents()
|
||||
if result != expect {
|
||||
fmt.Printf("expected:\n%+v", expect)
|
||||
fmt.Printf("actual:\n%+v", result)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEditableTextLine(t *testing.T) {
|
||||
e := NewEditableTextLine("")
|
||||
e.InsertAtCursor('a')
|
||||
e.InsertAtCursor('b')
|
||||
e.InsertAtCursor('c')
|
||||
if e.text != "abc" {
|
||||
fmt.Printf("expected: 'abc', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.MoveCursor(-1)
|
||||
e.InsertAtCursor('d')
|
||||
if e.text != "abdc" {
|
||||
fmt.Printf("expected: 'abdc', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.MoveCursor(-20)
|
||||
e.InsertAtCursor('e')
|
||||
if e.text != "eabdc" {
|
||||
fmt.Printf("expected: 'eabdc', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.MoveCursor(20)
|
||||
e.InsertAtCursor('f')
|
||||
if e.text != "eabdcf" {
|
||||
fmt.Printf("expected: 'eabdcf', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.MoveCursor(1)
|
||||
e.InsertAtCursor('g')
|
||||
if e.text != "eabdcfg" {
|
||||
fmt.Printf("expected: 'eabdcfg', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.DeleteAtCursor()
|
||||
e.DeleteAtCursor()
|
||||
e.MoveCursor(-1)
|
||||
e.DeleteAtCursor()
|
||||
if e.text != "eabc" {
|
||||
fmt.Printf("expected: 'eabc', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.ResetCursor(false)
|
||||
e.InsertAtCursor('h')
|
||||
e.ResetCursor(true)
|
||||
e.InsertAtCursor('g')
|
||||
if e.text != "geabch" {
|
||||
fmt.Printf("expected: 'geabch', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.SetText("the rain in spain")
|
||||
e.InsertAtCursor('s')
|
||||
if e.text != "the rain in spains" {
|
||||
fmt.Printf("expected: 'the rain in spains', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
e.SetText("")
|
||||
e.InsertAtCursor('s')
|
||||
if e.text != "s" {
|
||||
fmt.Printf("expected: 's', actual: '%+v'", e.text)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user