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

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
rm -r ./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=amd64 go build -o ./build/server_darwin_amd64
docker_build_local:
docker_build_local: build_tailwind
docker build -t bookmanager:latest .
docker_build_release_dev:
docker_build_release_dev: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:dev \
-f Dockerfile-BuildKit \
--push .
docker_build_release_latest:
docker_build_release_latest: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:latest \
@ -28,6 +28,13 @@ docker_build_release_latest:
-f Dockerfile-BuildKit \
--push .
build_tailwind:
tailwind build -o ./assets/style.css
clean:
rm -rf ./build
tests_integration:
go test -v -tags=integration -coverpkg=./... ./metadata

View File

@ -78,6 +78,7 @@ func (api *API) registerWebAppRoutes() {
"NiceSeconds": niceSeconds,
}
render.AddFromFiles("error", "templates/error.html")
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.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.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
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/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.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
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("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadDocumentFile)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocumentFile)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
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("/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)
}

View File

@ -19,6 +19,7 @@ import (
"reichard.io/bbank/database"
"reichard.io/bbank/metadata"
"reichard.io/bbank/search"
"reichard.io/bbank/utils"
)
type queryParams struct {
@ -32,6 +33,10 @@ type searchParams struct {
BookType *string `form:"book_type"`
}
type requestDocumentUpload struct {
DocumentFile *multipart.FileHeader `form:"document_file"`
}
type requestDocumentEdit struct {
Title *string `form:"title"`
Author *string `form:"author"`
@ -100,7 +105,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
})
if err != nil {
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
}
@ -113,7 +118,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[createAppResourcesRoute] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
@ -123,11 +128,10 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
})
if err != nil {
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
}
templateVars["RelBase"] = "../"
templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage
} 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)
if err != nil {
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
}
@ -172,14 +176,14 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
if err != nil {
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
}
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID)
if err != nil {
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
}
@ -221,7 +225,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[getDocumentCover] Invalid URI Bind")
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusNotFound, "Invalid cover.")
return
}
@ -229,7 +233,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[getDocumentCover] GetDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return
}
@ -314,7 +318,7 @@ func (api *API) documentReader(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[documentReader] Invalid URI Bind")
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
@ -325,7 +329,7 @@ func (api *API) documentReader(c *gin.Context) {
if err != nil && err != sql.ErrNoRows {
log.Error("[documentReader] UpsertDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return
}
@ -335,7 +339,7 @@ func (api *API) documentReader(c *gin.Context) {
})
if err != nil {
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
}
@ -343,22 +347,152 @@ func (api *API) documentReader(c *gin.Context) {
"SearchEnabled": api.Config.SearchEnabled,
"Progress": progress.Progress,
"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) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[createAppResourcesRoute] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
var rDocEdit requestDocumentEdit
if err := c.ShouldBind(&rDocEdit); err != nil {
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
}
@ -372,7 +506,7 @@ func (api *API) editDocument(c *gin.Context) {
rDocEdit.CoverGBID == nil &&
rDocEdit.CoverFile == nil {
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
}
@ -386,14 +520,14 @@ func (api *API) editDocument(c *gin.Context) {
uploadedFile, err := rDocEdit.CoverFile.Open()
if err != nil {
log.Error("[createAppResourcesRoute] File Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusInternalServerError, "Unable to open file.")
return
}
fileMime, err := mimetype.DetectReader(uploadedFile)
if err != nil {
log.Error("[createAppResourcesRoute] MIME Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusInternalServerError, "Unable to detect filetype.")
return
}
fileExtension := fileMime.Extension()
@ -401,7 +535,7 @@ func (api *API) editDocument(c *gin.Context) {
// Validate Extension
if !slices.Contains([]string{".jpg", ".png"}, fileExtension) {
log.Error("[uploadDocumentFile] Invalid FileType: ", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
errorPage(c, http.StatusBadRequest, "Invalid filetype.")
return
}
@ -412,8 +546,8 @@ func (api *API) editDocument(c *gin.Context) {
// Save
err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath)
if err != nil {
log.Error("[createAppResourcesRoute] File Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("[createAppResourcesRoute] File Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
@ -437,7 +571,7 @@ func (api *API) editDocument(c *gin.Context) {
Coverfile: coverFileName,
}); err != nil {
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
}
@ -449,18 +583,18 @@ func (api *API) deleteDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[deleteDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
changed, err := api.DB.Queries.DeleteDocument(api.DB.Ctx, rDocID.DocumentID)
if err != nil {
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
}
if changed == 0 {
log.Error("[deleteDocument] DeleteDocument DB Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
@ -473,14 +607,14 @@ func (api *API) identifyDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[identifyDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil {
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
}
@ -498,13 +632,12 @@ func (api *API) identifyDocument(c *gin.Context) {
// Validate Values
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
log.Error("[identifyDocument] Invalid Form")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return
}
// Template Variables
templateVars := gin.H{
"RelBase": "../../",
"SearchEnabled": api.Config.SearchEnabled,
}
@ -544,7 +677,7 @@ func (api *API) identifyDocument(c *gin.Context) {
})
if err != nil {
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
}
@ -558,7 +691,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
var rDocAdd requestDocumentAdd
if err := c.ShouldBind(&rDocAdd); err != nil {
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
}
@ -568,7 +701,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
rDocAdd.Title == nil ||
rDocAdd.Author == nil {
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
}
@ -581,15 +714,15 @@ func (api *API) saveNewDocument(c *gin.Context) {
tempFilePath, err := search.SaveBook(*rDocAdd.ID, bType)
if err != nil {
log.Warn("[saveNewDocument] Temp File Error: ", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
// Calculate Partial MD5 ID
partialMD5, err := calculatePartialMD5(tempFilePath)
partialMD5, err := utils.CalculatePartialMD5(tempFilePath)
if err != nil {
log.Warn("[saveNewDocument] Partial MD5 Error: ", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, "Unable to calculate partial MD5.")
return
}
@ -623,7 +756,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
// Move File
if err := os.Rename(tempFilePath, safePath); err != nil {
log.Warn("[saveNewDocument] Move Temp File Error: ", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
@ -631,7 +764,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
fileHash, err := getFileMD5(safePath)
if err != nil {
log.Error("[saveNewDocument] Hash Failure:", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, "Unable to calculate MD5.")
return
}
@ -644,7 +777,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
Filepath: &fileName,
}); err != nil {
log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return
}
@ -657,14 +790,14 @@ func (api *API) editSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
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
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil {
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
}
@ -703,7 +836,7 @@ func (api *API) editSettings(c *gin.Context) {
_, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings)
if err != nil {
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
}
@ -711,7 +844,7 @@ func (api *API) editSettings(c *gin.Context) {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
if err != nil {
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
}
@ -719,7 +852,7 @@ func (api *API) editSettings(c *gin.Context) {
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
if err != nil {
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
}
@ -792,3 +925,24 @@ func bindQueryParams(c *gin.Context) queryParams {
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.Abort()
return
}
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) {
if !api.Config.RegistrationEnabled {
c.AbortWithStatus(http.StatusConflict)
errorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
return
}
username := strings.TrimSpace(c.PostForm("username"))
@ -202,7 +204,7 @@ func (api *API) authFormRegister(c *gin.Context) {
// Set Session
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
errorPage(c, http.StatusUnauthorized, "Unauthorized.")
return
}

View File

@ -500,17 +500,17 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
c.JSON(http.StatusOK, rCheckDocSync)
}
func (api *API) uploadDocumentFile(c *gin.Context) {
func (api *API) uploadExistingDocument(c *gin.Context) {
var rDoc requestDocumentID
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"})
return
}
fileData, err := c.FormFile("file")
if err != nil {
log.Error("[uploadDocumentFile] File Error:", err)
log.Error("[uploadExistingDocument] File Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -521,7 +521,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
fileExtension := fileMime.Extension()
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"})
return
}
@ -529,7 +529,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
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"})
return
}
@ -562,7 +562,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
if os.IsNotExist(err) {
err = c.SaveUploadedFile(fileData, safePath)
if err != nil {
log.Error("[uploadDocumentFile] Save Failure:", err)
log.Error("[uploadExistingDocument] Save Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -571,7 +571,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
// Get MD5 Hash
fileHash, err := getFileMD5(safePath)
if err != nil {
log.Error("[uploadDocumentFile] Hash Failure:", err)
log.Error("[uploadExistingDocument] Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
@ -582,7 +582,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
Md5: fileHash,
Filepath: &fileName,
}); 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"})
return
}
@ -592,7 +592,7 @@ func (api *API) uploadDocumentFile(c *gin.Context) {
ID: document.ID,
Synced: true,
}); 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"})
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
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"})
return
}
@ -613,13 +613,13 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
// Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
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"})
return
}
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"})
return
}
@ -630,7 +630,7 @@ func (api *API) downloadDocumentFile(c *gin.Context) {
// Validate File Exists
_, err = os.Stat(filePath)
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"})
return
}

View File

@ -1,12 +1,8 @@
package api
import (
"bytes"
"crypto/md5"
"fmt"
"io"
"math"
"os"
"reichard.io/bbank/database"
"reichard.io/bbank/graph"
@ -90,41 +86,6 @@ func niceSeconds(input int64) (result string) {
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
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
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
this.bookState.pageStart = Date.now();
// Restore Theme
this.setTheme(this.readerSettings.theme || "tan");
// Get Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
@ -64,19 +61,8 @@ class EBookReader {
// Register Content Hook
this.rendition.hooks.content.register(getStats);
/**
* 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
**/
await this.rendition.display(cfi);
await this.rendition.display(cfi);
await this.rendition.display(cfi);
// Update Position
await this.setPosition(cfi);
// Highlight Element - DOM Has Element
let { element } = await this.getCFIFromXPath(this.bookState.progress);
@ -100,6 +86,7 @@ class EBookReader {
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
// Save Settings (Device ID)
this.saveSettings();
}
@ -154,24 +141,66 @@ class EBookReader {
themeLinkEl.setAttribute("rel", "stylesheet");
themeLinkEl.setAttribute("href", THEME_FILE);
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
**/
setTheme(newTheme) {
// Update Settings
this.readerSettings.theme = newTheme;
this.saveSettings();
// Assert Theme Object
this.readerSettings.theme =
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
this.rendition.themes.select(newTheme);
this.rendition.themes.select(colorScheme);
// Get Reader Theme
let themeColorEl = document.querySelector("[name='theme-color']");
let themeStyleSheet = document.querySelector("#themes").sheet;
let themeStyleRule = Array.from(themeStyleSheet.cssRules).find(
(item) => item.selectorText == "." + newTheme
(item) => item.selectorText == "." + colorScheme
);
// Match Reader Theme
@ -180,16 +209,37 @@ class EBookReader {
themeColorEl.setAttribute("content", backgroundColor);
document.body.style.backgroundColor = backgroundColor;
// Update Position Highlight Theme
// Set Font Family & Highlight Style
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) => {
Object.assign(el.style, {
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() {
if (!this.bookState.progressElement) return;
@ -248,7 +298,7 @@ class EBookReader {
// Local Functions
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 prevPage = this.prevPage.bind(this);
let saveSettings = this.saveSettings.bind(this);
@ -260,15 +310,6 @@ class EBookReader {
this.rendition.hooks.render.register(function (doc, data) {
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 ---------------- //
// ------------------------------------------------ //
@ -279,55 +320,6 @@ class EBookReader {
renderDoc.addEventListener("gesturechange", 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 --------------- //
// ------------------------------------------------ //
@ -350,7 +342,7 @@ class EBookReader {
function (event) {
touchEndX = event.changedTouches[0].screenX;
touchEndY = event.changedTouches[0].screenY;
if (!isScaling) handleGesture(event);
handleGesture(event);
},
false
);
@ -447,11 +439,14 @@ class EBookReader {
// "t" Key (Theme Cycle)
if ((e.keyCode || e.which) == 84) {
let currentThemeIdx = THEMES.indexOf(readerSettings.theme);
if (THEMES.length == currentThemeIdx + 1)
readerSettings.theme = THEMES[0];
else readerSettings.theme = THEMES[currentThemeIdx + 1];
setTheme(readerSettings.theme);
let currentThemeIdx = THEMES.indexOf(
readerSettings.theme.colorScheme
);
let colorScheme =
THEMES.length == currentThemeIdx + 1
? THEMES[0]
: THEMES[currentThemeIdx + 1];
setTheme({ colorScheme });
}
},
false
@ -469,6 +464,7 @@ class EBookReader {
let nextPage = this.nextPage.bind(this);
let prevPage = this.prevPage.bind(this);
// Keyboard Shortcuts
document.addEventListener(
"keyup",
function (e) {
@ -484,28 +480,76 @@ class EBookReader {
// "t" Key (Theme Cycle)
if ((e.keyCode || e.which) == 84) {
let currentThemeIdx = THEMES.indexOf(this.readerSettings.theme);
let newTheme =
let currentThemeIdx = THEMES.indexOf(
this.readerSettings.theme.colorScheme
);
let colorScheme =
THEMES.length == currentThemeIdx + 1
? THEMES[0]
: THEMES[currentThemeIdx + 1];
this.setTheme(newTheme);
this.setTheme({ colorScheme });
}
}.bind(this),
false
);
document.querySelectorAll(".theme").forEach(
// Color Scheme Switcher
document.querySelectorAll(".color-scheme").forEach(
function (item) {
item.addEventListener(
"click",
function (event) {
this.setTheme(event.target.innerText);
let colorScheme = event.target.innerText;
console.log(colorScheme);
this.setTheme({ colorScheme });
}.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", () => {
topBar.classList.remove("top-0");
});
@ -584,6 +628,24 @@ class EBookReader {
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
**/
@ -659,6 +721,9 @@ class EBookReader {
);
}
/**
* Flush progress to the API. Called when the page changes.
**/
async flushProgress() {
console.log("Flushing Progress...");
@ -833,13 +898,13 @@ class EBookReader {
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
// No XPath
if (!xpath || xpath == "") return;
if (!xpath || xpath == "") return {};
// Match Document Fragment Index
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch) {
console.warn("No XPath Match");
return;
return {};
}
// Match Item Index
@ -976,10 +1041,9 @@ class EBookReader {
/**
* Save settings to localStorage
**/
saveSettings(obj) {
saveSettings() {
if (!this.readerSettings) this.loadSettings();
let newSettings = Object.assign(this.readerSettings, obj);
localStorage.setItem("readerSettings", JSON.stringify(newSettings));
localStorage.setItem("readerSettings", JSON.stringify(this.readerSettings));
}
/**

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"
)
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) {
rc, err := epub.OpenReader(filepath)
if err != nil {

View File

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

View File

@ -67,3 +67,16 @@ func GetWordCount(filepath string) (int64, error) {
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) {
var want int64 = 30477
wordCount, err := countEPUBWords("./_test_files/alice.epub")
wordCount, err := countEPUBWords("../_test_files/alice.epub")
if wordCount != want {
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 {
packages = with pkgs; [
go
zig
nodejs_20
nodePackages.tailwindcss
];
}

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">
<meta name="apple-mobile-web-app-capable" content="yes"/>
<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="#1F2937" media="(prefers-color-scheme: dark)">
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css">
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */

View File

@ -3,7 +3,7 @@
{{define "title"}}Documents{{end}}
{{define "header"}}
<a href="{{ .RelBase }}./documents">Documents</a>
<a href="/documents">Documents</a>
{{end}}
{{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="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">
<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>
{{ if .Data.Filepath }}
@ -52,7 +52,7 @@
name="cover_file"
>
<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"
>Upload Cover</button>
</form>
@ -63,7 +63,7 @@
>
<input type="checkbox" checked id="remove_cover" name="remove_cover" class="hidden" />
<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"
>Remove Cover</button>
</form>
@ -95,7 +95,7 @@
class="text-black dark:text-white text-sm"
>
<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"
>Delete</button>
</form>
@ -163,7 +163,7 @@
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: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"
>Identify</button>
</form>
@ -244,7 +244,7 @@
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: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"
>Save</button>
</form>
@ -293,7 +293,7 @@
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: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"
>Save</button>
</form>
@ -401,7 +401,7 @@
<div
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
method="POST"
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"
>{{ or .Data.Description "N/A" }}</textarea>
<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"
>Save</button>
</form>
@ -430,8 +430,8 @@
<div class="text-center">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
</div>
<a href="{{ .RelBase }}./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"
<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>
@ -449,7 +449,7 @@
<form
id="metadata-save"
method="POST"
action="{{ .RelBase }}./documents/{{ .Data.ID }}/edit"
action="/documents/{{ .Data.ID }}/edit"
class="text-black dark:text-white border-b dark:border-black"
>
<dl>
@ -512,13 +512,13 @@
</div>
</form>
<div class="flex justify-end gap-4 m-4">
<a href="{{ .RelBase }}./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"
<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: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"
>Save</button>
</div>

View File

@ -103,4 +103,55 @@
</div>
{{end}}
</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}}

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"
content="black-translucent"
/>
<link rel="manifest" href="./manifest.json" />
<meta
name="theme-color"
content="#F3F4F6"
@ -22,10 +21,11 @@
content="#1F2937"
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>
<link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./assets/style.css" />
</head>
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<div class="flex flex-wrap w-full">

View File

@ -12,10 +12,13 @@
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
<meta name="theme-color" content="#D2B48C" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<style>
html,
body {
@ -166,32 +169,76 @@
<p class="text-gray-400">Theme</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<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
</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
</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
</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
</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
</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>
{{block "content" .}}{{end}}
</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)
}
}