discogs #2
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
|||||||
*.properties
|
*.properties
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.csv
|
*.csv
|
||||||
|
/vendor
|
@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.yetaga.in/alazyreader/library/config"
|
"git.yetaga.in/alazyreader/library/config"
|
||||||
"git.yetaga.in/alazyreader/library/database"
|
"git.yetaga.in/alazyreader/library/database"
|
||||||
@ -22,24 +23,44 @@ func max(a, b int) int {
|
|||||||
return b
|
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 {
|
type Library interface {
|
||||||
GetAllBooks(context.Context) ([]media.Book, error)
|
GetAllBooks(context.Context) ([]media.Book, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordCollection interface {
|
||||||
|
GetAllRecords(context.Context) ([]media.Record, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
static fs.FS
|
static fs.FS
|
||||||
lib Library
|
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) {
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.URL.Path == "/api" {
|
if req.URL.Path == "/api/records" {
|
||||||
APIHandler(r.lib).ServeHTTP(w, req)
|
RecordsAPIHandler(r.rcol).ServeHTTP(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.URL.Path == "/api/books" {
|
||||||
|
BooksAPIHandler(r.lib).ServeHTTP(w, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
StaticHandler(r.static).ServeHTTP(w, req)
|
StaticHandler(r.static).ServeHTTP(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func APIHandler(l Library) http.Handler {
|
func BooksAPIHandler(l Library) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
books, err := l.GetAllBooks(r.Context())
|
books, err := l.GetAllBooks(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -50,11 +71,25 @@ func APIHandler(l Library) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, b, http.StatusOK)
|
||||||
w.WriteHeader(http.StatusOK)
|
})
|
||||||
w.Write(b)
|
}
|
||||||
w.Write([]byte("\n"))
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,8 +108,11 @@ func main() {
|
|||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
|
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
|
||||||
if c.DBPass != "" { // obscure password
|
if c.DBPass != "" {
|
||||||
c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3))
|
c.DBPass = obscureStr(c.DBPass, 3)
|
||||||
|
}
|
||||||
|
if c.DiscogsToken != "" {
|
||||||
|
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
|
||||||
}
|
}
|
||||||
log.Fatalf("vars: %+v", c)
|
log.Fatalf("vars: %+v", c)
|
||||||
}
|
}
|
||||||
@ -91,10 +129,16 @@ func main() {
|
|||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
log.Printf("latest migration: %d; migrations run: %d", latest, run)
|
log.Printf("latest migration: %d; migrations run: %d", latest, run)
|
||||||
|
discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, "delta.mu.alpha")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
go discogsCache.FlushCache(context.Background())
|
||||||
r := &Router{
|
r := &Router{
|
||||||
static: f,
|
static: f,
|
||||||
lib: lib,
|
lib: lib,
|
||||||
|
rcol: discogsCache,
|
||||||
}
|
}
|
||||||
log.Println("Listening on http://localhost:8080/")
|
log.Println("Listening on http://0.0.0.0:8080/")
|
||||||
log.Fatalln(http.ListenAndServe(":8080", r))
|
log.Fatalln(http.ListenAndServe(":8080", r))
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,6 @@ type Config struct {
|
|||||||
DBHost string
|
DBHost string
|
||||||
DBPort string
|
DBPort string
|
||||||
DBName string
|
DBName string
|
||||||
|
DiscogsToken string
|
||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
|
@ -49,15 +49,15 @@ func (m *MySQL) PrepareDatabase(ctx context.Context) error {
|
|||||||
return fmt.Errorf("uninitialized mysql client")
|
return fmt.Errorf("uninitialized mysql client")
|
||||||
}
|
}
|
||||||
|
|
||||||
tablecheck := `SELECT count(*) AS count
|
tablecheck := fmt.Sprintf(`SELECT count(*) AS count
|
||||||
FROM information_schema.TABLES
|
FROM information_schema.TABLES
|
||||||
WHERE TABLE_NAME = '` + m.versionTable + `'
|
WHERE TABLE_NAME = '%s'
|
||||||
AND TABLE_SCHEMA in (SELECT DATABASE());`
|
AND TABLE_SCHEMA in (SELECT DATABASE());`, m.versionTable)
|
||||||
tableschema := `CREATE TABLE ` + m.versionTable + `(
|
tableschema := fmt.Sprintf(`CREATE TABLE %s (
|
||||||
id INT NOT NULL,
|
id INT NOT NULL,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
datetime DATE,
|
datetime DATE,
|
||||||
PRIMARY KEY (id))`
|
PRIMARY KEY (id))`, m.versionTable)
|
||||||
|
|
||||||
var versionTableExists int
|
var versionTableExists int
|
||||||
m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists)
|
m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists)
|
||||||
@ -73,8 +73,9 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
|
|||||||
return 0, fmt.Errorf("uninitialized mysql client")
|
return 0, fmt.Errorf("uninitialized mysql client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrationCheck := fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", m.versionTable)
|
||||||
var latestMigration int
|
var latestMigration int
|
||||||
err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration)
|
err := m.connection.QueryRowContext(ctx, migrationCheck).Scan(&latestMigration)
|
||||||
return latestMigration, err
|
return latestMigration, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +98,9 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
|||||||
}
|
}
|
||||||
mig.id, mig.name = id, name
|
mig.id, mig.name = id, name
|
||||||
mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
|
mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failure loading migration: %w", err)
|
||||||
|
}
|
||||||
migrations[mig.id] = mig
|
migrations[mig.id] = mig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -116,6 +120,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return latestMigrationRan, 0, err
|
return latestMigrationRan, 0, err
|
||||||
}
|
}
|
||||||
|
migrationLogSql := fmt.Sprintf("INSERT INTO %s (id, name, datetime) VALUES (?, ?, ?)", m.versionTable)
|
||||||
migrationsRun := 0
|
migrationsRun := 0
|
||||||
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
|
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
|
||||||
mig := migrations[latestMigrationRan+1]
|
mig := migrations[latestMigrationRan+1]
|
||||||
@ -127,7 +132,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
|
|||||||
}
|
}
|
||||||
return latestMigrationRan, migrationsRun, err
|
return latestMigrationRan, migrationsRun, err
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now())
|
_, err = tx.ExecContext(ctx, migrationLogSql, mig.id, mig.name, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nestederr := tx.Rollback()
|
nestederr := tx.Rollback()
|
||||||
if nestederr != nil {
|
if nestederr != nil {
|
||||||
@ -147,26 +152,14 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
|
|||||||
return nil, fmt.Errorf("uninitialized mysql client")
|
return nil, fmt.Errorf("uninitialized mysql client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allBooksQuery := fmt.Sprintf(`SELECT
|
||||||
|
id, title, authors, sortauthor, isbn10, isbn13, format,
|
||||||
|
genre, publisher, series, volume, year, signed,
|
||||||
|
description, notes, onloan, coverurl
|
||||||
|
FROM %s`, m.tableName)
|
||||||
|
|
||||||
books := []media.Book{}
|
books := []media.Book{}
|
||||||
rows, err := m.connection.QueryContext(ctx, `
|
rows, err := m.connection.QueryContext(ctx, allBooksQuery)
|
||||||
SELECT id,
|
|
||||||
title,
|
|
||||||
authors,
|
|
||||||
sortauthor,
|
|
||||||
isbn10,
|
|
||||||
isbn13,
|
|
||||||
format,
|
|
||||||
genre,
|
|
||||||
publisher,
|
|
||||||
series,
|
|
||||||
volume,
|
|
||||||
year,
|
|
||||||
signed,
|
|
||||||
description,
|
|
||||||
notes,
|
|
||||||
onloan,
|
|
||||||
coverurl
|
|
||||||
FROM `+m.tableName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
112
database/records.go
Normal file
112
database/records.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yetaga.in/alazyreader/library/media"
|
||||||
|
"github.com/irlndts/go-discogs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscogsCache struct {
|
||||||
|
authToken string
|
||||||
|
m sync.Mutex
|
||||||
|
cache []media.Record
|
||||||
|
maxCacheAge time.Duration
|
||||||
|
lastRefresh time.Time
|
||||||
|
client discogs.Discogs
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscogsCache(token string, maxCacheAge time.Duration, username string) (*DiscogsCache, error) {
|
||||||
|
client, err := discogs.New(&discogs.Options{
|
||||||
|
UserAgent: "library.yetaga.in personal collection cache",
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &DiscogsCache{
|
||||||
|
authToken: token,
|
||||||
|
client: client,
|
||||||
|
maxCacheAge: maxCacheAge,
|
||||||
|
username: username,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscogsCache) FlushCache(ctx context.Context) error {
|
||||||
|
c.m.Lock()
|
||||||
|
defer c.m.Unlock()
|
||||||
|
records, err := c.fetchRecords(ctx, nil)
|
||||||
|
c.cache = records
|
||||||
|
c.lastRefresh = time.Now()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscogsCache) GetAllRecords(ctx context.Context) ([]media.Record, error) {
|
||||||
|
c.m.Lock()
|
||||||
|
defer c.m.Unlock()
|
||||||
|
if time.Now().After(c.lastRefresh.Add(c.maxCacheAge)) {
|
||||||
|
records, err := c.fetchRecords(ctx, nil)
|
||||||
|
c.cache = records
|
||||||
|
c.lastRefresh = time.Now()
|
||||||
|
return c.cache, err
|
||||||
|
}
|
||||||
|
return c.cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscogsCache) fetchRecords(ctx context.Context, pagination *discogs.Pagination) ([]media.Record, error) {
|
||||||
|
records := []media.Record{}
|
||||||
|
if pagination == nil {
|
||||||
|
pagination = getPagination(1)
|
||||||
|
}
|
||||||
|
log.Printf("calling discogs API, page %v", pagination.Page)
|
||||||
|
coll, err := c.client.CollectionItemsByFolder(c.username, 0, pagination)
|
||||||
|
if err != nil {
|
||||||
|
return records, fmt.Errorf("error loading collection: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("length: %v, first item in list: %s", len(coll.Items), coll.Items[0].BasicInformation.Title)
|
||||||
|
for i := range coll.Items {
|
||||||
|
records = append(records, collectionItemToRecord(&coll.Items[i]))
|
||||||
|
}
|
||||||
|
// recurse down the list
|
||||||
|
if coll.Pagination.URLs.Next != "" {
|
||||||
|
coll, err := c.fetchRecords(ctx, getPagination(pagination.Page+1))
|
||||||
|
if err != nil {
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
|
records = append(records, coll...)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPagination(page int) *discogs.Pagination {
|
||||||
|
return &discogs.Pagination{Page: page, Sort: "added", SortOrder: "asc", PerPage: 100}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionItemToRecord(item *discogs.CollectionItemSource) media.Record {
|
||||||
|
artists := []string{}
|
||||||
|
for _, artist := range item.BasicInformation.Artists {
|
||||||
|
artists = append(artists, artist.Name)
|
||||||
|
}
|
||||||
|
year := strconv.Itoa(item.BasicInformation.Year)
|
||||||
|
if year == "0" {
|
||||||
|
year = ""
|
||||||
|
}
|
||||||
|
return media.Record{
|
||||||
|
ID: item.ID,
|
||||||
|
AlbumName: item.BasicInformation.Title,
|
||||||
|
Artists: artists,
|
||||||
|
Identifier: item.BasicInformation.Labels[0].Catno,
|
||||||
|
Format: item.BasicInformation.Formats[0].Name,
|
||||||
|
Genre: item.BasicInformation.Genres[0],
|
||||||
|
Label: item.BasicInformation.Labels[0].Name,
|
||||||
|
Year: year,
|
||||||
|
CoverURL: item.BasicInformation.CoverImage,
|
||||||
|
DiscogsURL: fmt.Sprintf("https://www.discogs.com/release/%v", item.ID),
|
||||||
|
}
|
||||||
|
}
|
@ -4,7 +4,7 @@ var sortState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
fetch("/api")
|
fetch("/api/books")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((books) => {
|
.then((books) => {
|
||||||
// prepare response
|
// prepare response
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<h1>Library</h1>
|
<h1>Library</h1>
|
||||||
|
<a href="/records">records</a>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
176
frontend/files/records/app.js
Normal file
176
frontend/files/records/app.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
var sortState = {
|
||||||
|
sortBy: "sortArtist",
|
||||||
|
sortOrder: "asc",
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
fetch("/api/records")
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((records) => {
|
||||||
|
// prepare response
|
||||||
|
records.forEach(apiResponseParsing);
|
||||||
|
document.getElementById("search").addEventListener("input", (e) => {
|
||||||
|
renderTable(search(records, e.target.value));
|
||||||
|
});
|
||||||
|
renderTable(records);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(records, sortField) {
|
||||||
|
if (sortField) {
|
||||||
|
if (sortState.sortBy === sortField && sortState.sortOrder === "asc") {
|
||||||
|
sortState.sortOrder = "desc";
|
||||||
|
} else {
|
||||||
|
sortState.sortOrder = "asc";
|
||||||
|
}
|
||||||
|
sortState.sortBy = sortField;
|
||||||
|
}
|
||||||
|
records.sort((one, two) =>
|
||||||
|
(one[sortState.sortBy] + one["sortName"]).localeCompare(
|
||||||
|
two[sortState.sortBy] + two["sortName"]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (sortState.sortOrder === "desc") {
|
||||||
|
records.reverse();
|
||||||
|
}
|
||||||
|
records.forEach((e, i) => (e.rowNumber = i)); // re-key
|
||||||
|
|
||||||
|
// rendering
|
||||||
|
var recordElement = document.getElementById("records");
|
||||||
|
recordElement.innerHTML = TableTemplate(records);
|
||||||
|
|
||||||
|
// add listeners for selecting record to view
|
||||||
|
Array.from(recordElement.querySelectorAll("tbody tr"))
|
||||||
|
.slice(1) // remove header from Array
|
||||||
|
.forEach((row) => {
|
||||||
|
row.addEventListener("click", (e) => {
|
||||||
|
// add listener to swap current record
|
||||||
|
document.getElementById("current").innerHTML = RecordTemplate(
|
||||||
|
records[e.currentTarget.id]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// add sorting callbacks
|
||||||
|
Array.from(
|
||||||
|
recordElement.querySelectorAll("tbody tr th[data-sort-by]")
|
||||||
|
).forEach((row) => {
|
||||||
|
row.addEventListener("click", function (e) {
|
||||||
|
renderTable(records, e.target.dataset.sortBy); // only add callback when there's a sortBy attribute
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// mark currently active column
|
||||||
|
recordElement
|
||||||
|
.querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]")
|
||||||
|
.classList.add(sortState.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiResponseParsing(record) {
|
||||||
|
record.sortName = titleCleaner(record.name);
|
||||||
|
record.artists = record.artists.map((artist) => {
|
||||||
|
return artist.replace(/ \([0-9]+\)$/, "");
|
||||||
|
});
|
||||||
|
record.sortArtist = record.artists.reduce((acc, curr) => {
|
||||||
|
return (
|
||||||
|
acc +
|
||||||
|
curr
|
||||||
|
.replace(/^(An?|The)\s/i, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('"', "")
|
||||||
|
.replaceAll(":", "")
|
||||||
|
.replaceAll("'", "")
|
||||||
|
.replaceAll(" ", "")
|
||||||
|
);
|
||||||
|
}, "");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(records, searchBy) {
|
||||||
|
searchBy = searchCleaner(searchBy);
|
||||||
|
if (searchBy !== "") {
|
||||||
|
records = records.filter(({ name, artists, genre, label, year }) => {
|
||||||
|
return Object.values({
|
||||||
|
name,
|
||||||
|
artists: artists.join(" "),
|
||||||
|
genre,
|
||||||
|
label,
|
||||||
|
year,
|
||||||
|
}).find((field) => searchCleaner(field).indexOf(searchBy) !== -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCleaner(title) {
|
||||||
|
return title
|
||||||
|
.replace('"', "")
|
||||||
|
.replace(":", "")
|
||||||
|
.replace(/^(An?|The)\s/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchCleaner(str) {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('"', "")
|
||||||
|
.replaceAll(":", "")
|
||||||
|
.replaceAll("'", "")
|
||||||
|
.replaceAll(" ", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordTemplate({
|
||||||
|
name,
|
||||||
|
artists,
|
||||||
|
coverURL,
|
||||||
|
format,
|
||||||
|
genre,
|
||||||
|
identifier,
|
||||||
|
label,
|
||||||
|
year,
|
||||||
|
discogsURL,
|
||||||
|
}) {
|
||||||
|
return `${coverURL ? `<img src="${coverURL}"/>` : ""}
|
||||||
|
<h1>${name}</h1>
|
||||||
|
<h2>${artists.join(", ")}</h2>
|
||||||
|
<span>${identifier}</span><br/>
|
||||||
|
<span>${genre}, ${label}, ${year}</span><br/>
|
||||||
|
<span>${format}</span>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="${discogsURL}"
|
||||||
|
>
|
||||||
|
Data provided by Discogs.
|
||||||
|
</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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>`;
|
||||||
|
}
|
BIN
frontend/files/records/favicon.ico
Normal file
BIN
frontend/files/records/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 174 KiB |
BIN
frontend/files/records/favicon.png
Normal file
BIN
frontend/files/records/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
46
frontend/files/records/index.html
Normal file
46
frontend/files/records/index.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Library</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700&display=swap"
|
||||||
|
as="style"
|
||||||
|
rel="stylesheet preload prefetch"
|
||||||
|
/>
|
||||||
|
<script type="text/javascript" src="app.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div id="header">
|
||||||
|
<h1>Records</h1>
|
||||||
|
<a href="/">books</a>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href="https://git.yetaga.in/alazyreader/library"
|
||||||
|
>git</a
|
||||||
|
>
|
||||||
|
<div id="searchBox">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="current">No Record Selected</div>
|
||||||
|
<div id="records"></div>
|
||||||
|
<!-- Table goes here -->
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
284
frontend/files/records/style.css
Normal file
284
frontend/files/records/style.css
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
/* http://meyerweb.com/eric/tools/css/reset/
|
||||||
|
v2.0 | 20110126
|
||||||
|
License: none (public domain)
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
div,
|
||||||
|
span,
|
||||||
|
applet,
|
||||||
|
object,
|
||||||
|
iframe,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
p,
|
||||||
|
blockquote,
|
||||||
|
pre,
|
||||||
|
a,
|
||||||
|
abbr,
|
||||||
|
acronym,
|
||||||
|
address,
|
||||||
|
big,
|
||||||
|
cite,
|
||||||
|
code,
|
||||||
|
del,
|
||||||
|
dfn,
|
||||||
|
em,
|
||||||
|
img,
|
||||||
|
ins,
|
||||||
|
kbd,
|
||||||
|
q,
|
||||||
|
s,
|
||||||
|
samp,
|
||||||
|
small,
|
||||||
|
strike,
|
||||||
|
strong,
|
||||||
|
sub,
|
||||||
|
sup,
|
||||||
|
tt,
|
||||||
|
var,
|
||||||
|
b,
|
||||||
|
u,
|
||||||
|
i,
|
||||||
|
center,
|
||||||
|
dl,
|
||||||
|
dt,
|
||||||
|
dd,
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
li,
|
||||||
|
fieldset,
|
||||||
|
form,
|
||||||
|
label,
|
||||||
|
legend,
|
||||||
|
table,
|
||||||
|
caption,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
thead,
|
||||||
|
tr,
|
||||||
|
th,
|
||||||
|
td,
|
||||||
|
article,
|
||||||
|
aside,
|
||||||
|
canvas,
|
||||||
|
details,
|
||||||
|
embed,
|
||||||
|
figure,
|
||||||
|
figcaption,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
hgroup,
|
||||||
|
menu,
|
||||||
|
nav,
|
||||||
|
output,
|
||||||
|
ruby,
|
||||||
|
section,
|
||||||
|
summary,
|
||||||
|
time,
|
||||||
|
mark,
|
||||||
|
audio,
|
||||||
|
video {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
font: inherit;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
article,
|
||||||
|
aside,
|
||||||
|
details,
|
||||||
|
figcaption,
|
||||||
|
figure,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
hgroup,
|
||||||
|
menu,
|
||||||
|
nav,
|
||||||
|
section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
blockquote,
|
||||||
|
q {
|
||||||
|
quotes: none;
|
||||||
|
}
|
||||||
|
blockquote:before,
|
||||||
|
blockquote:after,
|
||||||
|
q:before,
|
||||||
|
q:after {
|
||||||
|
content: "";
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* site CSS starts here */
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
height: 30px;
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
padding: 4px 10px;
|
||||||
|
background-color: #f7f3dc;
|
||||||
|
border-bottom: 2px solid #d8d0a0;
|
||||||
|
font-family: "Libre Baskerville", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header h1 {
|
||||||
|
font-size: xx-large;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchBox {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 7px;
|
||||||
|
text-align: right;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchBox input {
|
||||||
|
width: 300px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f9f8ed;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #d8d0a0;
|
||||||
|
font-family: "Libre Baskerville", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchBox input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchBox input::placeholder {
|
||||||
|
font-family: "Libre Baskerville", sans-serif;
|
||||||
|
color: #d8d0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current {
|
||||||
|
background-color: #f7f3dc;
|
||||||
|
width: calc(40vw - 40px);
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#records {
|
||||||
|
width: calc(60vw - 40px);
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
background: #f9f8ed;
|
||||||
|
border-bottom: 1px solid #d8d0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordTable .tRow {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordTable .onLoan {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordTable .tRow .title {
|
||||||
|
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%;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current .description p {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current h1.onLoan {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current h2.onLoan {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
3
go.mod
3
go.mod
@ -2,8 +2,11 @@ module git.yetaga.in/alazyreader/library
|
|||||||
|
|
||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
|
replace github.com/irlndts/go-discogs v0.3.5 => git.yetaga.in/alazyreader/go-discogs v0.3.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gdamore/tcell v1.4.0
|
github.com/gdamore/tcell v1.4.0
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.6.0
|
||||||
|
github.com/irlndts/go-discogs v0.3.5
|
||||||
github.com/kelseyhightower/envconfig v1.4.0
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
)
|
)
|
||||||
|
7
go.sum
7
go.sum
@ -1,9 +1,15 @@
|
|||||||
|
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 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
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 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
|
||||||
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
|
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 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
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=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
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 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
|
||||||
@ -14,3 +20,4 @@ golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeo
|
|||||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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=
|
||||||
|
@ -21,4 +21,16 @@ type Book struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Record struct {
|
type Record struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
AlbumName string `json:"name"`
|
||||||
|
Artists []string `json:"artists"`
|
||||||
|
SortArtist string `json:"sortArtist"`
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Year string `json:"year"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CoverURL string `json:"coverURL"`
|
||||||
|
DiscogsURL string `json:"discogsURL"`
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user