[add] better error handling, [add] font selector, [add] tailwind generation
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2023-10-25 19:52:01 -04:00
parent 3577dd89a0
commit cdec621043
25 changed files with 2792 additions and 241 deletions

View File

@ -1,4 +1,4 @@
build_local: build_local: build_tailwind
go mod download go mod download
rm -r ./build rm -r ./build
mkdir -p ./build mkdir -p ./build
@ -10,17 +10,17 @@ build_local:
env GOOS=darwin GOARCH=arm64 go build -o ./build/server_darwin_arm64 env GOOS=darwin GOARCH=arm64 go build -o ./build/server_darwin_arm64
env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64 env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64
docker_build_local: docker_build_local: build_tailwind
docker build -t bookmanager:latest . docker build -t bookmanager:latest .
docker_build_release_dev: docker_build_release_dev: build_tailwind
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:dev \ -t gitea.va.reichard.io/evan/bookmanager:dev \
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
docker_build_release_latest: docker_build_release_latest: build_tailwind
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:latest \ -t gitea.va.reichard.io/evan/bookmanager:latest \
@ -28,6 +28,13 @@ docker_build_release_latest:
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
build_tailwind:
tailwind build -o ./assets/style.css
clean:
rm -rf ./build
tests_integration: tests_integration:
go test -v -tags=integration -coverpkg=./... ./metadata go test -v -tags=integration -coverpkg=./... ./metadata

View File

@ -78,6 +78,7 @@ func (api *API) registerWebAppRoutes() {
"NiceSeconds": niceSeconds, "NiceSeconds": niceSeconds,
} }
render.AddFromFiles("error", "templates/error.html")
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html") render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html") render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html")
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html") render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
@ -101,9 +102,10 @@ func (api *API) registerWebAppRoutes() {
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings) api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity")) api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents")) api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document")) api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader) api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile) api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover) api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument) api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument) api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
@ -127,8 +129,8 @@ func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments) koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync) koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadDocumentFile) koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocumentFile) koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities) koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync) koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
@ -139,7 +141,7 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments) opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription) opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocumentFile) opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover) opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
} }

View File

@ -19,6 +19,7 @@ import (
"reichard.io/bbank/database" "reichard.io/bbank/database"
"reichard.io/bbank/metadata" "reichard.io/bbank/metadata"
"reichard.io/bbank/search" "reichard.io/bbank/search"
"reichard.io/bbank/utils"
) )
type queryParams struct { type queryParams struct {
@ -32,6 +33,10 @@ type searchParams struct {
BookType *string `form:"book_type"` BookType *string `form:"book_type"`
} }
type requestDocumentUpload struct {
DocumentFile *multipart.FileHeader `form:"document_file"`
}
type requestDocumentEdit struct { type requestDocumentEdit struct {
Title *string `form:"title"` Title *string `form:"title"`
Author *string `form:"author"` Author *string `form:"author"`
@ -100,7 +105,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}) })
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetDocumentsWithStats DB Error:", err) log.Error("[createAppResourcesRoute] GetDocumentsWithStats DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
return return
} }
@ -113,7 +118,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[createAppResourcesRoute] Invalid URI Bind") log.Error("[createAppResourcesRoute] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
@ -123,11 +128,10 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}) })
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetDocumentWithStats DB Error:", err) log.Error("[createAppResourcesRoute] GetDocumentWithStats DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
return return
} }
templateVars["RelBase"] = "../"
templateVars["Data"] = document templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage
} else if routeName == "activity" { } else if routeName == "activity" {
@ -145,7 +149,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, activityFilter) activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, activityFilter)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetActivity DB Error:", err) log.Error("[createAppResourcesRoute] GetActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
return return
} }
@ -172,14 +176,14 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID) user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetUser DB Error:", err) log.Error("[createAppResourcesRoute] GetUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return return
} }
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID) devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetDevices DB Error:", err) log.Error("[createAppResourcesRoute] GetDevices DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return return
} }
@ -221,7 +225,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
var rDoc requestDocumentID var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil { if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[getDocumentCover] Invalid URI Bind") log.Error("[getDocumentCover] Invalid URI Bind")
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusNotFound, "Invalid cover.")
return return
} }
@ -229,7 +233,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("[getDocumentCover] GetDocument DB Error:", err) log.Error("[getDocumentCover] GetDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return return
} }
@ -314,7 +318,7 @@ func (api *API) documentReader(c *gin.Context) {
var rDoc requestDocumentID var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil { if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[documentReader] Invalid URI Bind") log.Error("[documentReader] Invalid URI Bind")
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
@ -325,7 +329,7 @@ func (api *API) documentReader(c *gin.Context) {
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Error("[documentReader] UpsertDocument DB Error:", err) log.Error("[documentReader] UpsertDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return return
} }
@ -335,7 +339,7 @@ func (api *API) documentReader(c *gin.Context) {
}) })
if err != nil { if err != nil {
log.Error("[documentReader] GetDocumentWithStats DB Error:", err) log.Error("[documentReader] GetDocumentWithStats DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return return
} }
@ -343,22 +347,152 @@ func (api *API) documentReader(c *gin.Context) {
"SearchEnabled": api.Config.SearchEnabled, "SearchEnabled": api.Config.SearchEnabled,
"Progress": progress.Progress, "Progress": progress.Progress,
"Data": document, "Data": document,
"RelBase": "../../",
}) })
} }
func (api *API) uploadNewDocument(c *gin.Context) {
var rDocUpload requestDocumentUpload
if err := c.ShouldBind(&rDocUpload); err != nil {
log.Error("[uploadNewDocument] Invalid Form Bind")
errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return
}
if rDocUpload.DocumentFile == nil {
c.Redirect(http.StatusFound, "./documents")
return
}
// Validate Type & Derive Extension on MIME
uploadedFile, err := rDocUpload.DocumentFile.Open()
if err != nil {
log.Error("[uploadNewDocument] File Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to open file.")
return
}
fileMime, err := mimetype.DetectReader(uploadedFile)
if err != nil {
log.Error("[uploadNewDocument] MIME Error")
errorPage(c, http.StatusInternalServerError, "Unable to detect filetype.")
return
}
fileExtension := fileMime.Extension()
// Validate Extension
if !slices.Contains([]string{".epub"}, fileExtension) {
log.Error("[uploadNewDocument] Invalid FileType: ", fileExtension)
errorPage(c, http.StatusBadRequest, "Invalid filetype.")
return
}
// Create Temp File
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Warn("[uploadNewDocument] Temp File Create Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
return
}
defer tempFile.Close()
// Save Temp
err = c.SaveUploadedFile(rDocUpload.DocumentFile, tempFile.Name())
if err != nil {
log.Error("[uploadNewDocument] File Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
// Get Metadata
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
if err != nil {
log.Warn("[uploadNewDocument] GetMetadata Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to acquire file metadata.")
return
}
// Calculate Partial MD5 ID
partialMD5, err := utils.CalculatePartialMD5(tempFile.Name())
if err != nil {
log.Warn("[uploadNewDocument] Partial MD5 Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to calculate partial MD5.")
return
}
// Check Exists
_, err = api.DB.Queries.GetDocument(api.DB.Ctx, partialMD5)
if err == nil {
c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", partialMD5))
return
}
// Calculate Actual MD5
fileHash, err := getFileMD5(tempFile.Name())
if err != nil {
log.Error("[uploadNewDocument] MD5 Hash Failure:", err)
errorPage(c, http.StatusInternalServerError, "Unable to calculate MD5.")
return
}
// Derive Filename
var fileName string
if *metadataInfo.Author != "" {
fileName = fileName + *metadataInfo.Author
} else {
fileName = fileName + "Unknown"
}
if *metadataInfo.Title != "" {
fileName = fileName + " - " + *metadataInfo.Title
} else {
fileName = fileName + " - Unknown"
}
// Remove Slashes
fileName = strings.ReplaceAll(fileName, "/", "")
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
// Generate Storage Path
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
// Move File
if err := os.Rename(tempFile.Name(), safePath); err != nil {
log.Error("[uploadNewDocument] Move Temp File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
// Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: partialMD5,
Title: metadataInfo.Title,
Author: metadataInfo.Author,
Description: metadataInfo.Description,
Md5: fileHash,
Filepath: &fileName,
}); err != nil {
log.Error("[uploadNewDocument] UpsertDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return
}
c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", partialMD5))
}
func (api *API) editDocument(c *gin.Context) { func (api *API) editDocument(c *gin.Context) {
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[createAppResourcesRoute] Invalid URI Bind") log.Error("[createAppResourcesRoute] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
var rDocEdit requestDocumentEdit var rDocEdit requestDocumentEdit
if err := c.ShouldBind(&rDocEdit); err != nil { if err := c.ShouldBind(&rDocEdit); err != nil {
log.Error("[createAppResourcesRoute] Invalid Form Bind") log.Error("[createAppResourcesRoute] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -372,7 +506,7 @@ func (api *API) editDocument(c *gin.Context) {
rDocEdit.CoverGBID == nil && rDocEdit.CoverGBID == nil &&
rDocEdit.CoverFile == nil { rDocEdit.CoverFile == nil {
log.Error("[createAppResourcesRoute] Missing Form Values") log.Error("[createAppResourcesRoute] Missing Form Values")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -386,14 +520,14 @@ func (api *API) editDocument(c *gin.Context) {
uploadedFile, err := rDocEdit.CoverFile.Open() uploadedFile, err := rDocEdit.CoverFile.Open()
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] File Error") log.Error("[createAppResourcesRoute] File Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, "Unable to open file.")
return return
} }
fileMime, err := mimetype.DetectReader(uploadedFile) fileMime, err := mimetype.DetectReader(uploadedFile)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] MIME Error") log.Error("[createAppResourcesRoute] MIME Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, "Unable to detect filetype.")
return return
} }
fileExtension := fileMime.Extension() fileExtension := fileMime.Extension()
@ -401,7 +535,7 @@ func (api *API) editDocument(c *gin.Context) {
// Validate Extension // Validate Extension
if !slices.Contains([]string{".jpg", ".png"}, fileExtension) { if !slices.Contains([]string{".jpg", ".png"}, fileExtension) {
log.Error("[uploadDocumentFile] Invalid FileType: ", fileExtension) log.Error("[uploadDocumentFile] Invalid FileType: ", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"}) errorPage(c, http.StatusBadRequest, "Invalid filetype.")
return return
} }
@ -412,8 +546,8 @@ func (api *API) editDocument(c *gin.Context) {
// Save // Save
err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath) err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] File Error") log.Error("[createAppResourcesRoute] File Error: ", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return return
} }
@ -437,7 +571,7 @@ func (api *API) editDocument(c *gin.Context) {
Coverfile: coverFileName, Coverfile: coverFileName,
}); err != nil { }); err != nil {
log.Error("[createAppResourcesRoute] UpsertDocument DB Error:", err) log.Error("[createAppResourcesRoute] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return return
} }
@ -449,18 +583,18 @@ func (api *API) deleteDocument(c *gin.Context) {
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[deleteDocument] Invalid URI Bind") log.Error("[deleteDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
changed, err := api.DB.Queries.DeleteDocument(api.DB.Ctx, rDocID.DocumentID) changed, err := api.DB.Queries.DeleteDocument(api.DB.Ctx, rDocID.DocumentID)
if err != nil { if err != nil {
log.Error("[deleteDocument] DeleteDocument DB Error") log.Error("[deleteDocument] DeleteDocument DB Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("DeleteDocument DB Error: %v", err))
return return
} }
if changed == 0 { if changed == 0 {
log.Error("[deleteDocument] DeleteDocument DB Error") log.Error("[deleteDocument] DeleteDocument DB Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
@ -473,14 +607,14 @@ func (api *API) identifyDocument(c *gin.Context) {
var rDocID requestDocumentID var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil { if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[identifyDocument] Invalid URI Bind") log.Error("[identifyDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusNotFound, "Invalid document.")
return return
} }
var rDocIdentify requestDocumentIdentify var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil { if err := c.ShouldBind(&rDocIdentify); err != nil {
log.Error("[identifyDocument] Invalid Form Bind") log.Error("[identifyDocument] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -498,13 +632,12 @@ func (api *API) identifyDocument(c *gin.Context) {
// Validate Values // Validate Values
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil { if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
log.Error("[identifyDocument] Invalid Form") log.Error("[identifyDocument] Invalid Form")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
// Template Variables // Template Variables
templateVars := gin.H{ templateVars := gin.H{
"RelBase": "../../",
"SearchEnabled": api.Config.SearchEnabled, "SearchEnabled": api.Config.SearchEnabled,
} }
@ -544,7 +677,7 @@ func (api *API) identifyDocument(c *gin.Context) {
}) })
if err != nil { if err != nil {
log.Error("[identifyDocument] GetDocumentWithStats DB Error:", err) log.Error("[identifyDocument] GetDocumentWithStats DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return return
} }
@ -558,7 +691,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
var rDocAdd requestDocumentAdd var rDocAdd requestDocumentAdd
if err := c.ShouldBind(&rDocAdd); err != nil { if err := c.ShouldBind(&rDocAdd); err != nil {
log.Error("[saveNewDocument] Invalid Form Bind") log.Error("[saveNewDocument] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -568,7 +701,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
rDocAdd.Title == nil || rDocAdd.Title == nil ||
rDocAdd.Author == nil { rDocAdd.Author == nil {
log.Error("[saveNewDocument] Missing Form Values") log.Error("[saveNewDocument] Missing Form Values")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -581,15 +714,15 @@ func (api *API) saveNewDocument(c *gin.Context) {
tempFilePath, err := search.SaveBook(*rDocAdd.ID, bType) tempFilePath, err := search.SaveBook(*rDocAdd.ID, bType)
if err != nil { if err != nil {
log.Warn("[saveNewDocument] Temp File Error: ", err) log.Warn("[saveNewDocument] Temp File Error: ", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return return
} }
// Calculate Partial MD5 ID // Calculate Partial MD5 ID
partialMD5, err := calculatePartialMD5(tempFilePath) partialMD5, err := utils.CalculatePartialMD5(tempFilePath)
if err != nil { if err != nil {
log.Warn("[saveNewDocument] Partial MD5 Error: ", err) log.Warn("[saveNewDocument] Partial MD5 Error: ", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, "Unable to calculate partial MD5.")
return return
} }
@ -623,7 +756,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
// Move File // Move File
if err := os.Rename(tempFilePath, safePath); err != nil { if err := os.Rename(tempFilePath, safePath); err != nil {
log.Warn("[saveNewDocument] Move Temp File Error: ", err) log.Warn("[saveNewDocument] Move Temp File Error: ", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return return
} }
@ -631,7 +764,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
fileHash, err := getFileMD5(safePath) fileHash, err := getFileMD5(safePath)
if err != nil { if err != nil {
log.Error("[saveNewDocument] Hash Failure:", err) log.Error("[saveNewDocument] Hash Failure:", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, "Unable to calculate MD5.")
return return
} }
@ -644,7 +777,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
Filepath: &fileName, Filepath: &fileName,
}); err != nil { }); err != nil {
log.Error("[saveNewDocument] UpsertDocument DB Error:", err) log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return return
} }
@ -657,14 +790,14 @@ func (api *API) editSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil { if err := c.ShouldBind(&rUserSettings); err != nil {
log.Error("[editSettings] Invalid Form Bind") log.Error("[editSettings] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
// Validate Something Exists // Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil { if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil {
log.Error("[editSettings] Missing Form Values") log.Error("[editSettings] Missing Form Values")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return return
} }
@ -703,7 +836,7 @@ func (api *API) editSettings(c *gin.Context) {
_, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings) _, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings)
if err != nil { if err != nil {
log.Error("[editSettings] UpdateUser DB Error:", err) log.Error("[editSettings] UpdateUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
return return
} }
@ -711,7 +844,7 @@ func (api *API) editSettings(c *gin.Context) {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string)) user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
if err != nil { if err != nil {
log.Error("[editSettings] GetUser DB Error:", err) log.Error("[editSettings] GetUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return return
} }
@ -719,7 +852,7 @@ func (api *API) editSettings(c *gin.Context) {
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string)) devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
if err != nil { if err != nil {
log.Error("[editSettings] GetDevices DB Error:", err) log.Error("[editSettings] GetDevices DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return return
} }
@ -792,3 +925,24 @@ func bindQueryParams(c *gin.Context) queryParams {
return qParams return qParams
} }
func errorPage(c *gin.Context, errorCode int, errorMessage string) {
var errorHuman string = "We're not even sure what happened."
switch errorCode {
case http.StatusInternalServerError:
errorHuman = "Server hiccup."
case http.StatusNotFound:
errorHuman = "Something's missing."
case http.StatusBadRequest:
errorHuman = "We didn't expect that."
case http.StatusUnauthorized:
errorHuman = "You're not allowed to do that."
}
c.HTML(errorCode, "error", gin.H{
"Status": errorCode,
"Error": errorHuman,
"Message": errorMessage,
})
}

View File

@ -112,6 +112,7 @@ 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) authFormLogin(c *gin.Context) { func (api *API) authFormLogin(c *gin.Context) {
@ -152,7 +153,8 @@ func (api *API) authFormLogin(c *gin.Context) {
func (api *API) authFormRegister(c *gin.Context) { func (api *API) authFormRegister(c *gin.Context) {
if !api.Config.RegistrationEnabled { if !api.Config.RegistrationEnabled {
c.AbortWithStatus(http.StatusConflict) errorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
return
} }
username := strings.TrimSpace(c.PostForm("username")) username := strings.TrimSpace(c.PostForm("username"))
@ -202,7 +204,7 @@ func (api *API) authFormRegister(c *gin.Context) {
// Set Session // Set Session
session := sessions.Default(c) session := sessions.Default(c)
if err := setSession(session, username); err != nil { if err := setSession(session, username); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) errorPage(c, http.StatusUnauthorized, "Unauthorized.")
return return
} }

View File

@ -500,17 +500,17 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
c.JSON(http.StatusOK, rCheckDocSync) c.JSON(http.StatusOK, rCheckDocSync)
} }
func (api *API) uploadDocumentFile(c *gin.Context) { func (api *API) uploadExistingDocument(c *gin.Context) {
var rDoc requestDocumentID var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil { if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[uploadDocumentFile] Invalid URI Bind") log.Error("[uploadExistingDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return return
} }
fileData, err := c.FormFile("file") fileData, err := c.FormFile("file")
if err != nil { if err != nil {
log.Error("[uploadDocumentFile] File Error:", err) log.Error("[uploadExistingDocument] File Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return return
} }
@ -521,7 +521,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
fileExtension := fileMime.Extension() fileExtension := fileMime.Extension()
if !slices.Contains([]string{".epub", ".html"}, fileExtension) { if !slices.Contains([]string{".epub", ".html"}, fileExtension) {
log.Error("[uploadDocumentFile] Invalid FileType:", fileExtension) log.Error("[uploadExistingDocument] Invalid FileType:", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
return return
} }
@ -529,7 +529,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
// Validate Document Exists in DB // Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("[uploadDocumentFile] GetDocument DB Error:", err) log.Error("[uploadExistingDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return return
} }
@ -562,7 +562,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
if os.IsNotExist(err) { if os.IsNotExist(err) {
err = c.SaveUploadedFile(fileData, safePath) err = c.SaveUploadedFile(fileData, safePath)
if err != nil { if err != nil {
log.Error("[uploadDocumentFile] Save Failure:", err) log.Error("[uploadExistingDocument] Save Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return return
} }
@ -571,7 +571,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
// Get MD5 Hash // Get MD5 Hash
fileHash, err := getFileMD5(safePath) fileHash, err := getFileMD5(safePath)
if err != nil { if err != nil {
log.Error("[uploadDocumentFile] Hash Failure:", err) log.Error("[uploadExistingDocument] Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return return
} }
@ -582,7 +582,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
Md5: fileHash, Md5: fileHash,
Filepath: &fileName, Filepath: &fileName,
}); err != nil { }); err != nil {
log.Error("[uploadDocumentFile] UpsertDocument DB Error:", err) log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
return return
} }
@ -592,7 +592,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
ID: document.ID, ID: document.ID,
Synced: true, Synced: true,
}); err != nil { }); err != nil {
log.Error("[uploadDocumentFile] UpdateDocumentSync DB Error:", err) log.Error("[uploadExistingDocument] UpdateDocumentSync DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return return
} }
@ -602,10 +602,10 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
}) })
} }
func (api *API) downloadDocumentFile(c *gin.Context) { func (api *API) downloadDocument(c *gin.Context) {
var rDoc requestDocumentID var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil { if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[downloadDocumentFile] Invalid URI Bind") log.Error("[downloadDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return return
} }
@ -613,13 +613,13 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
// Get Document // Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("[uploadDocumentFile] GetDocument DB Error:", err) log.Error("[downloadDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return return
} }
if document.Filepath == nil { if document.Filepath == nil {
log.Error("[uploadDocumentFile] Document Doesn't Have File:", rDoc.DocumentID) log.Error("[downloadDocument] Document Doesn't Have File:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
return return
} }
@ -630,7 +630,7 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
// Validate File Exists // Validate File Exists
_, err = os.Stat(filePath) _, err = os.Stat(filePath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Error("[uploadDocumentFile] File Doesn't Exist:", rDoc.DocumentID) log.Error("[downloadDocument] File Doesn't Exist:", rDoc.DocumentID)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
return return
} }

View File

@ -1,12 +1,8 @@
package api package api
import ( import (
"bytes"
"crypto/md5"
"fmt" "fmt"
"io"
"math" "math"
"os"
"reichard.io/bbank/database" "reichard.io/bbank/database"
"reichard.io/bbank/graph" "reichard.io/bbank/graph"
@ -90,41 +86,6 @@ func niceSeconds(input int64) (result string) {
return return
} }
// Reimplemented KOReader Partial MD5 Calculation
func calculatePartialMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
var step int64 = 1024
var size int64 = 1024
var buf bytes.Buffer
for i := -1; i <= 10; i++ {
byteStep := make([]byte, size)
var newShift int64 = int64(i * 2)
var newOffset int64
if i == -1 {
newOffset = 0
} else {
newOffset = step << newShift
}
_, err := file.ReadAt(byteStep, newOffset)
if err == io.EOF {
break
}
buf.Write(byteStep)
}
allBytes := buf.Bytes()
return fmt.Sprintf("%x", md5.Sum(allBytes)), nil
}
// Convert Database Array -> Int64 Array // Convert Database Array -> Int64 Array
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData { func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
var intData []int64 var intData []int64

12
api/utils_test.go Normal file
View File

@ -0,0 +1,12 @@
package api
import "testing"
func TestNiceSeconds(t *testing.T) {
want := "22d 7h 39m 31s"
nice := niceSeconds(1928371)
if nice != want {
t.Fatalf(`Expected: %v, Got: %v`, want, nice)
}
}

View File

@ -53,9 +53,6 @@ class EBookReader {
// Start Timer // Start Timer
this.bookState.pageStart = Date.now(); this.bookState.pageStart = Date.now();
// Restore Theme
this.setTheme(this.readerSettings.theme || "tan");
// Get Stats // Get Stats
let stats = this.getBookStats(); let stats = this.getBookStats();
this.updateBookStats(stats); this.updateBookStats(stats);
@ -64,19 +61,8 @@ class EBookReader {
// Register Content Hook // Register Content Hook
this.rendition.hooks.content.register(getStats); this.rendition.hooks.content.register(getStats);
/** // Update Position
* Display @ CFI x 3 (Hack) await this.setPosition(cfi);
*
* This is absurd. Only way to get it to consistently show the correct
* page is to execute this three times. I tried the font hook,
* rendition hook, relocated hook, etc. No reliable way outside of
* running this three times.
*
* Likely Bug: https://github.com/futurepress/epub.js/issues/1194
**/
await this.rendition.display(cfi);
await this.rendition.display(cfi);
await this.rendition.display(cfi);
// Highlight Element - DOM Has Element // Highlight Element - DOM Has Element
let { element } = await this.getCFIFromXPath(this.bookState.progress); let { element } = await this.getCFIFromXPath(this.bookState.progress);
@ -100,6 +86,7 @@ class EBookReader {
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID(); this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
// Save Settings (Device ID)
this.saveSettings(); this.saveSettings();
} }
@ -154,24 +141,66 @@ class EBookReader {
themeLinkEl.setAttribute("rel", "stylesheet"); themeLinkEl.setAttribute("rel", "stylesheet");
themeLinkEl.setAttribute("href", THEME_FILE); themeLinkEl.setAttribute("href", THEME_FILE);
document.head.append(themeLinkEl); document.head.append(themeLinkEl);
// Set Theme Style
this.rendition.themes.default({
"*": {
"font-size": "var(--editor-font-size) !important",
"font-family": "var(--editor-font-family) !important",
},
});
// Restore Theme Hook
this.rendition.hooks.content.register(
function () {
// Restore Theme
this.setTheme();
// Set Fonts - TODO: Local
// https://gwfh.mranftl.com/fonts
this.rendition.getContents().forEach((c) => {
[
"https://fonts.googleapis.com/css?family=Arbutus+Slab",
"https://fonts.googleapis.com/css?family=Open+Sans",
"https://fonts.googleapis.com/css?family=Lato:400,400i,700,700i",
].forEach((url) => {
let el = c.document.head.appendChild(
c.document.createElement("link")
);
el.setAttribute("rel", "stylesheet");
el.setAttribute("href", url);
});
});
}.bind(this)
);
} }
/** /**
* Set theme & meta theme color * Set theme & meta theme color
**/ **/
setTheme(newTheme) { setTheme(newTheme) {
// Update Settings // Assert Theme Object
this.readerSettings.theme = newTheme; this.readerSettings.theme =
this.saveSettings(); typeof this.readerSettings.theme == "object"
? this.readerSettings.theme
: {};
// Assign Values
Object.assign(this.readerSettings.theme, newTheme);
// Get Desired Theme (Defaults)
let colorScheme = this.readerSettings.theme.colorScheme || "tan";
let fontFamily = this.readerSettings.theme.fontFamily || "serif";
let fontSize = this.readerSettings.theme.fontSize || 1;
// Set Reader Theme // Set Reader Theme
this.rendition.themes.select(newTheme); this.rendition.themes.select(colorScheme);
// Get Reader Theme // Get Reader Theme
let themeColorEl = document.querySelector("[name='theme-color']"); let themeColorEl = document.querySelector("[name='theme-color']");
let themeStyleSheet = document.querySelector("#themes").sheet; let themeStyleSheet = document.querySelector("#themes").sheet;
let themeStyleRule = Array.from(themeStyleSheet.cssRules).find( let themeStyleRule = Array.from(themeStyleSheet.cssRules).find(
(item) => item.selectorText == "." + newTheme (item) => item.selectorText == "." + colorScheme
); );
// Match Reader Theme // Match Reader Theme
@ -180,16 +209,37 @@ class EBookReader {
themeColorEl.setAttribute("content", backgroundColor); themeColorEl.setAttribute("content", backgroundColor);
document.body.style.backgroundColor = backgroundColor; document.body.style.backgroundColor = backgroundColor;
// Update Position Highlight Theme // Set Font Family & Highlight Style
this.rendition.getContents().forEach((item) => { this.rendition.getContents().forEach((item) => {
// Set Font Family
item.document.documentElement.style.setProperty(
"--editor-font-family",
fontFamily
);
// Set Font Size
item.document.documentElement.style.setProperty(
"--editor-font-size",
fontSize + "em"
);
// Set Highlight Style
item.document.querySelectorAll(".highlight").forEach((el) => { item.document.querySelectorAll(".highlight").forEach((el) => {
Object.assign(el.style, { Object.assign(el.style, {
background: backgroundColor, background: backgroundColor,
}); });
}); });
}); });
// Save Settings (Theme)
this.saveSettings();
} }
/**
* Takes existing progressElement and applies the highlight style to it.
* This is nice when font size or font family changes as it can cause
* the position to move.
**/
highlightPositionMarker() { highlightPositionMarker() {
if (!this.bookState.progressElement) return; if (!this.bookState.progressElement) return;
@ -248,7 +298,7 @@ class EBookReader {
// Local Functions // Local Functions
let getCFIFromXPath = this.getCFIFromXPath.bind(this); let getCFIFromXPath = this.getCFIFromXPath.bind(this);
let highlightPositionMarker = this.highlightPositionMarker.bind(this); let setPosition = this.setPosition.bind(this);
let nextPage = this.nextPage.bind(this); let nextPage = this.nextPage.bind(this);
let prevPage = this.prevPage.bind(this); let prevPage = this.prevPage.bind(this);
let saveSettings = this.saveSettings.bind(this); let saveSettings = this.saveSettings.bind(this);
@ -260,15 +310,6 @@ class EBookReader {
this.rendition.hooks.render.register(function (doc, data) { this.rendition.hooks.render.register(function (doc, data) {
let renderDoc = doc.document; let renderDoc = doc.document;
// Initial Font Size
renderDoc.documentElement.style.setProperty(
"--editor-font-size",
(readerSettings.fontSize || 1) + "em"
);
this.themes.default({
"*": { "font-size": "var(--editor-font-size) !important" },
});
// ------------------------------------------------ // // ------------------------------------------------ //
// ---------------- Wake Lock Hack ---------------- // // ---------------- Wake Lock Hack ---------------- //
// ------------------------------------------------ // // ------------------------------------------------ //
@ -279,55 +320,6 @@ class EBookReader {
renderDoc.addEventListener("gesturechange", wakeLockListener); renderDoc.addEventListener("gesturechange", wakeLockListener);
renderDoc.addEventListener("touchstart", wakeLockListener); renderDoc.addEventListener("touchstart", wakeLockListener);
// ------------------------------------------------ //
// ---------------- Resize Helpers ---------------- //
// ------------------------------------------------ //
let lastScale = 1;
let isScaling = false;
let timeoutID = undefined;
let cfiLocation = undefined;
// Gesture Listener
renderDoc.addEventListener(
"gesturechange",
async function (e) {
e.preventDefault();
isScaling = true;
clearTimeout(timeoutID);
if (!cfiLocation)
({ cfi: cfiLocation } = await getCFIFromXPath(bookState.progress));
// Damped Scale
readerSettings.fontSize =
(readerSettings.fontSize || 1) + (e.scale - lastScale) / 5;
lastScale = e.scale;
// Update Font Size
renderDoc.documentElement.style.setProperty(
"--editor-font-size",
(readerSettings.fontSize || 1) + "em"
);
timeoutID = setTimeout(() => {
// Display Position
this.display(cfiLocation);
// Reset Variables
isScaling = false;
cfiLocation = undefined;
// Highlight Location
highlightPositionMarker();
// Save Settings (Font Size)
saveSettings();
}, 250);
}.bind(this),
true
);
// ------------------------------------------------ // // ------------------------------------------------ //
// --------------- Swipe Pagination --------------- // // --------------- Swipe Pagination --------------- //
// ------------------------------------------------ // // ------------------------------------------------ //
@ -350,7 +342,7 @@ class EBookReader {
function (event) { function (event) {
touchEndX = event.changedTouches[0].screenX; touchEndX = event.changedTouches[0].screenX;
touchEndY = event.changedTouches[0].screenY; touchEndY = event.changedTouches[0].screenY;
if (!isScaling) handleGesture(event); handleGesture(event);
}, },
false false
); );
@ -447,11 +439,14 @@ class EBookReader {
// "t" Key (Theme Cycle) // "t" Key (Theme Cycle)
if ((e.keyCode || e.which) == 84) { if ((e.keyCode || e.which) == 84) {
let currentThemeIdx = THEMES.indexOf(readerSettings.theme); let currentThemeIdx = THEMES.indexOf(
if (THEMES.length == currentThemeIdx + 1) readerSettings.theme.colorScheme
readerSettings.theme = THEMES[0]; );
else readerSettings.theme = THEMES[currentThemeIdx + 1]; let colorScheme =
setTheme(readerSettings.theme); THEMES.length == currentThemeIdx + 1
? THEMES[0]
: THEMES[currentThemeIdx + 1];
setTheme({ colorScheme });
} }
}, },
false false
@ -469,6 +464,7 @@ class EBookReader {
let nextPage = this.nextPage.bind(this); let nextPage = this.nextPage.bind(this);
let prevPage = this.prevPage.bind(this); let prevPage = this.prevPage.bind(this);
// Keyboard Shortcuts
document.addEventListener( document.addEventListener(
"keyup", "keyup",
function (e) { function (e) {
@ -484,28 +480,76 @@ class EBookReader {
// "t" Key (Theme Cycle) // "t" Key (Theme Cycle)
if ((e.keyCode || e.which) == 84) { if ((e.keyCode || e.which) == 84) {
let currentThemeIdx = THEMES.indexOf(this.readerSettings.theme); let currentThemeIdx = THEMES.indexOf(
let newTheme = this.readerSettings.theme.colorScheme
);
let colorScheme =
THEMES.length == currentThemeIdx + 1 THEMES.length == currentThemeIdx + 1
? THEMES[0] ? THEMES[0]
: THEMES[currentThemeIdx + 1]; : THEMES[currentThemeIdx + 1];
this.setTheme(newTheme); this.setTheme({ colorScheme });
} }
}.bind(this), }.bind(this),
false false
); );
document.querySelectorAll(".theme").forEach( // Color Scheme Switcher
document.querySelectorAll(".color-scheme").forEach(
function (item) { function (item) {
item.addEventListener( item.addEventListener(
"click", "click",
function (event) { function (event) {
this.setTheme(event.target.innerText); let colorScheme = event.target.innerText;
console.log(colorScheme);
this.setTheme({ colorScheme });
}.bind(this) }.bind(this)
); );
}.bind(this) }.bind(this)
); );
// Font Switcher
document.querySelectorAll(".font-family").forEach(
function (item) {
item.addEventListener(
"click",
async function (event) {
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
let fontFamily = event.target.innerText;
this.setTheme({ fontFamily });
this.setPosition(cfi);
}.bind(this)
);
}.bind(this)
);
// Font Size
document.querySelectorAll(".font-size").forEach(
function (item) {
item.addEventListener(
"click",
async function (event) {
// Get Initial CFI
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
// Modify Size
let currentSize = this.readerSettings.theme.fontSize || 1;
let direction = event.target.innerText;
if (direction == "-") {
this.setTheme({ fontSize: currentSize * 0.99 });
} else if (direction == "+") {
this.setTheme({ fontSize: currentSize * 1.01 });
}
// Restore CFI
this.setPosition(cfi);
}.bind(this)
);
}.bind(this)
);
// Close Top Bar
document.querySelector(".close-top-bar").addEventListener("click", () => { document.querySelector(".close-top-bar").addEventListener("click", () => {
topBar.classList.remove("top-0"); topBar.classList.remove("top-0");
}); });
@ -584,6 +628,24 @@ class EBookReader {
this.flushProgress(); this.flushProgress();
} }
/**
* Display @ CFI x 3 (Hack)
*
* This is absurd. Only way to get it to consistently show the correct
* page is to execute this three times. I tried the font hook,
* rendition hook, relocated hook, etc. No reliable way outside of
* running this three times.
*
* Likely Bug: https://github.com/futurepress/epub.js/issues/1194
**/
async setPosition(cfi) {
await this.rendition.display(cfi);
await this.rendition.display(cfi);
await this.rendition.display(cfi);
this.highlightPositionMarker();
}
/** /**
* Normalize and flush activity * Normalize and flush activity
**/ **/
@ -659,6 +721,9 @@ class EBookReader {
); );
} }
/**
* Flush progress to the API. Called when the page changes.
**/
async flushProgress() { async flushProgress() {
console.log("Flushing Progress..."); console.log("Flushing Progress...");
@ -833,13 +898,13 @@ class EBookReader {
// - [...]/text().184 = text node of parent, character offset @ 184 chars? // - [...]/text().184 = text node of parent, character offset @ 184 chars?
// No XPath // No XPath
if (!xpath || xpath == "") return; if (!xpath || xpath == "") return {};
// Match Document Fragment Index // Match Document Fragment Index
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/); let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch) { if (!fragMatch) {
console.warn("No XPath Match"); console.warn("No XPath Match");
return; return {};
} }
// Match Item Index // Match Item Index
@ -976,10 +1041,9 @@ class EBookReader {
/** /**
* Save settings to localStorage * Save settings to localStorage
**/ **/
saveSettings(obj) { saveSettings() {
if (!this.readerSettings) this.loadSettings(); if (!this.readerSettings) this.loadSettings();
let newSettings = Object.assign(this.readerSettings, obj); localStorage.setItem("readerSettings", JSON.stringify(this.readerSettings));
localStorage.setItem("readerSettings", JSON.stringify(newSettings));
} }
/** /**

2083
assets/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,20 @@ import (
"golang.org/x/net/html" "golang.org/x/net/html"
) )
func getEPUBMetadata(filepath string) (*MetadataInfo, error) {
rc, err := epub.OpenReader(filepath)
if err != nil {
return nil, err
}
rf := rc.Rootfiles[0]
return &MetadataInfo{
Title: &rf.Title,
Author: &rf.Creator,
Description: &rf.Description,
}, nil
}
func countEPUBWords(filepath string) (int64, error) { func countEPUBWords(filepath string) (int64, error) {
rc, err := epub.OpenReader(filepath) rc, err := epub.OpenReader(filepath)
if err != nil { if err != nil {

View File

@ -35,7 +35,7 @@ func TestGBooksISBNQuery(t *testing.T) {
} }
func TestGBooksTitleQuery(t *testing.T) { func TestGBooksTitleQuery(t *testing.T) {
title := "Alice in Wonderland" title := "Alice in Wonderland 1877527815"
metadataResp, err := getGBooksMetadata(MetadataInfo{ metadataResp, err := getGBooksMetadata(MetadataInfo{
Title: &title, Title: &title,
}) })

View File

@ -67,3 +67,16 @@ func GetWordCount(filepath string) (int64, error) {
return 0, errors.New("Invalid Extension") return 0, errors.New("Invalid Extension")
} }
} }
func GetMetadata(filepath string) (*MetadataInfo, error) {
fileMime, err := mimetype.DetectFile(filepath)
if err != nil {
return nil, err
}
if fileExtension := fileMime.Extension(); fileExtension == ".epub" {
return getEPUBMetadata(filepath)
} else {
return nil, errors.New("Invalid Extension")
}
}

View File

@ -6,9 +6,31 @@ import (
func TestGetWordCount(t *testing.T) { func TestGetWordCount(t *testing.T) {
var want int64 = 30477 var want int64 = 30477
wordCount, err := countEPUBWords("./_test_files/alice.epub") wordCount, err := countEPUBWords("../_test_files/alice.epub")
if wordCount != want { if wordCount != want {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, wordCount, err) t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, wordCount, err)
} }
} }
func TestGetMetadata(t *testing.T) {
metadataInfo, err := getEPUBMetadata("../_test_files/alice.epub")
if err != nil {
t.Fatalf(`Expected: *MetadataInfo, Got: nil, Error: %v`, err)
}
want := "Alice's Adventures in Wonderland / Illustrated by Arthur Rackham. With a Proem by Austin Dobson"
if *metadataInfo.Title != want {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Title, err)
}
want = "Lewis Carroll"
if *metadataInfo.Author != want {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Author, err)
}
want = ""
if *metadataInfo.Description != want {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Description, err)
}
}

View File

@ -1,5 +0,0 @@
#!/bin/bash
env GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./build/server_linux_arm64
env GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./build/server_linux_amd64
# env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o ./build/server_darwin_amd64
# env GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o ./build/server_darwin_arm64

View File

@ -3,7 +3,6 @@
pkgs.mkShell { pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
go go
zig nodePackages.tailwindcss
nodejs_20
]; ];
} }

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**/*.{html,js}"],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -6,11 +6,14 @@
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"> content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes"/> <meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
<meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)">
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title> <title>Book Manager - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css">
<style> <style>
/* ----------------------------- */ /* ----------------------------- */
/* -------- PWA Styling -------- */ /* -------- PWA Styling -------- */

View File

@ -3,7 +3,7 @@
{{define "title"}}Documents{{end}} {{define "title"}}Documents{{end}}
{{define "header"}} {{define "header"}}
<a href="{{ .RelBase }}./documents">Documents</a> <a href="/documents">Documents</a>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -12,7 +12,7 @@
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"> <div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4">
<div class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"> <div class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative">
<label class="z-10 cursor-pointer" for="edit-cover-button"> <label class="z-10 cursor-pointer" for="edit-cover-button">
<img class="rounded object-fill w-full" src="{{ .RelBase }}./documents/{{.Data.ID}}/cover"></img> <img class="rounded object-fill w-full" src="/documents/{{.Data.ID}}/cover"></img>
</label> </label>
{{ if .Data.Filepath }} {{ if .Data.Filepath }}
@ -52,7 +52,7 @@
name="cover_file" name="cover_file"
> >
<button <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Upload Cover</button> >Upload Cover</button>
</form> </form>
@ -63,7 +63,7 @@
> >
<input type="checkbox" checked id="remove_cover" name="remove_cover" class="hidden" /> <input type="checkbox" checked id="remove_cover" name="remove_cover" class="hidden" />
<button <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Remove Cover</button> >Remove Cover</button>
</form> </form>
@ -95,7 +95,7 @@
class="text-black dark:text-white text-sm" class="text-black dark:text-white text-sm"
> >
<button <button
class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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"
type="submit" type="submit"
>Delete</button> >Delete</button>
</form> </form>
@ -163,7 +163,7 @@
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 <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Identify</button> >Identify</button>
</form> </form>
@ -244,7 +244,7 @@
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 <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Save</button> >Save</button>
</form> </form>
@ -293,7 +293,7 @@
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 <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Save</button> >Save</button>
</form> </form>
@ -401,7 +401,7 @@
<div <div
class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200" class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200"
> >
<img class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill" src="{{ .RelBase }}./documents/{{.Data.ID}}/cover"></img> <img class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill" src="/documents/{{.Data.ID}}/cover"></img>
<form <form
method="POST" method="POST"
action="./{{ .Data.ID }}/edit" action="./{{ .Data.ID }}/edit"
@ -414,7 +414,7 @@
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" 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> >{{ or .Data.Description "N/A" }}</textarea>
<button <button
class="font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Save</button> >Save</button>
</form> </form>
@ -430,8 +430,8 @@
<div class="text-center"> <div class="text-center">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3> <h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
</div> </div>
<a href="{{ .RelBase }}./documents/{{ .Data.ID }}" <a href="/documents/{{ .Data.ID }}"
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Back to Document</a> >Back to Document</a>
</div> </div>
@ -449,7 +449,7 @@
<form <form
id="metadata-save" id="metadata-save"
method="POST" method="POST"
action="{{ .RelBase }}./documents/{{ .Data.ID }}/edit" action="/documents/{{ .Data.ID }}/edit"
class="text-black dark:text-white border-b dark:border-black" class="text-black dark:text-white border-b dark:border-black"
> >
<dl> <dl>
@ -512,13 +512,13 @@
</div> </div>
</form> </form>
<div class="flex justify-end gap-4 m-4"> <div class="flex justify-end gap-4 m-4">
<a href="{{ .RelBase }}./documents/{{ .Data.ID }}" <a href="/documents/{{ .Data.ID }}"
class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Cancel</a> >Cancel</a>
<button <button
form="metadata-save" form="metadata-save"
class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" 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" type="submit"
>Save</button> >Save</button>
</div> </div>

View File

@ -103,4 +103,55 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center">
<input type="checkbox" id="upload-file-button" class="hidden css-button"/>
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2">
<form method="POST" enctype="multipart/form-data" action="./documents" class="flex flex-col gap-2">
<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" type="submit">Upload File</button>
</form>
<label for="upload-file-button">
<div class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800">Cancel Upload</div>
</label>
</div>
<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"
>
<svg
width="34"
height="34"
class="text-gray-200 dark:text-gray-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
/>
<path
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
/>
</svg>
</label>
</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}}

58
templates/error.html Normal file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>Book Manager - Error</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"
>
<section>
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1
class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
>
{{ .Status }}
</h1>
<p
class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white"
>
{{ .Error }}
</p>
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">
{{ .Message }}
</p>
<a
href="/"
class="rounded 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"
>Back to Homepage</a
>
</div>
</div>
</section>
</body>
</html>

View File

@ -11,7 +11,6 @@
name="apple-mobile-web-app-status-bar-style" name="apple-mobile-web-app-status-bar-style"
content="black-translucent" content="black-translucent"
/> />
<link rel="manifest" href="./manifest.json" />
<meta <meta
name="theme-color" name="theme-color"
content="#F3F4F6" content="#F3F4F6"
@ -22,10 +21,11 @@
content="#1F2937" content="#1F2937"
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title> <title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title>
<link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./assets/style.css" />
</head> </head>
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white"> <body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<div class="flex flex-wrap w-full"> <div class="flex flex-wrap w-full">

View File

@ -12,10 +12,13 @@
name="apple-mobile-web-app-status-bar-style" name="apple-mobile-web-app-status-bar-style"
content="black-translucent" content="black-translucent"
/> />
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
<meta name="theme-color" content="#D2B48C" /> <meta name="theme-color" content="#D2B48C" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title> <title>Book Manager - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<style> <style>
html, html,
body { body {
@ -166,32 +169,76 @@
<p class="text-gray-400">Theme</p> <p class="text-gray-400">Theme</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm"> <div class="flex justify-around w-full gap-4 p-2 text-sm">
<div <div
class="theme cursor-pointer rounded border border-white bg-[#fff] text-[#000] grow text-center" class="color-scheme cursor-pointer rounded border border-white bg-[#fff] text-[#000] grow text-center"
> >
light light
</div> </div>
<div <div
class="theme cursor-pointer rounded border border-white bg-[#d2b48c] text-[#333] grow text-center" class="color-scheme cursor-pointer rounded border border-white bg-[#d2b48c] text-[#333] grow text-center"
> >
tan tan
</div> </div>
<div <div
class="theme cursor-pointer rounded border border-white bg-[#1f2937] text-[#fff] grow text-center" class="color-scheme cursor-pointer rounded border border-white bg-[#1f2937] text-[#fff] grow text-center"
> >
blue blue
</div> </div>
<div <div
class="theme cursor-pointer rounded border border-white bg-[#232323] text-[#fff] grow text-center" class="color-scheme cursor-pointer rounded border border-white bg-[#232323] text-[#fff] grow text-center"
> >
gray gray
</div> </div>
<div <div
class="theme cursor-pointer rounded border border-white bg-[#000] text-[#ccc] grow text-center" class="color-scheme cursor-pointer rounded border border-white bg-[#000] text-[#ccc] grow text-center"
> >
black black
</div> </div>
</div> </div>
</div> </div>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<p class="text-gray-400">Font</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Serif
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Open Sans
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Arbutus Slab
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Lato
</div>
</div>
</div>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<p class="text-gray-400">Font Size</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<div
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
>
-
</div>
<div
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
>
+
</div>
</div>
</div>
</div> </div>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>

44
utils/utils.go Normal file
View File

@ -0,0 +1,44 @@
package utils
import (
"bytes"
"crypto/md5"
"fmt"
"io"
"os"
)
// Reimplemented KOReader Partial MD5 Calculation
func CalculatePartialMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
var step int64 = 1024
var size int64 = 1024
var buf bytes.Buffer
for i := -1; i <= 10; i++ {
byteStep := make([]byte, size)
var newShift int64 = int64(i * 2)
var newOffset int64
if i == -1 {
newOffset = 0
} else {
newOffset = step << newShift
}
_, err := file.ReadAt(byteStep, newOffset)
if err == io.EOF {
break
}
buf.Write(byteStep)
}
allBytes := buf.Bytes()
return fmt.Sprintf("%x", md5.Sum(allBytes)), nil
}

12
utils/utils_test.go Normal file
View File

@ -0,0 +1,12 @@
package utils
import "testing"
func TestCalculatePartialPD5(t *testing.T) {
partialMD5, err := CalculatePartialMD5("../_test_files/alice.epub")
want := "386d1cb51fe4a72e5c9fdad5e059bad9"
if partialMD5 != want {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, partialMD5, err)
}
}