more backend
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
David 2024-01-02 20:52:21 -05:00
parent 382d3bab61
commit 86f643ad0b
13 changed files with 349 additions and 4 deletions

View File

@ -14,6 +14,7 @@ type Router struct {
static fs.FS static fs.FS
lib Library lib Library
rcol RecordCollection rcol RecordCollection
query Query
ts *tailscale.LocalClient ts *tailscale.LocalClient
isAdmin bool isAdmin bool
} }
@ -73,6 +74,14 @@ func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p[http.MethodDelete] = func() { deleteBook(router.lib, w, r) } p[http.MethodDelete] = func() { deleteBook(router.lib, w, r) }
} }
p.ServeHTTP(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: default:
static(router.static).ServeHTTP(w, r) static(router.static).ServeHTTP(w, r)
} }
@ -159,6 +168,20 @@ func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request
writeJSON(w, whois.UserProfile, http.StatusOK) writeJSON(w, whois.UserProfile, http.StatusOK)
} }
func lookupBook(query Query, w http.ResponseWriter, r *http.Request) {
isbn := r.FormValue("isbn")
if len(isbn) != 10 && len(isbn) != 13 {
writeJSONerror(w, "invalid isbn", http.StatusBadRequest)
return
}
book, err := query.GetByISBN(isbn)
if err != nil {
writeJSONerror(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, book, http.StatusOK)
}
func static(f fs.FS) http.Handler { func static(f fs.FS) http.Handler {
return http.FileServer(http.FS(f)) return http.FileServer(http.FS(f))
} }

View File

@ -15,6 +15,7 @@ import (
"git.yetaga.in/alazyreader/library/database" "git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/frontend" "git.yetaga.in/alazyreader/library/frontend"
"git.yetaga.in/alazyreader/library/media" "git.yetaga.in/alazyreader/library/media"
"git.yetaga.in/alazyreader/library/query"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/tsnet" "tailscale.com/tsnet"
@ -35,6 +36,10 @@ type RecordCollection interface {
GetAllRecords(context.Context) ([]media.Record, error) GetAllRecords(context.Context) ([]media.Record, error)
} }
type Query interface {
GetByISBN(string) (*media.Book, error)
}
func main() { func main() {
var c config.Config var c config.Config
must.Do(envconfig.Process("library", &c)) must.Do(envconfig.Process("library", &c))
@ -55,6 +60,8 @@ func main() {
c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile, c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
)) ))
queryProvider := &query.GoogleBooks{}
staticRoot := must.Get(frontend.Root()) staticRoot := must.Get(frontend.Root())
servers := make(chan (*http.Server), 3) servers := make(chan (*http.Server), 3)
@ -74,6 +81,7 @@ func main() {
static: staticRoot, static: staticRoot,
lib: lib, lib: lib,
rcol: discogsCache, rcol: discogsCache,
query: queryProvider,
isAdmin: true, isAdmin: true,
})) }))
}) })
@ -137,11 +145,11 @@ func shutdown(servers chan (*http.Server)) error {
func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) { func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) {
server := &http.Server{Handler: handler} server := &http.Server{Handler: handler}
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080)) ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
log.Println("starting public server") log.Printf("public server: http://0.0.0.0:%d/", port)
return server, ln, nil return server, ln, nil
} }
@ -163,6 +171,7 @@ func tailscaleListener(hostname string, handler *Router) (*http.Server, net.List
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
log.Printf("management server: http://%s/", hostname)
server := &http.Server{Handler: handler} server := &http.Server{Handler: handler}
return server, ln, nil return server, ln, nil
} }

View File

@ -3,7 +3,23 @@ var sortState = {
sortOrder: "asc", sortOrder: "asc",
}; };
var admin = false;
function init() { function init() {
fetch("/api/mode")
.then((response) => response.json())
.then((resp) => (admin = resp.Admin))
.then(() => {
if (admin) {
var element = document.getElementById("addBook");
element.addEventListener("click", (e) => {
e.preventDefault();
renderAddBookView();
});
element.classList.remove("hidden");
}
});
fetch("/api/books") fetch("/api/books")
.then((response) => response.json()) .then((response) => response.json())
.then((books) => { .then((books) => {
@ -33,6 +49,10 @@ function init() {
}); });
} }
function renderAddBookView() {
document.getElementById("current").innerHTML = AddBookTemplate();
}
function renderTable(books, sortField) { function renderTable(books, sortField) {
if (sortField) { if (sortField) {
if (sortState.sortBy === sortField && sortState.sortOrder === "asc") { if (sortState.sortBy === sortField && sortState.sortOrder === "asc") {
@ -173,6 +193,7 @@ function BookTemplate({
} }
${signed ? "<span>Signed by the author ✒</span><br/>" : ""} ${signed ? "<span>Signed by the author ✒</span><br/>" : ""}
<span>${format}</span> <span>${format}</span>
${admin ? `<a href="#">Edit Book</a>` : ""}
</div>`; </div>`;
} }
@ -211,3 +232,31 @@ function TableTemplate(books) {
return acc.concat(TableRowTemplate(book)); return acc.concat(TableRowTemplate(book));
}, "")} </table>`; }, "")} </table>`;
} }
function AddBookTemplate() {
return `<div class="addBookView">
<form>${[
{ name: "ISBN10", type: "text" },
{ name: "ISBN13", type: "text" },
{ name: "Title", type: "text" },
{ name: "Authors", type: "text" },
{ name: "SortAuthor", type: "text" },
{ name: "Format", type: "text" },
{ name: "Genre", type: "text" },
{ name: "Publisher", type: "text" },
{ name: "Series", type: "text" },
{ name: "Volume", type: "text" },
{ name: "Year", type: "text" },
{ name: "Signed", type: "checkbox" },
// { name: "Description", type: "text" },
// { name: "Notes", type: "text" },
{ name: "CoverURL", type: "text" },
{ name: "Childrens", type: "checkbox" },
].reduce((acc, field) => {
return acc.concat(
`<label>${field.name} <input type="${field.type}" name="${field.name.toLowerCase}"/></label><br/>`
);
}, "")}
</form>
</div>`;
}

View File

@ -31,6 +31,7 @@
href="https://git.yetaga.in/alazyreader/library" href="https://git.yetaga.in/alazyreader/library"
>git</a >git</a
> >
<a href="#" id="addBook" class="hidden">add book</a>
<div id="searchBox"> <div id="searchBox">
<label for="childrens" class="bookCount" <label for="childrens" class="bookCount"
>Include Childrens Books?</label >Include Childrens Books?</label

View File

@ -133,6 +133,10 @@ body {
overflow: hidden; overflow: hidden;
} }
.hidden {
display: none;
}
#header { #header {
height: 30px; height: 30px;
width: calc(100vw - 20px); width: calc(100vw - 20px);

1
go.mod
View File

@ -15,6 +15,7 @@ require (
require ( require (
filippo.io/edwards25519 v1.0.0 // indirect filippo.io/edwards25519 v1.0.0 // indirect
git.yetaga.in/alazyreader/go-openlibrary v0.0.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/aws/aws-sdk-go-v2 v1.21.0 // indirect github.com/aws/aws-sdk-go-v2 v1.21.0 // indirect

2
go.sum
View File

@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek=
filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 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.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=

13
query/amazon.go Normal file
View 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
View 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
View 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 int `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
View 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
View 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
}