Compare commits
	
		
			186 Commits
		
	
	
		
			ce194c1418
			...
			renovate/m
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| eb685c0bbe | |||
| 78b2afd4fb | |||
| 88fcada19d | |||
| 16e270371e | |||
| cc0174ed24 | |||
| 8763bda3f4 | |||
| 212fa8be4d | |||
| 249e98d060 | |||
| 76964a6a00 | |||
| 6978b3dc47 | |||
| b8865b41dc | |||
| 23fd21dbf5 | |||
| 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 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| /server | ||||
| /manager | ||||
| *.properties | ||||
| .DS_Store | ||||
| /vendor | ||||
| .recordsCache | ||||
| .config | ||||
| @@ -1,17 +1,23 @@ | ||||
| pipeline: | ||||
| steps: | ||||
|   test: | ||||
|     image: golang:1.25 | ||||
|     commands: | ||||
|       - go test ./... | ||||
|   build: | ||||
|     image: docker | ||||
|     environment: | ||||
|       - DOCKER_HOST=tcp://docker:2375 | ||||
|     commands: | ||||
|       - sleep 5 # wait for docker service to come up | ||||
|       - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD registry.yetaga.in | ||||
|       - 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 | ||||
|     secrets: [docker_username, docker_password] | ||||
|  | ||||
| services: | ||||
|   docker: | ||||
|     image: docker:18.09-dind | ||||
|     command: ["--storage-driver=vfs"] | ||||
|     privileged: true | ||||
|       - '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,4 +1,4 @@ | ||||
| FROM golang:1.17 | ||||
| FROM golang:1.25 | ||||
| WORKDIR /src | ||||
| COPY . ./ | ||||
| RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/serve | ||||
|   | ||||
							
								
								
									
										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/media" | ||||
| 	"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 *media.Book | ||||
| } | ||||
|  | ||||
| func NewEventBookUpdate(b *media.Book) *EventBookUpdate { | ||||
| 	e := &EventBookUpdate{book: b} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| func (e *EventBookUpdate) Book() *media.Book { | ||||
| 	return e.book | ||||
| } | ||||
|  | ||||
| // open new book in display | ||||
| type EventLoadBook struct { | ||||
| 	tcell.EventTime | ||||
| 	ID int | ||||
| } | ||||
|  | ||||
| func NewEventLoadBook(id int) *EventLoadBook { | ||||
| 	e := &EventLoadBook{ID: id} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // open new book in display | ||||
| type EventEnterBook struct { | ||||
| 	tcell.EventTime | ||||
| } | ||||
|  | ||||
| func NewEventEnterBook() *EventEnterBook { | ||||
| 	e := &EventEnterBook{} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // switch back to menu control | ||||
| type EventExitBook struct { | ||||
| 	tcell.EventTime | ||||
| } | ||||
|  | ||||
| func NewEventExitBook() *EventExitBook { | ||||
| 	e := &EventExitBook{} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // open import window | ||||
| type EventOpenImport struct { | ||||
| 	tcell.EventTime | ||||
| } | ||||
|  | ||||
| func NewEventOpenImport() *EventOpenImport { | ||||
| 	e := &EventOpenImport{} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // attempt to import given filename.csv | ||||
| type EventAttemptImport struct { | ||||
| 	tcell.EventTime | ||||
| 	filename string | ||||
| } | ||||
|  | ||||
| func NewEventAttemptImport(f string) *EventAttemptImport { | ||||
| 	e := &EventAttemptImport{filename: f} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // close import window | ||||
| type EventCloseImport struct { | ||||
| 	tcell.EventTime | ||||
| } | ||||
|  | ||||
| func NewEventCloseImport() *EventCloseImport { | ||||
| 	e := &EventCloseImport{} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
|  | ||||
| // quit | ||||
| type EventQuit struct { | ||||
| 	tcell.EventTime | ||||
| } | ||||
|  | ||||
| func NewEventQuit() *EventQuit { | ||||
| 	e := &EventQuit{} | ||||
| 	e.SetEventNow() | ||||
| 	return e | ||||
| } | ||||
| @@ -1,336 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"runtime/debug" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"git.yetaga.in/alazyreader/library/config" | ||||
| 	"git.yetaga.in/alazyreader/library/database" | ||||
| 	"git.yetaga.in/alazyreader/library/importer" | ||||
| 	"git.yetaga.in/alazyreader/library/media" | ||||
| 	"git.yetaga.in/alazyreader/library/ui" | ||||
| 	"github.com/gdamore/tcell" | ||||
| 	"github.com/kelseyhightower/envconfig" | ||||
| ) | ||||
|  | ||||
| // State holds the UI state keys=>value map and manages access to the map with a mutex | ||||
| type State struct { | ||||
| 	m        sync.Mutex | ||||
| 	stateMap map[string]interface{} | ||||
| } | ||||
|  | ||||
| // key, present | ||||
| func (s *State) Get(key string) interface{} { | ||||
| 	s.m.Lock() | ||||
| 	defer s.m.Unlock() | ||||
| 	if s.stateMap == nil { | ||||
| 		s.stateMap = make(map[string]interface{}) | ||||
| 	} | ||||
| 	k, ok := s.stateMap[key] | ||||
| 	if !ok { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return k | ||||
| } | ||||
|  | ||||
| // key, value | ||||
| func (s *State) Set(key string, value interface{}) { | ||||
| 	s.m.Lock() | ||||
| 	defer s.m.Unlock() | ||||
| 	if s.stateMap == nil { | ||||
| 		s.stateMap = make(map[string]interface{}) | ||||
| 	} | ||||
| 	s.stateMap[key] = value | ||||
| } | ||||
|  | ||||
| func max(a, b int) int { | ||||
| 	if a > b { | ||||
| 		return a | ||||
| 	} | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // UI states | ||||
| const ( | ||||
| 	IN_MENU = iota | ||||
| 	IN_BOOK | ||||
| 	IN_IMPORT | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	var c config.Config | ||||
| 	err := envconfig.Process("library", &c) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
|  | ||||
| 	// create state | ||||
| 	state := State{} | ||||
|  | ||||
| 	// set up DB connection | ||||
| 	if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { | ||||
| 		if c.DBPass != "" { // obscure password | ||||
| 			c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3)) | ||||
| 		} | ||||
| 		log.Fatalf("vars: %+v", c) | ||||
| 	} | ||||
| 	lib, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	err = lib.PrepareDatabase(context.Background()) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	_, _, err = lib.RunMigrations(context.Background()) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	books, err := lib.GetAllBooks(context.Background()) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	state.Set("library", books) | ||||
|  | ||||
| 	screen, err := tcell.NewScreen() | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	err = screen.Init() | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	// cleanup our screen and log if we panic and crash out somewhere | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			if screen != nil { | ||||
| 				screen.Fini() | ||||
| 			} | ||||
| 			fmt.Println("fatal panic;", r) | ||||
| 			if c.Debug { | ||||
| 				fmt.Println("stacktrace: \n" + string(debug.Stack())) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	// book list and options menu (left column) | ||||
| 	l := ui.NewList(Titles(state.Get("library").([]media.Book)), 0) | ||||
| 	menu := ui.NewBox( | ||||
| 		"library", | ||||
| 		[]string{"˄˅ select", "⏎ edit", "(n)ew", "(i)mport", "(q)uit"}, | ||||
| 		ui.Contents{{ | ||||
| 			Offsets:   ui.Offsets{Top: 1, Left: 2, Bottom: -2, Right: -2}, | ||||
| 			Container: l, | ||||
| 		}}, | ||||
| 		ui.StyleActive, | ||||
| 		false, | ||||
| 	) | ||||
| 	activeBookDetails := ui.NewBookDetails(&media.Book{}) | ||||
|  | ||||
| 	// book display (right column) | ||||
| 	activeBook := ui.NewBox( | ||||
| 		"book", | ||||
| 		[]string{"˄˅ select", "⏎ edit", "(esc) close"}, | ||||
| 		ui.Contents{{ | ||||
| 			Offsets:   ui.Offsets{Top: 1, Left: 2, Bottom: 0, Right: 0}, | ||||
| 			Container: activeBookDetails, | ||||
| 		}}, | ||||
| 		ui.StyleInactive, | ||||
| 		false, | ||||
| 	) | ||||
|  | ||||
| 	// parent container | ||||
| 	container := ui.NewContainer( | ||||
| 		ui.Contents{ | ||||
| 			{Container: menu, Offsets: ui.Offsets{Percent: 1}}, | ||||
| 			{Container: activeBook, Offsets: ui.Offsets{Percent: 2}}, | ||||
| 		}, | ||||
| 		ui.LayoutHorizontalPercent, | ||||
| 	) | ||||
|  | ||||
| 	// import pop-up | ||||
| 	wd, _ := os.Getwd() | ||||
| 	fileSelector := ui.NewEditableTextLine(wd) | ||||
| 	fileSelector.ResetCursor(false) | ||||
| 	fileSelector.SetStyle(ui.StyleActive.Underline(true)) | ||||
| 	popup := ui.NewBox( | ||||
| 		"import csv file", | ||||
| 		[]string{"⏎ submit", "(esc)close"}, | ||||
| 		ui.Contents{ | ||||
| 			{Container: fileSelector, Offsets: ui.Offsets{Top: 2, Left: 2}}, | ||||
| 		}, | ||||
| 		ui.StyleActive, | ||||
| 		false, | ||||
| 	) | ||||
| 	popup.SetVisible(false) | ||||
|  | ||||
| 	// error pop-up | ||||
| 	errorMessage := ui.NewEditableTextLine("") | ||||
| 	errorPopup := ui.NewBox( | ||||
| 		"error", | ||||
| 		[]string{"⏎ close"}, | ||||
| 		ui.Contents{ | ||||
| 			{Container: errorMessage, Offsets: ui.Offsets{Top: 1, Left: 1}}, | ||||
| 		}, | ||||
| 		ui.StyleActive.Bold(true).Foreground(tcell.ColorRed), | ||||
| 		false, | ||||
| 	) | ||||
| 	errorPopup.SetVisible(false) | ||||
|  | ||||
| 	// init | ||||
| 	screen.Clear() | ||||
| 	w, h := screen.Size() | ||||
| 	container.SetSize(0, 0, h, w) | ||||
| 	container.Draw(screen) | ||||
| 	screen.Sync() | ||||
|  | ||||
| 	// init UI state | ||||
| 	state.Set("ui_state", IN_MENU) | ||||
| 	screen.PostEvent(NewEventLoadBook(l.SelectedID())) | ||||
|  | ||||
| 	// UI loop | ||||
| 	for { | ||||
| 		e := screen.PollEvent() | ||||
| 		switch v := e.(type) { | ||||
| 		case *tcell.EventError: | ||||
| 			fmt.Fprintf(os.Stderr, "%v", v) | ||||
| 			screen.Beep() | ||||
| 		case *tcell.EventKey: // input handling | ||||
| 			curr := state.Get("ui_state").(int) | ||||
| 			if curr == IN_MENU { | ||||
| 				if v.Key() == tcell.KeyUp && l.Selected() > 0 { | ||||
| 					l.SetSelected(l.Selected() - 1) | ||||
| 					screen.PostEvent(NewEventLoadBook(l.SelectedID())) | ||||
| 				} | ||||
| 				if v.Key() == tcell.KeyDown && l.Selected() < len(l.ListMembers())-1 { | ||||
| 					l.SetSelected(l.Selected() + 1) | ||||
| 					screen.PostEvent(NewEventLoadBook(l.SelectedID())) | ||||
| 				} | ||||
| 				if v.Key() == tcell.KeyEnter { | ||||
| 					screen.PostEvent(NewEventEnterBook()) | ||||
| 				} | ||||
| 				if v.Rune() == 'i' { | ||||
| 					screen.PostEvent(NewEventOpenImport()) | ||||
| 				} | ||||
| 				if v.Rune() == 'q' { | ||||
| 					screen.PostEvent(NewEventQuit()) | ||||
| 				} | ||||
| 			} else if curr == IN_BOOK { | ||||
| 				if v.Key() == tcell.KeyEsc { | ||||
| 					screen.PostEvent(NewEventExitBook()) | ||||
| 				} | ||||
| 			} else if curr == IN_IMPORT { | ||||
| 				if v.Key() == tcell.KeyEsc { | ||||
| 					fileSelector.SetText(wd) | ||||
| 					fileSelector.ResetCursor(false) | ||||
| 					screen.PostEvent(NewEventCloseImport()) | ||||
| 				} | ||||
| 				if v.Key() == tcell.KeyBackspace || v.Key() == tcell.KeyBackspace2 { | ||||
| 					fileSelector.DeleteAtCursor() | ||||
| 				} else if v.Key() == tcell.KeyRight { | ||||
| 					fileSelector.MoveCursor(1) | ||||
|  | ||||
| 				} else if v.Key() == tcell.KeyLeft { | ||||
| 					fileSelector.MoveCursor(-1) | ||||
| 				} else if v.Key() == tcell.KeyEnter { | ||||
| 					screen.PostEvent(NewEventAttemptImport(fileSelector.Text())) | ||||
| 				} else if v.Rune() != 0 { | ||||
| 					fileSelector.InsertAtCursor(v.Rune()) | ||||
| 				} | ||||
| 			} | ||||
| 		case *tcell.EventResize: // screen redraw | ||||
| 			w, h := screen.Size() | ||||
| 			container.SetSize(0, 0, h, w) | ||||
| 		case *EventBookUpdate: | ||||
| 			// TK | ||||
| 		case *EventEnterBook: | ||||
| 			activeBook.SetStyle(ui.StyleActive) | ||||
| 			menu.SetStyle(ui.StyleInactive) | ||||
| 			state.Set("ui_state", IN_BOOK) | ||||
| 		case *EventExitBook: | ||||
| 			state.Set("ui_state", IN_MENU) | ||||
| 			activeBook.SetStyle(ui.StyleInactive) | ||||
| 			menu.SetStyle(ui.StyleActive) | ||||
| 		case *EventLoadBook: | ||||
| 			activeBookDetails.SetBook(GetBookByID(v.ID, books)) | ||||
| 		case *EventOpenImport: | ||||
| 			state.Set("ui_state", IN_IMPORT) | ||||
| 			menu.SetStyle(ui.StyleInactive) | ||||
| 			popup.SetVisible(true) | ||||
| 			popup.SetSize(6, 3, 5, 80) | ||||
| 		case *EventAttemptImport: | ||||
| 			// this will block other events, but it shouldn't take too long... | ||||
| 			f, err := os.Open(v.filename) | ||||
| 			if err != nil { | ||||
| 				screen.PostEvent(NewEventError(err)) | ||||
| 				continue | ||||
| 			} | ||||
| 			books, err := importer.CSVToBooks(f) | ||||
| 			if err != nil { | ||||
| 				screen.PostEvent(NewEventError(err)) | ||||
| 				continue | ||||
| 			} | ||||
| 			for b := range books { | ||||
| 				err = lib.AddBook(context.Background(), &books[b]) | ||||
| 				if err != nil { | ||||
| 					screen.PostEvent(NewEventError(err)) | ||||
| 				} | ||||
| 			} | ||||
| 			screen.PostEvent(NewEventCloseImport()) | ||||
| 			allbooks, err := lib.GetAllBooks(context.Background()) | ||||
| 			if err != nil { | ||||
| 				screen.PostEvent(NewEventError(err)) | ||||
| 			} | ||||
| 			state.Set("library", allbooks) | ||||
| 			state.Set("ui_state", IN_MENU) | ||||
| 		case *EventCloseImport: | ||||
| 			state.Set("ui_state", IN_MENU) | ||||
| 			screen.HideCursor() | ||||
| 			menu.SetStyle(ui.StyleActive) | ||||
| 			popup.SetVisible(false) | ||||
| 		case *EventError: | ||||
| 			errorMessage.SetText(v.err.Error()) | ||||
| 			errorPopup.SetVisible(true) | ||||
| 		case *EventQuit: | ||||
| 			screen.Fini() | ||||
| 			fmt.Printf("Thank you for playing Wing Commander!\n\n") | ||||
| 			return | ||||
| 		case *tcell.EventInterrupt: | ||||
| 		case *tcell.EventMouse: | ||||
| 		case *tcell.EventTime: | ||||
| 		default: | ||||
| 		} | ||||
| 		// repaint | ||||
| 		l.SetMembers(Titles(state.Get("library").([]media.Book))) | ||||
| 		container.Draw(screen) | ||||
| 		popup.Draw(screen) | ||||
| 		errorPopup.Draw(screen) | ||||
| 		screen.Show() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func Titles(lb []media.Book) []ui.ListKeyValue { | ||||
| 	r := []ui.ListKeyValue{} | ||||
| 	for i := range lb { | ||||
| 		r = append(r, ui.ListKeyValue{ | ||||
| 			Key:   lb[i].ID, | ||||
| 			Value: lb[i].Title, | ||||
| 		}) | ||||
| 	} | ||||
| 	return r | ||||
| } | ||||
|  | ||||
| func GetBookByID(id int, lb []media.Book) *media.Book { | ||||
| 	for i := range lb { | ||||
| 		if lb[i].ID == id { | ||||
| 			return &lb[i] | ||||
| 		} | ||||
| 	} | ||||
| 	return &media.Book{} | ||||
| } | ||||
							
								
								
									
										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,10 +2,12 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"io/fs" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| @@ -13,100 +15,78 @@ import ( | ||||
| 	"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) ([]media.Book, error) | ||||
| 	AddBook(context.Context, *media.Book) error | ||||
| 	DeleteBook(context.Context, *media.Book) error | ||||
| } | ||||
|  | ||||
| type RecordCollection interface { | ||||
| 	GetAllRecords(context.Context) ([]media.Record, error) | ||||
| } | ||||
|  | ||||
| type Router struct { | ||||
| 	static fs.FS | ||||
| 	lib    Library | ||||
| 	rcol   RecordCollection | ||||
| } | ||||
|  | ||||
| func writeJSON(w http.ResponseWriter, b []byte, status int) { | ||||
| 	w.Header().Set("Content-Type", "application/json; charset=utf-8") | ||||
| 	w.WriteHeader(status) | ||||
| 	w.Write(b) | ||||
| 	w.Write([]byte("\n")) | ||||
| } | ||||
|  | ||||
| func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { | ||||
| 	if req.URL.Path == "/api/records" { | ||||
| 		RecordsAPIHandler(r.rcol).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	if req.URL.Path == "/api/books" { | ||||
| 		BooksAPIHandler(r.lib).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	StaticHandler(r.static).ServeHTTP(w, req) | ||||
| } | ||||
|  | ||||
| func BooksAPIHandler(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 | ||||
|  | ||||
| 		} | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func RecordsAPIHandler(l RecordCollection) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		books, err := l.GetAllRecords(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 | ||||
| 		} | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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) | ||||
| 	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.Printf("latest migration: %d; migrations run: %d", latest, run) | ||||
| 		lib = sqllib | ||||
| 	} | ||||
| 	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) | ||||
| @@ -114,30 +94,77 @@ func main() { | ||||
| 		if c.DiscogsToken != "" { | ||||
| 			c.DiscogsToken = obscureStr(c.DiscogsToken, 3) | ||||
| 		} | ||||
| 		log.Fatalf("vars: %+v", c) | ||||
| 		return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c) | ||||
| 	} | ||||
| 	lib, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) | ||||
| 	sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 		return nil, 0, 0, err | ||||
| 	} | ||||
| 	err = lib.PrepareDatabase(context.Background()) | ||||
| 	err = sql.PrepareDatabase(context.Background()) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 		return nil, 0, 0, err | ||||
| 	} | ||||
| 	latest, run, err := lib.RunMigrations(context.Background()) | ||||
| 	latest, run, err := sql.RunMigrations(context.Background()) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 		return nil, 0, 0, err | ||||
| 	} | ||||
| 	log.Printf("latest migration: %d; migrations run: %d", latest, run) | ||||
| 	discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile) | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	r := &Router{ | ||||
| 		static: f, | ||||
| 		lib:    lib, | ||||
| 		rcol:   discogsCache, | ||||
| 	} | ||||
| 	log.Println("Listening on http://0.0.0.0:8080/") | ||||
| 	log.Fatalln(http.ListenAndServe(":8080", r)) | ||||
| 	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,6 +1,7 @@ | ||||
| package config | ||||
|  | ||||
| type Config struct { | ||||
| 	DBType           string `default:"sql"` | ||||
| 	DBUser           string | ||||
| 	DBPass           string | ||||
| 	DBHost           string | ||||
|   | ||||
							
								
								
									
										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 | ||||
| @@ -153,9 +153,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { | ||||
| 	} | ||||
|  | ||||
| 	allBooksQuery := fmt.Sprintf(`SELECT | ||||
| 		id, title, authors, sortauthor, isbn10, isbn13, format, | ||||
| 		genre, publisher, series, volume, year, signed, | ||||
| 		description, notes, onloan, coverurl | ||||
| 		id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, | ||||
| 		series, volume, year, signed, description, notes, coverurl, childrens | ||||
| 	FROM %s`, m.tableName) | ||||
|  | ||||
| 	books := []media.Book{} | ||||
| @@ -169,16 +168,13 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { | ||||
| 		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) | ||||
| 	} | ||||
|  | ||||
| @@ -192,25 +188,14 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error { | ||||
|  | ||||
| 	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 | ||||
| @@ -235,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.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 | ||||
| 	} | ||||
| @@ -283,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.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]) | ||||
|   | ||||
| @@ -49,22 +49,10 @@ func NewDiscogsCache(token string, maxCacheAge time.Duration, username string, p | ||||
| 		persistFile: persistFile, | ||||
| 	} | ||||
| 	if cache.persistence && cache.persistFile != "" { | ||||
| 		p := &persistence{} | ||||
| 		f, err := os.Open(cache.persistFile) | ||||
| 		if errors.Is(err, os.ErrNotExist) { | ||||
| 			log.Printf("%s not found, skipping file load...", cache.persistFile) | ||||
| 			return cache, nil | ||||
| 		} | ||||
| 		cache.cache, cache.lastRefresh, err = cache.loadRecordsFromFS(context.Background()) | ||||
| 		if err != nil { | ||||
| 			return cache, fmt.Errorf("error opening cache file %s: %w", cache.persistFile, err) | ||||
| 			return nil, fmt.Errorf("cache load failed: %w", err) | ||||
| 		} | ||||
| 		err = gob.NewDecoder(f).Decode(p) | ||||
| 		if err != nil { | ||||
| 			return cache, fmt.Errorf("error readhing from cache file %s: %w", cache.persistFile, err) | ||||
| 		} | ||||
| 		log.Printf("loaded %d records from %s", len(p.CachedRecordSlice), cache.persistFile) | ||||
| 		cache.cache = p.CachedRecordSlice | ||||
| 		cache.lastRefresh = p.LastRefresh | ||||
| 		if time.Now().After(cache.lastRefresh.Add(cache.maxCacheAge)) { | ||||
| 			log.Printf("cache expired, running refresh...") | ||||
| 			go func() { | ||||
| @@ -102,6 +90,24 @@ func (c *DiscogsCache) GetAllRecords(ctx context.Context) ([]media.Record, error | ||||
| 	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() | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| version: "3.8" | ||||
| services: | ||||
|   mysql: | ||||
|     image: mysql:5.7 | ||||
|     image: mysql:9.5 | ||||
|     ports: | ||||
|       - 3306:3306 | ||||
|     environment: | ||||
|   | ||||
| @@ -3,41 +3,130 @@ var sortState = { | ||||
|   sortOrder: "asc", | ||||
| }; | ||||
|  | ||||
| function init() { | ||||
|   fetch("/api/books") | ||||
| var admin = false; | ||||
|  | ||||
| var books; | ||||
|  | ||||
| function checkAdminMode() { | ||||
|   fetch("/api/mode") | ||||
|     .then((response) => response.json()) | ||||
|     .then((books) => { | ||||
|       // prepare response | ||||
|       books.forEach(apiResponseParsing); | ||||
|       document.getElementById("search").addEventListener("input", (e) => { | ||||
|         renderTable(search(books, e.target.value)); | ||||
|       }); | ||||
|       renderTable(books); | ||||
|     .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 renderTable(books, sortField) { | ||||
|   if (sortField) { | ||||
|     if (sortState.sortBy === sortField && sortState.sortOrder === "asc") { | ||||
|       sortState.sortOrder = "desc"; | ||||
| 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 { | ||||
|       sortState.sortOrder = "asc"; | ||||
|       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; | ||||
|   } | ||||
|   books.sort((one, two) => | ||||
|   bookList.sort((one, two) => | ||||
|     (one[sortState.sortBy] + one["sortTitle"]).localeCompare( | ||||
|       two[sortState.sortBy] + two["sortTitle"] | ||||
|     ) | ||||
|   ); | ||||
|   if (sortState.sortOrder === "desc") { | ||||
|     books.reverse(); | ||||
|     bookList.reverse(); | ||||
|   } | ||||
|   books.forEach((e, i) => (e.rowNumber = i)); // re-key | ||||
|   bookList.forEach((e, i) => (e.rowNumber = i)); // re-key | ||||
|  | ||||
|   // rendering | ||||
|   var bookElement = document.getElementById("books"); | ||||
|   bookElement.innerHTML = TableTemplate(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")) | ||||
| @@ -46,7 +135,7 @@ function renderTable(books, sortField) { | ||||
|       row.addEventListener("click", (e) => { | ||||
|         // add listener to swap current book | ||||
|         document.getElementById("current").innerHTML = BookTemplate( | ||||
|           books[e.currentTarget.id] | ||||
|           bookList[e.currentTarget.id] | ||||
|         ); | ||||
|       }); | ||||
|     }); | ||||
| @@ -54,7 +143,8 @@ function renderTable(books, sortField) { | ||||
|   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 | ||||
|         // only add callback when there's a sortBy attribute | ||||
|         renderTable(bookList, e.target.dataset.sortBy); | ||||
|       }); | ||||
|     } | ||||
|   ); | ||||
| @@ -78,12 +168,13 @@ function apiResponseParsing(book) { | ||||
|   return book; | ||||
| } | ||||
|  | ||||
| function search(books, searchBy) { | ||||
| function search(searchBy, includeChildrensBooks) { | ||||
|   searchBy = searchCleaner(searchBy); | ||||
|   if (searchBy !== "") { | ||||
|     books = books.filter( | ||||
|       ({ title, authors, genre, publisher, series, year }) => { | ||||
|         return Object.values({ | ||||
|   return books.filter( | ||||
|     ({ title, authors, genre, publisher, series, year, childrens }) => { | ||||
|       var inSearch = true; | ||||
|       if (searchBy !== "") { | ||||
|         inSearch = Object.values({ | ||||
|           title, | ||||
|           authors: authors.join(" "), | ||||
|           genre, | ||||
| @@ -92,9 +183,12 @@ function search(books, searchBy) { | ||||
|           year, | ||||
|         }).find((field) => searchCleaner(field).indexOf(searchBy) !== -1); | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
|   return books; | ||||
|       if (!includeChildrensBooks) { | ||||
|         return inSearch && !childrens; | ||||
|       } | ||||
|       return inSearch; | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function titleCleaner(title) { | ||||
| @@ -120,14 +214,20 @@ function ISBNfromEAN(EAN) { | ||||
|   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, | ||||
|   description, | ||||
|   format, | ||||
|   notes, | ||||
|   onLoan, | ||||
|   publisher, | ||||
|   series, | ||||
|   signed, | ||||
| @@ -135,45 +235,43 @@ function BookTemplate({ | ||||
|   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>` : ""} | ||||
|   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, | ||||
|   onLoan, | ||||
|   publisher, | ||||
|   rowNumber, | ||||
|   signed, | ||||
|   title, | ||||
|   year, | ||||
| }) { | ||||
|   return `<tr class="tRow ${onLoan ? "onLoan" : ""}" id="${rowNumber}"> | ||||
|   return `<tr class="tRow" id="${rowNumber}"> | ||||
|     <td class="title"> | ||||
|       ${title} ${ | ||||
|     signed ? '<span class="signed" title="Signed by the author" >✒</span>︎' : "" | ||||
|     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> | ||||
|     <td class="isbn">${isbn13 ? isbn13 : isbn10}</td> | ||||
|   </tr>`; | ||||
| } | ||||
|  | ||||
| @@ -189,3 +287,36 @@ function TableTemplate(books) { | ||||
|       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 | ||||
| @@ -17,6 +13,12 @@ | ||||
|     <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 personal library record." /> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div class="wrapper"> | ||||
| @@ -29,7 +31,13 @@ | ||||
|           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" | ||||
|   | ||||
| @@ -39,6 +39,9 @@ function renderTable(records, sortField) { | ||||
|   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 | ||||
| @@ -128,7 +131,7 @@ function RecordTemplate({ | ||||
|   year, | ||||
|   discogsURL, | ||||
| }) { | ||||
|   return `${coverURL ? `<img src="${coverURL}"/>` : ""} | ||||
|   return `${coverURL ? `<img src="${coverURL}" loading="lazy"/>` : ""} | ||||
|   <h1>${name}</h1> | ||||
|   <h2>${artists.join(", ")}</h2> | ||||
|   <span>${identifier}</span><br/> | ||||
| @@ -144,34 +147,22 @@ function RecordTemplate({ | ||||
|   </span>`; | ||||
| } | ||||
|  | ||||
| function TableRowTemplate({ | ||||
|   artists, | ||||
|   identifier, | ||||
|   label, | ||||
|   rowNumber, | ||||
|   name, | ||||
|   year, | ||||
| }) { | ||||
|   return `<tr class="tRow" id="${rowNumber}"> | ||||
|     <td class="name"> | ||||
|       ${name} | ||||
|     </td> | ||||
|     <td class="artist">${artists.join(", ")}</td> | ||||
|     <td class="label">${label}</td> | ||||
|     <td class="identifier">${identifier}</td> | ||||
|     <td class="year">${year}</td> | ||||
|   </tr>`; | ||||
| 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 `<table class="recordTable"> | ||||
|     <tr> | ||||
|       <th data-sort-by="sortName" class="tHeader name">Name</th> | ||||
|       <th data-sort-by="sortArtist" class="tHeader artist">Artist(s)</th> | ||||
|       <th data-sort-by="label" class="tHeader label">Label</th> | ||||
|       <th data-sort-by="identifier" class="tHeader identifier">Identifier</th> | ||||
|       <th data-sort-by="year" class="tHeader year">Year</th> | ||||
|     </tr>${records.reduce((acc, record) => { | ||||
|       return acc.concat(TableRowTemplate(record)); | ||||
|     }, "")} </table>`; | ||||
|   return `<div class="flow">${records.reduce((acc, record) => { | ||||
|     return acc.concat(TableRowTemplate(record)); | ||||
|   }, "")} </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 | ||||
| @@ -17,6 +13,11 @@ | ||||
|     <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"> | ||||
| @@ -30,6 +31,7 @@ | ||||
|           >git</a | ||||
|         > | ||||
|         <div id="searchBox"> | ||||
|           <span id="recordCount" class="recordCount">_ records</span> | ||||
|           <input | ||||
|             id="search" | ||||
|             type="text" | ||||
| @@ -38,8 +40,11 @@ | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div id="current">No Record Selected</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> | ||||
|   | ||||
| @@ -147,6 +147,11 @@ body { | ||||
|   display: inline; | ||||
| } | ||||
|  | ||||
| #header .recordCount { | ||||
|   font-size: small; | ||||
|   color: #a29c77; | ||||
| } | ||||
|  | ||||
| #searchBox { | ||||
|   position: absolute; | ||||
|   right: 10px; | ||||
| @@ -174,111 +179,60 @@ body { | ||||
|   color: #d8d0a0; | ||||
| } | ||||
|  | ||||
| #current { | ||||
|   background-color: #f7f3dc; | ||||
|   width: calc(40vw - 40px); | ||||
|   height: calc(100vh - 80px); | ||||
|   padding: 20px; | ||||
| #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; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| #records { | ||||
|   width: calc(60vw - 40px); | ||||
|   height: calc(100vh - 80px); | ||||
|   padding: 20px; | ||||
|   overflow: auto; | ||||
|   float: left; | ||||
| #records .flow .record { | ||||
|   display: inline-block; | ||||
|   width: 250px; | ||||
|   padding: 15px; | ||||
|   border-radius: 3px; | ||||
| } | ||||
|  | ||||
| .recordTable th { | ||||
|   font-weight: bold; | ||||
|   text-align: left; | ||||
|   font-family: "Libre Baskerville", sans-serif; | ||||
| } | ||||
|  | ||||
| .recordTable th[data-sort-by] { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .recordTable th[data-sort-by]::after { | ||||
|   content: "\f0dc"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable th.asc::after { | ||||
|   content: "\f0de"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable th.desc::after { | ||||
|   content: "\f0dd"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable td, | ||||
| .recordTable th { | ||||
|   padding: 5px; | ||||
|   min-width: 50px; | ||||
| } | ||||
|  | ||||
| .tRow:nth-child(odd) { | ||||
| #records .flow .record:nth-child(odd) { | ||||
|   background: #f9f8ed; | ||||
|   border-bottom: 1px solid #d8d0a0; | ||||
| } | ||||
|  | ||||
| .recordTable .tRow { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .recordTable .onLoan { | ||||
|   color: #bbb; | ||||
| } | ||||
|  | ||||
| .recordTable .tRow .name { | ||||
|   font-style: italic; | ||||
|   max-width: 600px; | ||||
| } | ||||
|  | ||||
| #current h1 { | ||||
|   font-size: x-large; | ||||
|   font-weight: bold; | ||||
|   font-style: italic; | ||||
|   padding: 10px 0; | ||||
| } | ||||
|  | ||||
| #current h2 { | ||||
|   font-size: large; | ||||
|   padding: 7px 0; | ||||
| } | ||||
|  | ||||
| #current img { | ||||
|   max-height: 400px; | ||||
|   max-width: 100%; | ||||
| #records .flow .record .cover { | ||||
|   border-radius: 3px; | ||||
|   max-width: 250px; | ||||
|   display: block; | ||||
|   margin: 0 auto; | ||||
|   margin: 0 auto 3px; | ||||
|   overflow: unset; | ||||
| } | ||||
|  | ||||
| #current .description p { | ||||
|   padding: 20px 0; | ||||
| #records .flow .record .name { | ||||
|   font-style: italic; | ||||
| } | ||||
|  | ||||
| #current h1.onLoan { | ||||
|   color: #bbb; | ||||
| #records .flow .record a.discogsLink { | ||||
|   display: block; | ||||
|   text-align: right; | ||||
|   font-size: smaller; | ||||
|   padding-top: 10px; | ||||
|   color: #a29c77; | ||||
| } | ||||
|  | ||||
| #current h2.onLoan { | ||||
|   font-weight: bold; | ||||
| 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; | ||||
| } | ||||
|   | ||||
							
								
								
									
										83
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										83
									
								
								go.mod
									
									
									
									
									
								
							| @@ -1,12 +1,83 @@ | ||||
| module git.yetaga.in/alazyreader/library | ||||
|  | ||||
| go 1.16 | ||||
|  | ||||
| replace github.com/irlndts/go-discogs v0.3.5 => git.yetaga.in/alazyreader/go-discogs v0.3.6 | ||||
| go 1.25.1 | ||||
|  | ||||
| require ( | ||||
| 	github.com/gdamore/tcell v1.4.0 | ||||
| 	github.com/go-sql-driver/mysql v1.6.0 | ||||
| 	github.com/irlndts/go-discogs v0.3.5 | ||||
| 	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.17.0 | ||||
| 	tailscale.com v1.88.4 | ||||
| ) | ||||
|  | ||||
| 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-20250813024750-ebf49471dced // 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/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-20250716170648-1d0488a3d7da // indirect | ||||
| 	github.com/vishvananda/netns v0.0.5 // 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.38.0 // indirect | ||||
| 	golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect | ||||
| 	golang.org/x/mod v0.24.0 // indirect | ||||
| 	golang.org/x/net v0.40.0 // indirect | ||||
| 	golang.org/x/sys v0.33.0 // indirect | ||||
| 	golang.org/x/term v0.32.0 // indirect | ||||
| 	golang.org/x/text v0.25.0 // indirect | ||||
| 	golang.org/x/time v0.11.0 // indirect | ||||
| 	golang.org/x/tools v0.33.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 | ||||
| ) | ||||
|   | ||||
							
								
								
									
										265
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										265
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,23 +1,246 @@ | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5 h1:XcwcFJP0p1eQQ6OQhScYiNM8+vymeEO4V+M9H9x58os= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6 h1:VhV8/uhnWsxae6PvIVtXOfO4eWWqShX6DkiN2hFsZ8U= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= | ||||
| github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= | ||||
| github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= | ||||
| github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= | ||||
| github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= | ||||
| github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| 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/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-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= | ||||
| github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/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.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/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/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= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| 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-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= | ||||
| github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/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.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= | ||||
| github.com/vishvananda/netns v0.0.5/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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= | ||||
| golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= | ||||
| 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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= | ||||
| golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= | ||||
| golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= | ||||
| golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= | ||||
| golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= | ||||
| golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= | ||||
| golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | ||||
| golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | ||||
| 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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= | ||||
| golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||||
| golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= | ||||
| golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= | ||||
| golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= | ||||
| golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= | ||||
| golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= | ||||
| golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= | ||||
| golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= | ||||
| golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= | ||||
| 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.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= | ||||
| google.golang.org/protobuf v1.36.3/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.88.2 h1:S8S+gt/Vx4KDlVjNHk7spcyGihTcJflKMroSnwjp5kQ= | ||||
| tailscale.com v1.88.2/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= | ||||
| tailscale.com v1.88.3 h1:OiE6iVqzykhbITxmIKjH8d00cw0LsJFO3TuFd4jQVXU= | ||||
| tailscale.com v1.88.3/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= | ||||
| tailscale.com v1.88.4 h1:fXWotRMi9ZARyHRdKQa4ohXj8kqtemvvTzjreWLHVHo= | ||||
| tailscale.com v1.88.4/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= | ||||
|   | ||||
| @@ -40,7 +40,6 @@ func CSVToBooks(r io.Reader) ([]media.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) | ||||
|   | ||||
| @@ -16,8 +16,8 @@ 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 { | ||||
|   | ||||
							
								
								
									
										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/media" | ||||
| 	"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    *media.Book | ||||
| 	style   tcell.Style | ||||
| 	visible bool | ||||
| } | ||||
|  | ||||
| func NewBookDetails(b *media.Book) *BookDetails { | ||||
| 	return &BookDetails{ | ||||
| 		book:    b, | ||||
| 		visible: true, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (l *BookDetails) SetBook(b *media.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