diff --git a/Dockerfile b/Dockerfile index ec308ad..33f22cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,35 @@ -FROM alpine:edge AS build -RUN apk add --no-cache --update go gcc g++ -WORKDIR /app -COPY . /app +# Certificate Store +FROM alpine as certs +RUN apk update && apk add ca-certificates -# Copy Resources +# Build Image +FROM --platform=$BUILDPLATFORM golang:1.20 AS build + +# Install Dependencies +RUN apt-get update -y +RUN apt install -y gcc-x86-64-linux-gnu + +# Create Package Directory +WORKDIR /src RUN mkdir -p /opt/bookmanager -RUN cp -a ./templates /opt/bookmanager/templates -RUN cp -a ./assets /opt/bookmanager/assets -# Download Dependencies & Compile -RUN go mod download -RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o /opt/bookmanager/server +# Cache Dependencies & Compile +ARG TARGETOS +ARG TARGETARCH +RUN --mount=target=. \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + if [ "$TARGETARCH" = "amd64" ]; then \ + GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" CC=x86_64-linux-gnu-gcc go build -o /opt/bookmanager/server; \ + else \ + GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o /opt/bookmanager/server; \ + fi; \ + cp -a ./templates /opt/bookmanager/templates; \ + cp -a ./assets /opt/bookmanager/assets; # Create Image -FROM alpine:3.18 +FROM busybox:1.36 +COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY --from=build /opt/bookmanager /opt/bookmanager WORKDIR /opt/bookmanager EXPOSE 8585 diff --git a/Makefile b/Makefile index f4c3da9..2eb4f03 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,13 @@ docker_build_local: docker_build_release_beta: docker buildx build \ --platform linux/amd64,linux/arm64 \ - -t gitea.va.reichard.io/reichard/bookmanager:beta --push . + -t gitea.va.reichard.io/reichard/bookmanager:beta \ + -t gitea.va.reichard.io/evan/bookmanager:beta \ + --push . docker_build_release_latest: docker buildx build \ --platform linux/amd64,linux/arm64 \ - -t gitea.va.reichard.io/reichard/bookmanager:latest --push . + -t gitea.va.reichard.io/reichard/bookmanager:latest \ + -t gitea.va.reichard.io/evan/bookmanager:latest \ + --push . diff --git a/README.md b/README.md index 173614f..3d9d80e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # Book Manager

- - + + - - + + - - + + + + + + + +

@@ -24,11 +30,30 @@ In additional to the compatible KOSync API's, we add: - Additional APIs to automatically upload reading statistics - Automatically upload documents to the server (can download in the "Documents" view) -- Automatic book cover metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/)) +- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started)) - No JavaScript! All information is rendered server side. # Server +Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest` + +## Quick Start + +```bash +# Make Data Directory +mkdir -p bookmanager_data + +# Run Server +docker run \ + -p 8585:8585 \ + -e REGISTRATION_ENABLED=true \ + -v ./bookmanager_data:/config \ + -v ./bookmanager_data:/data \ + gitea.va.reichard.io/evan/bookmanager:latest +``` + +The service is now accessible at: `http://localhost:8585` + ## Configuration | Environment Variable | Default Value | Description | @@ -50,22 +75,22 @@ See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reicha SQLC Generation (v1.21.0): -``` +```bash go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest ~/go/bin/sqlc generate ``` Run Development: -``` -CONFIG_PATH=./data DATA_PATH=./data go run cmd/main.go serve +```bash +CONFIG_PATH=./data DATA_PATH=./data go run main.go serve ``` # Building The `Dockerfile` and `Makefile` contain the build information: -``` +```bash # Build Local Docker Image make docker_build_local @@ -75,12 +100,12 @@ make docker_build_release_latest If manually building, you must enable CGO: -``` +```bash # Download Dependencies go mod download # Compile (Binary `./bookmanager`) -CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o /bookmanager cmd/main.go +CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o /bookmanager ``` ## Notes diff --git a/api/app-routes.go b/api/app-routes.go index d4c58fe..5d5c4f0 100644 --- a/api/app-routes.go +++ b/api/app-routes.go @@ -21,8 +21,11 @@ type requestDocumentEdit struct { Title *string `form:"title"` Author *string `form:"author"` Description *string `form:"description"` + ISBN10 *string `form:"isbn_10"` + ISBN13 *string `form:"isbn_13"` RemoveCover *string `form:"remove_cover"` - CoverFile *multipart.FileHeader `form:"cover"` + CoverGBID *string `form:"cover_gbid"` + CoverFile *multipart.FileHeader `form:"cover_file"` } type requestDocumentIdentify struct { @@ -101,6 +104,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any return } + templateVars["RelBase"] = "../" templateVars["Data"] = document } else if routeName == "activity" { activityFilter := database.GetActivityParams{ @@ -131,7 +135,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any if err != nil { log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) } - log.Info("GetUserWindowStreaks - WEEK - ", time.Since(start_time)) + log.Debug("GetUserWindowStreaks - WEEK - ", time.Since(start_time)) start_time = time.Now() daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ @@ -141,15 +145,15 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any if err != nil { log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) } - log.Info("GetUserWindowStreaks - DAY - ", time.Since(start_time)) + log.Debug("GetUserWindowStreaks - DAY - ", time.Since(start_time)) start_time = time.Now() database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string)) - log.Info("GetDatabaseInfo - ", time.Since(start_time)) + log.Debug("GetDatabaseInfo - ", time.Since(start_time)) start_time = time.Now() read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string)) - log.Info("GetDailyReadStats - ", time.Since(start_time)) + log.Debug("GetDailyReadStats - ", time.Since(start_time)) templateVars["Data"] = gin.H{ "DailyStreak": daily_streak, @@ -218,7 +222,7 @@ func (api *API) getDocumentCover(c *gin.Context) { firstResult := metadataResults[0] // Save Cover - fileName, err := metadata.SaveCover(*firstResult.GBID, coverDir, document.ID) + fileName, err := metadata.SaveCover(*firstResult.GBID, coverDir, document.ID, false) if err == nil { coverFile = *fileName } @@ -275,8 +279,11 @@ func (api *API) editDocument(c *gin.Context) { if rDocEdit.Author == nil && rDocEdit.Title == nil && rDocEdit.Description == nil && - rDocEdit.CoverFile == nil && - rDocEdit.RemoveCover == nil { + rDocEdit.ISBN10 == nil && + rDocEdit.ISBN13 == nil && + rDocEdit.RemoveCover == nil && + rDocEdit.CoverGBID == nil && + rDocEdit.CoverFile == nil { log.Error("[createAppResourcesRoute] Missing Form Values") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return @@ -288,7 +295,6 @@ func (api *API) editDocument(c *gin.Context) { s := "UNKNOWN" coverFileName = &s } else if rDocEdit.CoverFile != nil { - // Validate Type & Derive Extension on MIME uploadedFile, err := rDocEdit.CoverFile.Open() if err != nil { @@ -325,6 +331,14 @@ func (api *API) editDocument(c *gin.Context) { } coverFileName = &fileName + } else if rDocEdit.CoverGBID != nil { + // TODO + + var coverDir string = filepath.Join(api.Config.DataPath, "covers") + fileName, err := metadata.SaveCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true) + if err == nil { + coverFileName = fileName + } } // Update Document @@ -333,6 +347,8 @@ func (api *API) editDocument(c *gin.Context) { Title: api.sanitizeInput(rDocEdit.Title), Author: api.sanitizeInput(rDocEdit.Author), Description: api.sanitizeInput(rDocEdit.Description), + Isbn10: api.sanitizeInput(rDocEdit.ISBN10), + Isbn13: api.sanitizeInput(rDocEdit.ISBN13), Coverfile: coverFileName, }); err != nil { log.Error("[createAppResourcesRoute] UpsertDocument DB Error:", err) @@ -367,6 +383,8 @@ func (api *API) deleteDocument(c *gin.Context) { } func (api *API) identifyDocument(c *gin.Context) { + rUser, _ := c.Get("AuthorizedUser") + var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("[identifyDocument] Invalid URI Bind") @@ -399,36 +417,52 @@ func (api *API) identifyDocument(c *gin.Context) { return } + // Template Variables + templateVars := gin.H{ + "RelBase": "../../", + } + + // Get Metadata metadataResults, err := metadata.GetMetadata(metadata.MetadataInfo{ Title: rDocIdentify.Title, Author: rDocIdentify.Author, ISBN10: rDocIdentify.ISBN, ISBN13: rDocIdentify.ISBN, }) - if err != nil || len(metadataResults) == 0 { - log.Error("[identifyDocument] Metadata Error") - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Metadata Error"}) + if err == nil && len(metadataResults) > 0 { + firstResult := metadataResults[0] + + // Store First Metadata Result + if _, err = api.DB.Queries.AddMetadata(api.DB.Ctx, database.AddMetadataParams{ + DocumentID: rDocID.DocumentID, + Title: firstResult.Title, + Author: firstResult.Author, + Description: firstResult.Description, + Gbid: firstResult.GBID, + Olid: firstResult.OLID, + Isbn10: firstResult.ISBN10, + Isbn13: firstResult.ISBN13, + }); err != nil { + log.Error("[identifyDocument] AddMetadata DB Error:", err) + } + + templateVars["Metadata"] = firstResult + } else { + log.Warn("[identifyDocument] Metadata Error") + templateVars["MetadataError"] = "No Metadata Found" + } + + document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{ + UserID: rUser.(string), + DocumentID: rDocID.DocumentID, + }) + if err != nil { + log.Error("[identifyDocument] GetDocumentWithStats DB Error:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } - // TODO - firstResult := metadataResults[0] + templateVars["Data"] = document - if firstResult.Title != nil { - log.Info("Title:", *firstResult.Title) - } - if firstResult.Author != nil { - log.Info("Author:", *firstResult.Author) - } - if firstResult.Description != nil { - log.Info("Description:", *firstResult.Description) - } - if firstResult.ISBN10 != nil { - log.Info("ISBN 10:", *firstResult.ISBN10) - } - if firstResult.ISBN13 != nil { - log.Info("ISBN 13:", *firstResult.ISBN13) - } - - c.Redirect(http.StatusFound, "/") + c.HTML(http.StatusOK, "document", templateVars) } diff --git a/api/auth.go b/api/auth.go index 18815fa..f890217 100644 --- a/api/auth.go +++ b/api/auth.go @@ -85,7 +85,8 @@ func (api *API) authFormLogin(c *gin.Context) { if username == "" || rawPassword == "" { c.HTML(http.StatusUnauthorized, "login", gin.H{ - "Error": "Invalid Credentials", + "RegistrationEnabled": api.Config.RegistrationEnabled, + "Error": "Invalid Credentials", }) return } @@ -93,7 +94,8 @@ func (api *API) authFormLogin(c *gin.Context) { if authorized := api.authorizeCredentials(username, password); authorized != true { c.HTML(http.StatusUnauthorized, "login", gin.H{ - "Error": "Invalid Credentials", + "RegistrationEnabled": api.Config.RegistrationEnabled, + "Error": "Invalid Credentials", }) return } diff --git a/database/query.sql b/database/query.sql index e60c9f8..4f205f9 100644 --- a/database/query.sql +++ b/database/query.sql @@ -380,8 +380,12 @@ current_streak AS ( end_date AS current_streak_end_date FROM streaks WHERE CASE - WHEN ?2 = "WEEK" THEN DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date - WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date + WHEN ?2 = "WEEK" THEN + DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date + OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date + WHEN ?2 = "DAY" THEN + DATE('now', time_offset, '-1 day') = current_streak_end_date + OR DATE('now', time_offset) = current_streak_end_date END LIMIT 1 ) diff --git a/database/query.sql.go b/database/query.sql.go index 54ebaea..be6e78b 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -1006,8 +1006,12 @@ current_streak AS ( end_date AS current_streak_end_date FROM streaks WHERE CASE - WHEN ?2 = "WEEK" THEN DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date - WHEN ?2 = "DAY" THEN DATE('now', time_offset, '-1 day') = current_streak_end_date OR DATE('now', time_offset) = current_streak_end_date + WHEN ?2 = "WEEK" THEN + DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date + OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date + WHEN ?2 = "DAY" THEN + DATE('now', time_offset, '-1 day') = current_streak_end_date + OR DATE('now', time_offset) = current_streak_end_date END LIMIT 1 ) diff --git a/metadata/metadata.go b/metadata/metadata.go index 627c83e..6e15aad 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -129,7 +129,7 @@ func GetMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) { return allMetadata, nil } -func SaveCover(gbid string, coverDir string, documentID string) (*string, error) { +func SaveCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) { // Google Books -> JPG coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID)) @@ -137,7 +137,7 @@ func SaveCover(gbid string, coverDir string, documentID string) (*string, error) // Validate File Doesn't Exists _, err := os.Stat(coverFilePath) - if err == nil { + if err == nil && overwrite == false { log.Warn("[SaveCover] File Alreads Exists") return &coverFile, nil } diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..efc0dfc --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,11 @@ +# Screenshots + +## Process Images + +```bash +# Resize +sips -Z 1500 *.png + +# Crop Top & Bottom +sips --cropOffset 85 1 -c 1385 693 *.png +``` diff --git a/screenshots/pwa/activity.png b/screenshots/pwa/activity.png new file mode 100644 index 0000000..a040dde Binary files /dev/null and b/screenshots/pwa/activity.png differ diff --git a/screenshots/pwa/document.png b/screenshots/pwa/document.png new file mode 100644 index 0000000..a1e7de5 Binary files /dev/null and b/screenshots/pwa/document.png differ diff --git a/screenshots/pwa/documents.png b/screenshots/pwa/documents.png new file mode 100644 index 0000000..ae70428 Binary files /dev/null and b/screenshots/pwa/documents.png differ diff --git a/screenshots/pwa/home.png b/screenshots/pwa/home.png new file mode 100644 index 0000000..1ab019c Binary files /dev/null and b/screenshots/pwa/home.png differ diff --git a/screenshots/pwa/login.png b/screenshots/pwa/login.png new file mode 100644 index 0000000..9440e7a Binary files /dev/null and b/screenshots/pwa/login.png differ diff --git a/screenshots/pwa/metadata.png b/screenshots/pwa/metadata.png new file mode 100644 index 0000000..c579ee0 Binary files /dev/null and b/screenshots/pwa/metadata.png differ diff --git a/screenshots/pwa/navigation.png b/screenshots/pwa/navigation.png new file mode 100644 index 0000000..c6c5dc3 Binary files /dev/null and b/screenshots/pwa/navigation.png differ diff --git a/screenshots/web/activity.png b/screenshots/web/activity.png new file mode 100644 index 0000000..7467c11 Binary files /dev/null and b/screenshots/web/activity.png differ diff --git a/screenshots/web/document.png b/screenshots/web/document.png new file mode 100644 index 0000000..5580332 Binary files /dev/null and b/screenshots/web/document.png differ diff --git a/screenshots/web/documents.png b/screenshots/web/documents.png new file mode 100644 index 0000000..a6ce663 Binary files /dev/null and b/screenshots/web/documents.png differ diff --git a/screenshots/web/home.png b/screenshots/web/home.png new file mode 100644 index 0000000..7891596 Binary files /dev/null and b/screenshots/web/home.png differ diff --git a/screenshots/web/login.png b/screenshots/web/login.png new file mode 100644 index 0000000..4b0bd82 Binary files /dev/null and b/screenshots/web/login.png differ diff --git a/screenshots/web/metadata.png b/screenshots/web/metadata.png new file mode 100644 index 0000000..826020b Binary files /dev/null and b/screenshots/web/metadata.png differ diff --git a/screenshots/web_documents.png b/screenshots/web_documents.png deleted file mode 100644 index 85918da..0000000 Binary files a/screenshots/web_documents.png and /dev/null differ diff --git a/screenshots/web_home.png b/screenshots/web_home.png deleted file mode 100644 index 0957c91..0000000 Binary files a/screenshots/web_home.png and /dev/null differ diff --git a/screenshots/web_login.png b/screenshots/web_login.png deleted file mode 100644 index 8f03e62..0000000 Binary files a/screenshots/web_login.png and /dev/null differ diff --git a/templates/base.html b/templates/base.html index e9041b4..93a3be3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,9 +1,10 @@ - + - + Book Manager - {{block "title" .}}{{end}} @@ -11,215 +12,170 @@
-
- -
-
-
- -

Book Manager

+
+ +

{{block "header" .}}{{end}}

+
+ + + + + + + -
-
-
-
-

{{block "header" .}}{{end}}

-
- - - - - - -
- -
- -
-
-
- {{block "content" .}}{{end}}
+
+ +
+ {{block "content" .}}{{end}} +
diff --git a/templates/document.html b/templates/document.html index dc928c0..3450b69 100644 --- a/templates/document.html +++ b/templates/document.html @@ -1,106 +1,172 @@ -{{template "base.html" .}} +{{template "base.html" . }} {{define "title"}}Documents{{end}} {{define "header"}} -Documents +Documents {{end}} {{define "content"}} -
-
- -
-
-
-

ISBN 10:

-

- {{ or .Data.Isbn10 "N/A" }} -

+
+ +
+
+ +
+
+
+

ISBN-10:

+

+ {{ or .Data.Isbn10 "N/A" }} +

+
+
+

ISBN-13:

+

+ {{ or .Data.Isbn13 "N/A" }} +

+
-
-

ISBN 13:

-

- {{ or .Data.Isbn13 "N/A" }} -

-
-
-
- -
-
- + +
+ - - -
- - -
+ + + +
+ + +
-
-
-
+
+ + +
+
+ +
+
+
+ - - + + - - -
-
- -
+
+
+ + +
+
+ + + + +
+
-
- - - - - - -
- - -
-
- - - - -
-
-
- {{ if .Data.Filepath }} - + + {{ else }} - - {{ else }} - - - - {{ end }} + {{ end }} +
-
-
+
+
+
+

Title

+ + +
+
+ + +
+
+
+

+ {{ or .Data.Title "N/A" }} +

+
+
+
+

Author

+ + + +
+
+ + +
+
+
+

+ {{ or .Data.Author "N/A" }} +

+
+
+

Time Read

+

+ {{ .Data.TotalTimeMinutes }} Minutes +

+
+
+

Progress

+

+ {{ .Data.CurrentPage }} / {{ .Data.TotalPages }} ({{ .Data.Percentage }}%) +

+
+ +
+
-
-

Title

-
-
-
-

Author

- - - -
-
- - -
-
-
-

- {{ or .Data.Author "N/A" }} -

-
-
-

Time Read

-

- {{ .Data.TotalTimeMinutes }} Minutes -

-
-
-

Progress

-

- {{ .Data.CurrentPage }} / {{ .Data.TotalPages }} ({{ .Data.Percentage }}%) -

-
- -
- -
-
-

Description

-
-
- -
+
+
+
+

No Metadata Results Found

+
+ Back to Document +
+
+ {{ end }} + + + {{ if .Metadata }} +
+
+
+
+

Metadata Results

+
+
- - - - -
+
+
+
+ Cover +
+
+ +
+
+
+
+ Title +
+
+ {{ or .Metadata.Title "N/A" }} +
+
+
+
+ Author +
+
+ {{ or .Metadata.Author "N/A" }} +
+
+
+
+ ISBN 10 +
+
+ {{ or .Metadata.ISBN10 "N/A" }} +
+
+
+
+ ISBN 13 +
+
+ {{ or .Metadata.ISBN13 "N/A" }} +
+
+
+
+ Description +
+
+ {{ or .Metadata.Description "N/A" }} +
+
+
+ + +
+ Cancel +
-

{{ or .Data.Description "N/A" }}

+
+ {{ end }}
+