Compare commits

...

2 Commits

Author SHA1 Message Date
7c6acad689 chore(templates): component-ize things
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 20:04:26 -04:00
5482899075 feat(admin): adding user & importing 2024-05-25 20:02:57 -04:00
36 changed files with 914 additions and 473 deletions

View File

@ -227,6 +227,7 @@ func (api *API) generateTemplates() *multitemplate.Renderer {
render := multitemplate.NewRenderer() render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{ helperFuncs := template.FuncMap{
"dict": dict, "dict": dict,
"slice": slice,
"fields": fields, "fields": fields,
"getSVGGraphData": getSVGGraphData, "getSVGGraphData": getSVGGraphData,
"getTimeZones": getTimeZones, "getTimeZones": getTimeZones,

View File

@ -3,6 +3,7 @@ package api
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -12,14 +13,19 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
"time" "time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
) )
type adminAction string type adminAction string
@ -59,12 +65,13 @@ type operationType string
const ( const (
opUpdate operationType = "UPDATE" opUpdate operationType = "UPDATE"
opCreate operationType = "CREATE" opCreate operationType = "CREATE"
opDelete operationType = "DELETE"
) )
type requestAdminUpdateUser struct { type requestAdminUpdateUser struct {
User string `form:"user"` User string `form:"user"`
Password string `form:"password"` Password *string `form:"password"`
isAdmin bool `form:"is_admin"` isAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"` Operation operationType `form:"operation"`
} }
@ -72,6 +79,22 @@ type requestAdminLogs struct {
Filter string `form:"filter"` Filter string `form:"filter"`
} }
type importStatus string
const (
importFailed importStatus = "FAILED"
importSuccess importStatus = "SUCCESS"
importExists importStatus = "EXISTS"
)
type importResult struct {
ID string
Name string
Path string
Status importStatus
Error error
}
func (api *API) appPerformAdminAction(c *gin.Context) { func (api *API) appPerformAdminAction(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c) templateVars, _ := api.getBaseTemplateVars("admin", c)
@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return return
} }
// TODO - Messages
switch rAdminAction.Action { switch rAdminAction.Action {
case adminMetadataMatch: case adminMetadataMatch:
// TODO // TODO
// 1. Documents xref most recent metadata table? // 1. Documents xref most recent metadata table?
// 2. Select all / deselect? // 2. Select all / deselect?
case adminCacheTables: case adminCacheTables:
go api.db.CacheTempTables() go func() {
// TODO - Message err := api.db.CacheTempTables()
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore: case adminRestore:
api.processRestoreFile(rAdminAction, c) api.processRestoreFile(rAdminAction, c)
return return
@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error var err error
switch rAdminUserUpdate.Operation { switch rAdminUserUpdate.Operation {
case opCreate: case opCreate:
err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password) err = api.createUser(rAdminUserUpdate)
case opUpdate: case opUpdate:
err = fmt.Errorf("unimplemented") err = api.updateUser(rAdminUserUpdate)
case opDelete:
err = api.deleteUser(rAdminUserUpdate)
default: default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation") appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return return
} }
if err != nil { if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create user: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err))
return return
} }
@ -338,46 +365,157 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
return return
} }
// TODO - Store results for approval? // Get import directory
// Walk import directory & copy or import files
importDirectory := filepath.Clean(rAdminImport.Directory) importDirectory := filepath.Clean(rAdminImport.Directory)
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
// Get data directory
absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
// Validate different path
if absoluteDataPath == importDirectory {
appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path")
return
}
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown error")
return
}
// Defer & Start Transaction
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Track imports
importResults := make([]importResult, 0)
// Walk Directory & Import
err = filepath.WalkDir(importDirectory, func(importPath string, f fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if f.IsDir() { if f.IsDir() {
return nil return nil
} }
// Get metadata // Get relative path
fileMeta, err := metadata.GetMetadata(currentPath) basePath := importDirectory
relFilePath, err := filepath.Rel(importDirectory, importPath)
if err != nil { if err != nil {
fmt.Printf("metadata error: %v\n", err) log.Warnf("path error: %v", err)
return nil return nil
} }
// Only needed if copying // Track imports
newName := deriveBaseFileName(fileMeta) iResult := importResult{
Path: relFilePath,
Status: importFailed,
}
defer func() {
importResults = append(importResults, iResult)
}()
// Open File on Disk // Get metadata
// file, err := os.Open(currentPath) fileMeta, err := metadata.GetMetadata(importPath)
// if err != nil { if err != nil {
// return err log.Errorf("metadata error: %v", err)
// } iResult.Error = err
// defer file.Close() return nil
}
iResult.ID = *fileMeta.PartialMD5
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// TODO - BasePath in DB // Check already exists
// TODO - Copy / Import _, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
return nil
}
fmt.Printf("New File Metadata: %s\n", newName) // Import Copy
if rAdminImport.Type == importCopy {
// Derive & Sanitize File Name
relFilePath = deriveBaseFileName(fileMeta)
safePath := filepath.Join(api.cfg.DataPath, "documents", relFilePath)
// Open Source File
srcFile, err := os.Open(importPath)
if err != nil {
log.Errorf("unable to open current file: %v", err)
iResult.Error = err
return nil
}
defer srcFile.Close()
// Open Destination File
destFile, err := os.Create(safePath)
if err != nil {
log.Errorf("unable to open destination file: %v", err)
iResult.Error = err
return nil
}
defer destFile.Close()
// Copy File
if _, err = io.Copy(destFile, srcFile); err != nil {
log.Errorf("unable to save file: %v", err)
iResult.Error = err
return nil
}
// Update Base & Path
basePath = filepath.Join(api.cfg.DataPath, "documents")
iResult.Path = relFilePath
}
// Upsert document
if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
Title: fileMeta.Title,
Author: fileMeta.Author,
Description: fileMeta.Description,
Md5: fileMeta.MD5,
Words: fileMeta.WordCount,
Filepath: &relFilePath,
Basepath: &basePath,
}); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err)
iResult.Error = err
return nil
}
iResult.Status = importSuccess
return nil return nil
}) })
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err))
return
}
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory) // Commit transaction
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "page/admin-import", templateVars) // Sort import results
sort.Slice(importResults, func(i int, j int) bool {
return importStatusPriority(importResults[i].Status) <
importStatusPriority(importResults[j].Status)
})
templateVars["Data"] = importResults
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
} }
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
@ -521,7 +659,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
// Restore all data
func (api *API) restoreData(zipReader *zip.Reader) error { func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories // Ensure Directories
api.cfg.EnsureDirectories() api.cfg.EnsureDirectories()
@ -552,7 +689,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
return nil return nil
} }
// Remove all data
func (api *API) removeData() error { func (api *API) removeData() error {
allPaths := []string{ allPaths := []string{
"covers", "covers",
@ -575,7 +711,6 @@ func (api *API) removeData() error {
return nil return nil
} }
// Backup all data
func (api *API) createBackup(w io.Writer, directories []string) error { func (api *API) createBackup(w io.Writer, directories []string) error {
ar := zip.NewWriter(w) ar := zip.NewWriter(w)
@ -628,7 +763,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
if err != nil { if err != nil {
return err return err
} }
io.Copy(newDbFile, dbFile)
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents // Backup Covers & Documents
for _, dir := range directories { for _, dir := range directories {
@ -641,3 +780,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
ar.Close() ar.Close()
return nil return nil
} }
func (api *API) createUser(createRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if createRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if createRequest.Password == nil || *createRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Base Params
createParams := database.CreateUserParams{
ID: createRequest.User,
}
// Handle Admin (Explicit or False)
if createRequest.isAdmin != nil {
createParams.Admin = *createRequest.isAdmin
} else {
createParams.Admin = false
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
createParams.Pass = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
createParams.AuthHash = &authHash
// Create user in DB
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", createParams.ID)
return fmt.Errorf("user already exists")
}
return nil
}
func (api *API) updateUser(updateRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if updateRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if updateRequest.Password == nil && updateRequest.isAdmin == nil {
return fmt.Errorf("nothing to update")
}
// Base Params
updateParams := database.UpdateUserParams{
UserID: updateRequest.User,
}
// Handle Admin (Update or Existing)
if updateRequest.isAdmin != nil {
updateParams.Admin = *updateRequest.isAdmin
} else {
user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
}
updateParams.Admin = user.Admin
}
// TODO:
// - Validate Not Last Admin
// Handle Password
if updateRequest.Password != nil {
if *updateRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
updateParams.Password = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
updateParams.AuthHash = &authHash
}
// Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
}
return nil
}
func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error {
// TODO:
// - Validate Not Last Admin
return errors.New("unimplemented")
}

View File

@ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("search", c) templateVars, _ := api.getBaseTemplateVars("search", c)
var sParams searchParams var sParams searchParams
c.BindQuery(&sParams) err := c.BindQuery(&sParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return
}
// Only Handle Query // Only Handle Query
if sParams.Query != nil && sParams.Source != nil { if sParams.Query != nil && sParams.Source != nil {
@ -369,10 +373,9 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
DocumentID: rDoc.DocumentID, DocumentID: rDoc.DocumentID,
UserID: auth.UserName, UserID: auth.UserName,
}) })
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Error("UpsertDocument DB Error: ", err) log.Error("GetDocumentProgress DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err))
return return
} }
@ -461,7 +464,8 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
// Derive & Sanitize File Name // Derive & Sanitize File Name
fileName := deriveBaseFileName(metadataInfo) fileName := deriveBaseFileName(metadataInfo)
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Open Destination File // Open Destination File
destFile, err := os.Create(safePath) destFile, err := os.Create(safePath)
@ -488,9 +492,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
Md5: metadataInfo.MD5, Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount, Words: metadataInfo.WordCount,
Filepath: &fileName, Filepath: &fileName,
Basepath: &basePath,
// TODO (BasePath):
// - Should be current config directory
}); err != nil { }); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err) log.Errorf("UpsertDocument DB Error: %v", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
@ -595,7 +597,6 @@ func (api *API) appEditDocument(c *gin.Context) {
} }
c.Redirect(http.StatusFound, "./") c.Redirect(http.StatusFound, "./")
return
} }
func (api *API) appDeleteDocument(c *gin.Context) { func (api *API) appDeleteDocument(c *gin.Context) {
@ -764,6 +765,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
// Derive Extension on MIME // Derive Extension on MIME
fileMime, err := mimetype.DetectFile(tempFilePath) fileMime, err := mimetype.DetectFile(tempFilePath)
if err != nil {
log.Warn("MIME Detect Error: ", err)
sendDownloadMessage("Unable to download file", gin.H{"Error": true})
return
}
fileExtension := fileMime.Extension() fileExtension := fileMime.Extension()
// Derive Filename // Derive Filename
@ -797,7 +803,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
defer sourceFile.Close() defer sourceFile.Close()
// Generate Storage Path & Open File // Generate Storage Path & Open File
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
destFile, err := os.Create(safePath) destFile, err := os.Create(safePath)
if err != nil { if err != nil {
log.Error("Dest File Error: ", err) log.Error("Dest File Error: ", err)
@ -844,8 +852,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
Title: rDocAdd.Title, Title: rDocAdd.Title,
Author: rDocAdd.Author, Author: rDocAdd.Author,
Md5: fileHash, Md5: fileHash,
Filepath: &fileName,
Words: wordCount, Words: wordCount,
Filepath: &fileName,
Basepath: &basePath,
}); err != nil { }); err != nil {
log.Error("UpsertDocument DB Error: ", err) log.Error("UpsertDocument DB Error: ", err)
sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) sendDownloadMessage("Unable to save to database", gin.H{"Error": true})
@ -951,7 +960,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
for _, item := range documents { for _, item := range documents {
@ -1000,7 +1013,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams var qParams queryParams
c.BindQuery(&qParams) err := c.BindQuery(&qParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return qParams
}
if qParams.Limit == nil { if qParams.Limit == nil {
qParams.Limit = &defaultLimit qParams.Limit = &defaultLimit

View File

@ -28,18 +28,13 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"` AuthKey string `header:"x-auth-key"`
} }
// OPDS Auth Headers
type authOPDSHeader struct {
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (auth *authData) { func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username) user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil { if err != nil {
return return
} }
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return return
} }
@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session First // Check Session First
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
user, rawPassword, hasAuth := c.Request.BasicAuth() user, rawPassword, hasAuth := c.Request.BasicAuth()
// Validate Auth Fields // Validate Auth Fields
if hasAuth != true || user == "" || rawPassword == "" { if !hasAuth || user == "" || rawPassword == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session // Check Session
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
c.Abort() c.Abort()
return
} }
func (api *API) authAdminWebAppMiddleware(c *gin.Context) { func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData) auth := data.(authData)
if auth.IsAdmin == true { if auth.IsAdmin {
c.Next() c.Next()
return return
} }
@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort() c.Abort()
return
} }
func (api *API) appAuthLogin(c *gin.Context) { func (api *API) appAuthLogin(c *gin.Context) {
@ -276,7 +269,10 @@ func (api *API) appAuthRegister(c *gin.Context) {
func (api *API) appAuthLogout(c *gin.Context) { func (api *API) appAuthLogout(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
session.Clear() session.Clear()
session.Save() if err := session.Save(); err != nil {
log.Error("unable to save session")
}
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Refresh // Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session") log.Info("Refreshing Session")
api.setSession(session, auth) if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
} }
// Authorized // Authorized
@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(api.db.Ctx) users, err := qtx.GetUsers(api.db.Ctx)
@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
// Update users // Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users { for _, user := range users {
// Generate Auth Hash // Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64) rawAuthHash, err := utils.GenerateToken(64)
@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
// Update Cache // Save New Hash Cache
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
} }
// Commit Transaction // Commit Transaction
@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
return nil // Transaction Succeeded -> Update Cache
} for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
func (api *API) createUser(username string, rawPassword string) error {
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if username == "" {
return fmt.Errorf("username can't be empty")
}
if rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
// Generate auth hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
return fmt.Errorf("unable to get current users")
}
// Determine if we should be admin
isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: isAdmin,
}); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", username)
return fmt.Errorf("user already exists")
} }
return nil return nil

View File

@ -2,11 +2,12 @@ package api
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
return return
} }
// Derive Basepath
basepath := filepath.Join(api.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location // Derive Storage Location
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath) filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists // Validate File Exists
_, err = os.Stat(filePath) _, err = os.Stat(filePath)

View File

@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap) allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
// Upsert Documents // Upsert Documents
@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
// Upsert Documents // Upsert Documents
@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return return
} }
missingDocs := []database.Document{}
deletedDocIDs := []string{}
// Get Missing Documents // Get Missing Documents
missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetMissingDocuments DB Error", err) log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
} }
// Get Deleted Documents // Get Deleted Documents
deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetDeletedDocuments DB Error", err) log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -494,7 +499,8 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
}) })
// Generate Storage Path // Generate Storage Path
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save & Prevent Overwrites // Save & Prevent Overwrites
_, err = os.Stat(safePath) _, err = os.Stat(safePath)
@ -521,6 +527,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
Md5: metadataInfo.MD5, Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount, Words: metadataInfo.WordCount,
Filepath: &fileName, Filepath: &fileName,
Basepath: &basePath,
}); err != nil { }); err != nil {
log.Error("UpsertDocument DB Error:", err) log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Document Error") apiErrorPage(c, http.StatusBadRequest, "Document Error")

View File

@ -108,11 +108,11 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv
return graph.GetSVGGraphData(intData, svgWidth, svgHeight) return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
} }
func dict(values ...interface{}) (map[string]interface{}, error) { func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 { if len(values)%2 != 0 {
return nil, errors.New("invalid dict call") return nil, errors.New("invalid dict call")
} }
dict := make(map[string]interface{}, len(values)/2) dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 { for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string) key, ok := values[i].(string)
if !ok { if !ok {
@ -123,12 +123,12 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
return dict, nil return dict, nil
} }
func fields(value interface{}) (map[string]interface{}, error) { func fields(value any) (map[string]any, error) {
v := reflect.Indirect(reflect.ValueOf(value)) v := reflect.Indirect(reflect.ValueOf(value))
if v.Kind() != reflect.Struct { if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("%T is not a struct", value) return nil, fmt.Errorf("%T is not a struct", value)
} }
m := make(map[string]interface{}) m := make(map[string]any)
t := v.Type() t := v.Type()
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {
sv := t.Field(i) sv := t.Field(i)
@ -137,6 +137,10 @@ func fields(value interface{}) (map[string]interface{}, error) {
return m, nil return m, nil
} }
func slice(elements ...any) []any {
return elements
}
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName // Derive New FileName
var newFileName string var newFileName string
@ -155,3 +159,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
fileName := strings.ReplaceAll(newFileName, "/", "") fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
} }
func importStatusPriority(status importStatus) int {
switch status {
case importFailed:
return 1
case importExists:
return 2
default:
return 3
}
}

File diff suppressed because one or more lines are too long

View File

@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [
// ----------------------- Helpers ----------------------- // // ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- // // ------------------------------------------------------- //
function purgeCache() { async function purgeCache() {
console.log("[purgeCache] Purging Cache"); console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) { return caches.keys().then(function (names) {
for (let name of names) caches.delete(name); for (let name of names) caches.delete(name);
@ -136,7 +136,7 @@ async function handleFetch(event) {
const directive = ROUTES.find( const directive = ROUTES.find(
(item) => (item) =>
(item.route instanceof RegExp && url.match(item.route)) || (item.route instanceof RegExp && url.match(item.route)) ||
url == item.route url == item.route,
) || { type: CACHE_NEVER }; ) || { type: CACHE_NEVER };
// Get Fallback // Get Fallback
@ -161,11 +161,11 @@ async function handleFetch(event) {
); );
case CACHE_UPDATE_SYNC: case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch( return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event) (e) => currentCache || fallbackFunc(event),
); );
case CACHE_UPDATE_ASYNC: case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) => let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event) fallbackFunc(event),
); );
return currentCache || newResponse; return currentCache || newResponse;
@ -192,7 +192,7 @@ function handleMessage(event) {
.filter( .filter(
(item) => (item) =>
item.startsWith("/documents/") || item.startsWith("/documents/") ||
item.startsWith("/reader/progress/") item.startsWith("/reader/progress/"),
); );
// Derive Unique IDs // Derive Unique IDs
@ -200,8 +200,8 @@ function handleMessage(event) {
new Set( new Set(
docResources docResources
.filter((item) => item.startsWith("/documents/")) .filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2]) .map((item) => item.split("/")[2]),
) ),
); );
/** /**
@ -214,14 +214,14 @@ function handleMessage(event) {
.filter( .filter(
(id) => (id) =>
docResources.includes("/documents/" + id + "/file") && docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/reader/progress/" + id) docResources.includes("/reader/progress/" + id),
) )
.map(async (id) => { .map(async (id) => {
let url = "/reader/progress/" + id; let url = "/reader/progress/" + id;
let currentCache = await caches.match(url); let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache); let resp = await updateCache(url).catch((e) => currentCache);
return resp.json(); return resp.json();
}) }),
); );
event.source.postMessage({ id, data: cachedDocuments }); event.source.postMessage({ id, data: cachedDocuments });
@ -233,7 +233,7 @@ function handleMessage(event) {
Promise.all([ Promise.all([
cache.delete("/documents/" + data.id + "/file"), cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id), cache.delete("/reader/progress/" + data.id),
]) ]),
) )
.then(() => event.source.postMessage({ id, data: "SUCCESS" })) .then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" })); .catch(() => event.source.postMessage({ id, data: "FAILURE" }));

View File

@ -0,0 +1,38 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upImportBasepath, downImportBasepath)
}
func upImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Determine if we have a new DB or not
isNew := ctx.Value("isNew").(bool)
if isNew {
return nil
}
// Add basepath column
_, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`)
if err != nil {
return err
}
// This code is executed when the migration is applied.
return nil
}
func downImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Drop basepath column
_, err := tx.Exec("ALTER documents DROP COLUMN basepath;")
if err != nil {
return err
}
return nil
}

View File

@ -30,6 +30,7 @@ type Device struct {
type Document struct { type Document struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`

View File

@ -396,6 +396,7 @@ RETURNING *;
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@ -410,10 +411,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),

View File

@ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
} }
const getDocument = `-- name: GetDocument :one const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
` `
@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
} }
const getDocuments = `-- name: GetDocuments :many const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?2 LIMIT ?2
OFFSET ?1 OFFSET ?1
@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
} }
const getMissingDocuments = `-- name: GetMissingDocuments :many const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE WHERE
documents.filepath IS NOT NULL documents.filepath IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@ -1339,10 +1343,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),
@ -1356,12 +1361,13 @@ SET
gbid = COALESCE(excluded.gbid, gbid), gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13) isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpsertDocumentParams struct { type UpsertDocumentParams struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`
@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
row := q.db.QueryRowContext(ctx, upsertDocument, row := q.db.QueryRowContext(ctx, upsertDocument,
arg.ID, arg.ID,
arg.Md5, arg.Md5,
arg.Basepath,
arg.Filepath, arg.Filepath,
arg.Coverfile, arg.Coverfile,
arg.Title, arg.Title,
@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,

View File

@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
md5 TEXT, md5 TEXT,
basepath TEXT,
filepath TEXT, filepath TEXT,
coverfile TEXT, coverfile TEXT,
title TEXT, title TEXT,

View File

@ -294,5 +294,3 @@ INNER JOIN
ON ga.document_id = d.id ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC; ORDER BY total_wpm DESC;

1
go.mod
View File

@ -49,6 +49,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect

View File

@ -19,6 +19,10 @@ sql:
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true
- column: "documents.basepath"
go_type:
type: "string"
pointer: true
- column: "documents.coverfile" - column: "documents.coverfile"
go_type: go_type:
type: "string" type: "string"

View File

@ -54,6 +54,19 @@
display: none; display: none;
} }
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */ /* ----------------------------- */
/* ------- User Dropdown ------- */ /* ------- User Dropdown ------- */
/* ----------------------------- */ /* ----------------------------- */

View File

@ -0,0 +1,14 @@
<!-- Variant -->
{{ $baseClass := "transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white" }}
{{ if eq .Variant "Secondary" }}
{{ $baseClass = printf "bg-black shadow-md hover:text-black hover:bg-white %s" $baseClass }}
{{ else }}
{{ $baseClass = printf "bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 %s" $baseClass }}
{{ end }}
<!-- Type -->
{{ if eq .Type "Link" }}
<a href="{{ .URL }}" class="text-center {{ $baseClass }}" type="submit">{{ .Title }}</a>
{{ else }}
<button class="{{ $baseClass }}" type="submit" {{ if .FormName }} form="{{ .FormName}}" {{ end }}>{{ .Title }}
</button>
{{ end }}

View File

@ -0,0 +1,43 @@
<div class="w-full relative">
<div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded">
<div class="min-w-fit my-auto h-48 relative">
<a href="./documents/{{.ID}}">
<img class="rounded object-cover h-full" src="./documents/{{.ID}}/cover" />
</a>
</div>
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Title</p>
<p class="font-medium">{{ or .Title "Unknown" }}</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium">{{ or .Author "Unknown" }}</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">{{ .Percentage }}%</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Time Read</p>
<p class="font-medium">{{ niceSeconds .TotalTimeSeconds }}</p>
</div>
</div>
</div>
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
<a href="./activity?document={{ .ID }}">{{ template "svg/activity" }}</a>
{{ if .Filepath }}
<a href="./documents/{{.ID}}/file">{{ template "svg/download" }}</a>
{{ else }}
{{ template "svg/download" (dict "Disabled" true) }}
{{ end }}
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
{{ if .Link }}<a href="{{ .Link }}" {{ else }} <div {{ end }}class="w-full">
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<p class="text-2xl font-bold text-black dark:text-white">{{ .Size }}</p>
<p class="text-sm text-gray-400">{{ .Title }}</p>
</div>
</div>
{{ if .Link }}
</a>
{{ else }}
</div>
{{ end }}

View File

@ -0,0 +1,20 @@
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>{{ .Title }}</p>
<label class="my-auto cursor-pointer" for="edit-{{ .FormValue }}-button">
{{ template "svg/edit" (dict "Size" 18) }}
</label>
<input type="checkbox"
id="edit-{{ .FormValue }}-button"
class="hidden css-button" />
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST"
action="{{ .URL }}"
class="flex flex-col gap-2 text-black dark:text-white text-sm">
<input type="text" id="{{ .FormValue }}" name="{{ .FormValue }}" value="{{ or .Value "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
{{ template "component/button" (dict "Title" "Save") }}
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Value "N/A" }}</p>
</div>

View File

@ -0,0 +1,102 @@
{{ if .Error }}
<div class="absolute top-0 left-0 w-full h-full z-50">
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
<div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
<div class="text-center">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
</div>
{{ template "component/button" (dict
"Title" "Back to Document"
"Type" "Link"
"URL" (printf "/documents/%s" .ID)
)}}
</div>
</div>
{{ end }}
{{ if .Metadata }}
<div class="absolute top-0 left-0 w-full h-full z-50">
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
<div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
<div class="py-5 text-center">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3>
</div>
<form id="metadata-save"
method="POST"
action="/documents/{{ .ID }}/edit"
class="text-black dark:text-white border-b dark:border-black">
<dl>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Cover</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
<img class="rounded object-fill h-32"
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690" />
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Title</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.Title "N/A" }}
</dd>
</div>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Author</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.Author "N/A" }}
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">ISBN 10</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.ISBN10 "N/A" }}
</dd>
</div>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">ISBN 13</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.ISBN13 "N/A" }}
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6">
<dt class="my-auto font-medium text-gray-500">Description</dt>
<dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2">
{{ or .Metadata.Description "N/A" }}
</dd>
</div>
</dl>
<div class="hidden">
<input type="text" id="title" name="title" value="{{ .Metadata.Title }}" />
<input type="text" id="author" name="author" value="{{ .Metadata.Author }}" />
<input type="text"
id="description"
name="description"
value="{{ .Metadata.Description }}" />
<input type="text"
id="isbn_10"
name="isbn_10"
value="{{ .Metadata.ISBN10 }}" />
<input type="text"
id="isbn_13"
name="isbn_13"
value="{{ .Metadata.ISBN13 }}" />
<input type="text"
id="cover_gbid"
name="cover_gbid"
value="{{ .Metadata.ID }}" />
</div>
</form>
<div class="flex justify-end">
<div class="flex gap-4 m-4 w-48">
{{ template "component/button" (dict
"Title" "Cancel"
"Type" "Link"
"URL" (printf "/documents/%s" .ID)
)}}
{{ template "component/button" (dict
"Title" "Save"
"FormName" "metadata-save"
)}}
</div>
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,39 @@
<div class="w-full">
<div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
{{ if eq .Window "WEEK" }}
Weekly Read Streak
{{ else }}
Daily Read Streak
{{ end }}
</p>
<div class="flex items-end my-6 space-x-2">
<p class="text-5xl font-bold text-black dark:text-white">{{ .CurrentStreak }}</p>
</div>
<div class="dark:text-white">
<div class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
<div>
<p>
{{ if eq .Window "WEEK" }} Current Weekly Streak {{ else }}
Current Daily Streak {{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">{{ .CurrentStreakStartDate }} ➞ {{ .CurrentStreakEndDate }}</div>
</div>
<div class="flex items-end font-bold">{{ .CurrentStreak }}</div>
</div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>
{{ if eq .Window "WEEK" }}
Best Weekly Streak
{{ else }}
Best Daily Streak
{{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">{{ .MaxStreakStartDate }} ➞ {{ .MaxStreakEndDate }}</div>
</div>
<div class="flex items-end font-bold">{{ .MaxStreak }}</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,28 @@
{{ $rows := .Rows }}
{{ $cols := .Columns }}
{{ $keys := .Keys }}
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400">
<tr>
{{ range $col := $cols }}
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">{{ $col }}</th>
{{ end }}
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not $rows }}
<tr>
<td class="text-center p-3" colspan="4">No Results</td>
</tr>
{{ end }}
{{ range $row := $rows }}
<tr>
{{ range $key := $keys }}
<td class="p-3 border-b border-gray-200">
<p>{{ index (fields $row) $key }}</p>
</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>

View File

@ -0,0 +1,46 @@
{{ template "base" . }}
{{ define "title" }}Admin - Import Results{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import Results</a>{{ end }}
{{ define "content" }}
<div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400">
<tr>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Status</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Error</th>
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not .Data }}
<tr>
<td class="text-center p-3" colspan="4">No Results</td>
</tr>
{{ end }}
{{ range $result := .Data }}
<tr>
<td class="p-3 border-b border-gray-200 grid"
style="grid-template-columns: 4rem auto">
<span class="text-gray-800 dark:text-gray-400">Name:</span>
{{ if (eq $result.ID "") }}
<span>N/A</span>
{{ else }}
<a href="../documents/{{ $result.ID }}">{{ $result.Name }}</a>
{{ end }}
<span class="text-gray-800 dark:text-gray-400">File:</span>
<span>{{ $result.Path }}</span>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Status }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Error }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

View File

@ -19,12 +19,12 @@
</div> </div>
<div class="flex flex-col justify-around gap-2 mr-4"> <div class="flex flex-col justify-around gap-2 mr-4">
<div class="inline-flex gap-2 items-center"> <div class="inline-flex gap-2 items-center">
<input checked type="radio" id="copy" name="type" value="COPY" /> <input checked type="radio" id="direct" name="type" value="DIRECT" />
<label for="copy">Copy</label> <label for="direct">Direct</label>
</div> </div>
<div class="inline-flex gap-2 items-center"> <div class="inline-flex gap-2 items-center">
<input type="radio" id="direct" name="type" value="DIRECT" /> <input type="radio" id="copy" name="type" value="COPY" />
<label for="direct">Direct</label> <label for="copy">Copy</label>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,10 +17,12 @@
placeholder="JQ Filter" /> placeholder="JQ Filter" />
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Filter</span> "Title" "Filter"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
</div> </div>
<!-- Required for iOS "Hover" Events (onclick) --> <!-- Required for iOS "Hover" Events (onclick) -->

View File

@ -35,6 +35,7 @@
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label> <label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
</th> </th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center"> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
Permissions Permissions
</th> </th>
@ -55,6 +56,33 @@
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p> <p>{{ $user.ID }}</p>
</td> </td>
<td class="border-b border-gray-200 relative px-3">
<label for="edit-{{ $user.ID }}-button" class="cursor-pointer">
<span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Reset</span>
</label>
<input type="checkbox"
id="edit-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 -bottom-1.5 left-16 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST"
action="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="text"
id="operation"
name="operation"
value="UPDATE"
class="hidden" />
<input type="password"
id="password"
name="password"
placeholder="Password"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Change</button>
</form>
</div>
</td>
<td class="p-3 border-b border-gray-200 text-center min-w-40"> <td class="p-3 border-b border-gray-200 text-center min-w-40">
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span> <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span>
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span> <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span>

View File

@ -21,10 +21,12 @@
<label for="backup_documents">Documents</label> <label for="backup_documents">Documents</label>
</div> </div>
</div> </div>
<button type="submit" <div class="w-40 h-10">
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Backup</span> "Title" "Backup"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
<form method="POST" <form method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
@ -34,10 +36,12 @@
<div class="flex items-center w-1/2"> <div class="flex items-center w-1/2">
<input type="file" accept=".zip" name="restore_file" class="w-full" /> <input type="file" accept=".zip" name="restore_file" class="w-full" />
</div> </div>
<button type="submit" <div class="w-40 h-10">
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Restore</span> "Title" "Restore"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
</div> </div>
{{ if .PasswordErrorMessage }} {{ if .PasswordErrorMessage }}
@ -57,10 +61,12 @@
<td class="py-2 float-right"> <td class="py-2 float-right">
<form action="./admin" method="POST"> <form action="./admin" method="POST">
<input type="text" name="action" value="METADATA_MATCH" class="hidden" /> <input type="text" name="action" value="METADATA_MATCH" class="hidden" />
<button type="submit" <div class="w-40 h-10 text-base">
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Run</span> "Title" "Run"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
</td> </td>
</tr> </tr>
@ -71,10 +77,12 @@
<td class="py-2 float-right"> <td class="py-2 float-right">
<form action="./admin" method="POST"> <form action="./admin" method="POST">
<input type="text" name="action" value="CACHE_TABLES" class="hidden" /> <input type="text" name="action" value="CACHE_TABLES" class="hidden" />
<button type="submit" <div class="w-40 h-10 text-base">
class="w-40 px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Run</span> "Title" "Run"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
</td> </td>
</tr> </tr>

View File

@ -33,8 +33,7 @@
action="./{{ .Data.ID }}/edit" action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
<input type="file" id="cover_file" name="cover_file"> <input type="file" id="cover_file" name="cover_file">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" {{ template "component/button" (dict "Title" "Upload Cover") }}
type="submit">Upload Cover</button>
</form> </form>
<form method="POST" <form method="POST"
action="./{{ .Data.ID }}/edit" action="./{{ .Data.ID }}/edit"
@ -44,8 +43,7 @@
id="remove_cover" id="remove_cover"
name="remove_cover" name="remove_cover"
class="hidden" /> class="hidden" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" {{ template "component/button" (dict "Title" "Remove Cover") }}
type="submit">Remove Cover</button>
</form> </form>
</div> </div>
<div class="relative"> <div class="relative">
@ -54,9 +52,8 @@
<div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> <div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST" <form method="POST"
action="./{{ .Data.ID }}/delete" action="./{{ .Data.ID }}/delete"
class="text-black dark:text-white text-sm"> class="text-black dark:text-white text-sm w-24">
<button class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" {{ template "component/button" (dict "Title" "Delete") }}
type="submit">Delete</button>
</form> </form>
</div> </div>
</div> </div>
@ -86,8 +83,7 @@
placeholder="ISBN 10 / ISBN 13" placeholder="ISBN 10 / ISBN 13"
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}" value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" {{ template "component/button" (dict "Title" "Identify") }}
type="submit">Identify</button>
</form> </form>
</div> </div>
</div> </div>
@ -100,40 +96,18 @@
</div> </div>
</div> </div>
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4"> <div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
<div class="relative"> {{ template "component/key-val-edit" (dict
<div class="text-gray-500 inline-flex gap-2 relative"> "Title" "Title"
<p>Title</p> "Value" .Data.Title
<label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label> "URL" (printf "./%s/edit" .Data.ID)
<input type="checkbox" id="edit-title-button" class="hidden css-button" /> "FormValue" "title"
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> )}}
<form method="POST" {{ template "component/key-val-edit" (dict
action="./{{ .Data.ID }}/edit" "Title" "Author"
class="flex flex-col gap-2 text-black dark:text-white text-sm"> "Value" .Data.Author
<input type="text" id="title" name="title" value="{{ or .Data.Title "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> "URL" (printf "./%s/edit" .Data.ID)
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" "FormValue" "author"
type="submit">Save</button> )}}
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Data.Title "N/A" }}</p>
</div>
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>Author</p>
<label class="my-auto" for="edit-author-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
<input type="checkbox" id="edit-author-button" class="hidden css-button" />
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 text-black dark:text-white text-sm">
<input type="text" id="author" name="author" value="{{ or .Data.Author "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Data.Author "N/A" }}</p>
</div>
<div class="relative"> <div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative"> <div class="text-gray-500 inline-flex gap-2 relative">
<p>Time Read</p> <p>Time Read</p>
@ -181,119 +155,16 @@
id="description" id="description"
name="description" name="description"
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">{{ or .Data.Description "N/A" }}</textarea> class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">{{ or .Data.Description "N/A" }}</textarea>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" {{ template "component/button" (dict "Title" "Save") }}
type="submit">Save</button>
</form> </form>
</div> </div>
<p>{{ or .Data.Description "N/A" }}</p> <p>{{ or .Data.Description "N/A" }}</p>
</div> </div>
</div> </div>
{{ if .MetadataError }} {{ template "component/metadata" (dict
<div class="absolute top-0 left-0 w-full h-full z-50"> "ID" .Data.ID
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> "Metadata" .Metadata
<div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded"> "Error" .MetadataError
<div class="text-center"> )}}
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
</div>
<a href="/documents/{{ .Data.ID }}"
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Back to Document</a>
</div>
</div>
{{ end }}
<!-- Metadata Info -->
{{ if .Metadata }}
<div class="absolute top-0 left-0 w-full h-full z-50">
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
<div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
<div class="py-5 text-center">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3>
</div>
<form id="metadata-save"
method="POST"
action="/documents/{{ .Data.ID }}/edit"
class="text-black dark:text-white border-b dark:border-black">
<dl>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Cover</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
<img class="rounded object-fill h-32"
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690" />
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Title</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.Title "N/A" }}
</dd>
</div>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">Author</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.Author "N/A" }}
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">ISBN 10</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.ISBN10 "N/A" }}
</dd>
</div>
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
<dt class="my-auto font-medium text-gray-500">ISBN 13</dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
{{ or .Metadata.ISBN13 "N/A" }}
</dd>
</div>
<div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6">
<dt class="my-auto font-medium text-gray-500">Description</dt>
<dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2">
{{ or .Metadata.Description "N/A" }}
</dd>
</div>
</dl>
<div class="hidden">
<input type="text" id="title" name="title" value="{{ .Metadata.Title }}">
<input type="text" id="author" name="author" value="{{ .Metadata.Author }}">
<input type="text"
id="description"
name="description"
value="{{ .Metadata.Description }}">
<input type="text"
id="isbn_10"
name="isbn_10"
value="{{ .Metadata.ISBN10 }}">
<input type="text"
id="isbn_13"
name="isbn_13"
value="{{ .Metadata.ISBN13 }}">
<input type="text"
id="cover_gbid"
name="cover_gbid"
value="{{ .Metadata.ID }}">
</div>
</form>
<div class="flex justify-end gap-4 m-4">
<a href="/documents/{{ .Data.ID }}"
class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Cancel</a>
<button form="metadata-save"
class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Save</button>
</div>
</div>
</div>
{{ end }}
</div> </div>
<style>
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
</style>
{{ end }} {{ end }}

View File

@ -18,58 +18,17 @@
placeholder="Search Author / Title" /> placeholder="Search Author / Title" />
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Search</span> "Title" "Search"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ range $doc := .Data }} {{ range $doc := .Data }}
<div class="w-full relative"> {{ template "component/document-card" $doc }}
<div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded">
<div class="min-w-fit my-auto h-48 relative">
<a href="./documents/{{$doc.ID}}">
<img class="rounded object-cover h-full"
src="./documents/{{$doc.ID}}/cover" />
</a>
</div>
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Title</p>
<p class="font-medium">{{ or $doc.Title "Unknown" }}</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium">{{ or $doc.Author "Unknown" }}</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">{{ $doc.Percentage }}%</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Time Read</p>
<p class="font-medium">{{ niceSeconds $doc.TotalTimeSeconds }}</p>
</div>
</div>
</div>
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
<a href="./activity?document={{ $doc.ID }}">{{ template "svg/activity" }}</a>
{{ if $doc.Filepath }}
<a href="./documents/{{$doc.ID}}/file">{{ template "svg/download" }}</a>
{{ else }}
{{ template "svg/download" (dict "Disabled" true) }}
{{ end }}
</div>
</div>
</div>
{{ end }} {{ end }}
</div> </div>
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white"> <div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
@ -89,7 +48,7 @@
enctype="multipart/form-data" enctype="multipart/form-data"
action="./documents" action="./documents"
class="flex flex-col gap-2"> class="flex flex-col gap-2">
<input type="file" accept=".epub" id="document_file" name="document_file"> <input type="file" accept=".epub" id="document_file" name="document_file" />
<button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800" <button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
type="submit">Upload File</button> type="submit">Upload File</button>
</form> </form>
@ -102,19 +61,4 @@
<label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer" <label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label> for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label>
</div> </div>
<style>
.css-button:checked+div {
display: block;
opacity: 1;
}
.css-button+div {
display: none;
opacity: 0;
}
.css-button:checked+div+label {
display: none;
}
</style>
{{ end }} {{ end }}

View File

@ -41,82 +41,29 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4"> <div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<a href="./documents" class="w-full"> {{ template "component/info-card" (dict
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> "Title" "Documents"
<div class="flex flex-col justify-around dark:text-white w-full text-sm"> "Size" .Data.DatabaseInfo.DocumentsSize
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DocumentsSize }}</p> "Link" "./documents"
<p class="text-sm text-gray-400">Documents</p> )}}
</div> {{ template "component/info-card" (dict
</div> "Title" "Activity Records"
</a> "Size" .Data.DatabaseInfo.ActivitySize
<a href="./activity" class="w-full"> "Link" "./activity"
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> )}}
<div class="flex flex-col justify-around dark:text-white w-full text-sm"> {{ template "component/info-card" (dict
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ActivitySize }}</p> "Title" "Progress Records"
<p class="text-sm text-gray-400">Activity Records</p> "Size" .Data.DatabaseInfo.ProgressSize
</div> "Link" "./progress"
</div> )}}
</a> {{ template "component/info-card" (dict
<a href="./progress" class="w-full"> "Title" "Devices"
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> "Size" .Data.DatabaseInfo.DevicesSize
<div class="flex flex-col justify-around dark:text-white w-full text-sm"> )}}
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ProgressSize }}</p>
<p class="text-sm text-gray-400">Progress Records</p>
</div>
</div>
</a>
<div class="w-full">
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DevicesSize }}</p>
<p class="text-sm text-gray-400">Devices</p>
</div>
</div>
</div>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{{ range $item := .Data.Streaks }} {{ range $item := .Data.Streaks }}
<div class="w-full"> {{ template "component/streak-card" $item }}
<div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
{{ if eq $item.Window "WEEK" }}
Weekly Read Streak
{{ else }}
Daily Read Streak
{{ end }}
</p>
<div class="flex items-end my-6 space-x-2">
<p class="text-5xl font-bold text-black dark:text-white">{{ $item.CurrentStreak }}</p>
</div>
<div class="dark:text-white">
<div class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
<div>
<p>
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
Current Daily Streak {{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">
{{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div>
</div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>
{{ if eq $item.Window "WEEK" }}
Best Weekly Streak
{{ else }}
Best Daily Streak
{{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}</div>
</div>
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
</div>
</div>
</div>
</div>
{{ end }} {{ end }}
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">

View File

@ -39,6 +39,13 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
<!--
{{ template "component/table" (dict
"Columns" (slice "Author" "Title" "Device Name" "Percentage" "Created At")
"Keys" (slice "Author" "Title" "DeviceName" "Percentage" "CreatedAt")
"Rows" .Data
)}}
-->
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@ -30,10 +30,12 @@
<option value="LibGen Non-fiction">LibGen Non-fiction</option> <option value="LibGen Non-fiction">LibGen Non-fiction</option>
</select> </select>
</div> </div>
<button type="submit" <div class="lg:w-60">
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Search</span> "Title" "Search"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
{{ if .SearchErrorMessage }} {{ if .SearchErrorMessage }}
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span> <span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>

View File

@ -39,10 +39,12 @@
placeholder="New Password" /> placeholder="New Password" />
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Submit</span> "Title" "Submit"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
{{ if .PasswordErrorMessage }} {{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span> <span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
@ -69,10 +71,12 @@
{{ end }} {{ end }}
</select> </select>
</div> </div>
<button type="submit" <div class="lg:w-60">
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"> {{ template "component/button" (dict
<span class="w-full">Submit</span> "Title" "Submit"
</button> "Variant" "Secondary"
) }}
</div>
</form> </form>
{{ if .TimeOffsetErrorMessage }} {{ if .TimeOffsetErrorMessage }}
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span> <span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>