Compare commits

19 Commits

Author SHA1 Message Date
c3410b7833 [fix] version
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2023-11-18 10:14:57 -05:00
1403bae036 [add] pagination
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-11-17 23:10:59 -05:00
af41946a65 [add] git link
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 21:40:59 -05:00
243ae2a001 [add] document search
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 21:24:19 -05:00
d94e79f39c [fix] syncninja koreader nil error
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 22:53:41 -05:00
856bc7e2e6 [fix] xpath & cfi resolution
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-07 19:19:06 -05:00
5cc1e2d71c [fix] wonky xpath issues
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-06 07:12:24 -05:00
ffc5462326 [fix] opds no redirect - KOReader OPDS compatibility
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 21:38:10 -05:00
3cbe4b1c0d [fix] null pointer deref
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 21:20:15 -05:00
c213b3b09f [fix] wakelock detection
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 19:27:43 -05:00
7d45bb0253 [add] logo & banner, [fix] mobile alignment issue
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 13:20:19 -05:00
a8bcd0f588 [add] rename to AnthoLume
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 19:55:38 -04:00
bc3e9cbaf0 [add] update assets & slight rearrangements
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 13:12:05 -04:00
e6ad51ed70 [add] cleanup and minify
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 12:27:35 -04:00
cce0ef2de1 [fix] book stat dom
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 00:04:31 -04:00
71898c39e7 [improve] web reader activity & progress tracking
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-03 23:43:08 -04:00
985b6e0851 [fix] percentage regression, [add] individual doc & user update (performance) 2023-11-03 21:37:26 -04:00
425f469097 Merge pull request 'Migrate Pages -> Percentages' (#2) from remove_pages into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: evan/BookManager#2
2023-11-03 23:50:40 +00:00
761163d666 [add] migrate to percentages vs pages
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
[add] task runner
[fix] calculate word count on upload
[remove] unused queries
2023-11-03 19:38:35 -04:00
52 changed files with 1046 additions and 4982 deletions

View File

@@ -24,7 +24,7 @@ steps:
- name: publish_docker - name: publish_docker
image: plugins/docker image: plugins/docker
settings: settings:
repo: gitea.va.reichard.io/evan/bookmanager repo: gitea.va.reichard.io/evan/antholume
registry: gitea.va.reichard.io registry: gitea.va.reichard.io
tags: tags:
- dev - dev

View File

@@ -10,17 +10,17 @@ WORKDIR /src
COPY . . COPY . .
# Create Package Directory # Create Package Directory
RUN mkdir -p /opt/bookmanager RUN mkdir -p /opt/antholume
# Compile # Compile
RUN go build -o /opt/bookmanager/server; \ RUN go build -o /opt/antholume/server; \
cp -a ./templates /opt/bookmanager/templates; \ cp -a ./templates /opt/antholume/templates; \
cp -a ./assets /opt/bookmanager/assets; cp -a ./assets /opt/antholume/assets;
# Create Image # Create Image
FROM busybox:1.36 FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/bookmanager WORKDIR /opt/antholume
EXPOSE 8585 EXPOSE 8585
ENTRYPOINT ["/opt/bookmanager/server", "serve"] ENTRYPOINT ["/opt/antholume/server", "serve"]

View File

@@ -7,7 +7,7 @@ FROM --platform=$BUILDPLATFORM golang:1.20 AS build
# Create Package Directory # Create Package Directory
WORKDIR /src WORKDIR /src
RUN mkdir -p /opt/bookmanager RUN mkdir -p /opt/antholume
# Cache Dependencies & Compile # Cache Dependencies & Compile
ARG TARGETOS ARG TARGETOS
@@ -15,14 +15,14 @@ ARG TARGETARCH
RUN --mount=target=. \ RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/bookmanager/server; \ GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/antholume/server; \
cp -a ./templates /opt/bookmanager/templates; \ cp -a ./templates /opt/antholume/templates; \
cp -a ./assets /opt/bookmanager/assets; cp -a ./assets /opt/antholume/assets;
# Create Image # Create Image
FROM busybox:1.36 FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/bookmanager WORKDIR /opt/antholume
EXPOSE 8585 EXPOSE 8585
ENTRYPOINT ["/opt/bookmanager/server", "serve"] ENTRYPOINT ["/opt/antholume/server", "serve"]

View File

@@ -11,25 +11,25 @@ build_local: build_tailwind
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: build_tailwind docker_build_local: build_tailwind
docker build -t bookmanager:latest . docker build -t antholume:latest .
docker_build_release_dev: build_tailwind 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/antholume:dev \
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
docker_build_release_latest: build_tailwind 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/antholume:latest \
-t gitea.va.reichard.io/evan/bookmanager:`git describe --tags` \ -t gitea.va.reichard.io/evan/antholume:`git describe --tags` \
-f Dockerfile-BuildKit \ -f Dockerfile-BuildKit \
--push . --push .
build_tailwind: build_tailwind:
tailwind build -o ./assets/style.css tailwind build -o ./assets/style.css --minify
clean: clean:

View File

@@ -1,101 +1,90 @@
# Book Manager <p><img align="center" src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/banner.png"></p>
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png" width="19%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="19%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png" width="19%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="19%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png" width="19%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="19%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png" width="19%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="19%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png" width="19%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
</a> </a>
</p> </p>
<p align="center">Screenshots</p>
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/web/README.md">Web App</a> - <a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/pwa/README.md">PWA</a> <strong><a href="https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/screenshots">Screenshots</a></strong> •
<strong><a href="https://antholume-demo.cloud.reichard.io/">Demo Server</a></strong>
</p> </p>
<p align="center"><strong>user:</strong> demo • <strong>pass:</strong> demo</p>
<p align="center"> <p align="center">
<a href="https://drone.va.reichard.io/evan/BookManager" target="_blank"> <a href="https://drone.va.reichard.io/evan/AnthoLume" target="_blank">
<img src="https://drone.va.reichard.io/api/badges/evan/BookManager/status.svg"> <img src="https://drone.va.reichard.io/api/badges/evan/AnthoLume/status.svg">
</a> </a>
</p> </p>
--- ---
**TL;DR:** Show me the [demo](https://books-demo.cloud.reichard.io/)! AnthoLume is a Progressive Web App (PWA) that manages your EPUB documents, provides an EPUB reader, and tracks your reading activity! It also has a [KOReader KOSync](https://github.com/koreader/koreader-sync-server) compatible API, and a [KOReader](https://github.com/koreader/koreader) Plugin used to sync activity from your Kindle. Some additional features include:
- Username: `demo` - OPDS API Endpoint
- Password: `demo` - Local / Offline Reader (via ServiceWorker)
- Metadata Scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
- Words / Minute (WPM) Tracking & Leaderboard (Amongst Server Users)
This is BookManager! Will probably be renamed at some point. This repository contains: While some features require JavaScript (Service Worker & EPUB Reader), we make an effort to limit JavaScript usage. Outside of the two aforementioned features, no JavaScript is used.
- Web App / Progressive Web App (PWA) ## Server
- [KOReader](https://github.com/koreader/koreader) Plugin (See `client` subfolder)
- [KOReader KOSync](https://github.com/koreader/koreader-sync-server) compatible API
- OPDS API endpoint that provides access to the uploaded documents
In additional to the compatible KOSync API's, we add: Docker Image: `docker pull gitea.va.reichard.io/evan/antholume:latest`
- Additional APIs to automatically upload reading statistics ### Local / Offline Reader
- Upload documents to the server (can download in the "Documents" view or via OPDS)
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
- Limited JavaScript use. Server-Side Rendering is used wherever possible. The main app is fully operational without any JS. JS is only required for:
- EPUB Reader
- Local / Offline Mode
- Service Worker
# Server The Local / Offline reader allows you to use any AnthoLume server as a standalone offline accessible reading app! Some features:
Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest`
## Local / Offline Reader
The Local / Offline reader allows you to use any BookManager server as a standalone offline accessible reading app! Some features:
- Add local EPUB documents - Add local EPUB documents
- Read both local and any cached server documents - Read both local and any cached server documents
- Maintains progress for all types of documents (server / local) - Maintains progress for all types of documents (server / local)
- Uploads any progress or activity for cached server documents once the internet is accessible - Uploads any progress or activity for cached server documents once the internet is accessible
## KOSync API ### KOSync API
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko` The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`
## OPDS API ### OPDS API
The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds` The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds`
## Quick Start ### Quick Start
```bash ```bash
# Make Data Directory # Make Data Directory
mkdir -p bookmanager_data mkdir -p antholume_data
# Run Server # Run Server
docker run \ docker run \
-p 8585:8585 \ -p 8585:8585 \
-e REGISTRATION_ENABLED=true \ -e REGISTRATION_ENABLED=true \
-v ./bookmanager_data:/config \ -v ./antholume_data:/config \
-v ./bookmanager_data:/data \ -v ./antholume_data:/data \
gitea.va.reichard.io/evan/bookmanager:latest gitea.va.reichard.io/evan/antholume:latest
``` ```
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users. The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
## Configuration ### Configuration
| Environment Variable | Default Value | Description | | Environment Variable | Default Value | Description |
| -------------------- | ------------- | ------------------------------------------------------------------- | | -------------------- | ------------- | ------------------------------------------------------------------- |
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported | | DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
| DATABASE_NAME | book_manager | The database name, or in SQLite's case, the filename | | DATABASE_NAME | antholume | The database name, or in SQLite's case, the filename |
| CONFIG_PATH | /config | Directory where to store SQLite's DB | | CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata | | DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at | | LISTEN_PORT | 8585 | Port the server listens at |
@@ -118,11 +107,11 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
- The native KOSync plugin sends an MD5 hash of the password. Due to that: - The native KOSync plugin sends an MD5 hash of the password. Due to that:
- We store an Argon2 hash _and_ per-password salt of the MD5 hashed original password - We store an Argon2 hash _and_ per-password salt of the MD5 hashed original password
# Client (KOReader Plugin) ## Client (KOReader Plugin)
See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/BookManager/src/branch/master/client/) See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/client/)
# Development ## Development
SQLC Generation (v1.21.0): SQLC Generation (v1.21.0):
@@ -137,7 +126,7 @@ Run Development:
CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve
``` ```
# Building ## Building
The `Dockerfile` and `Makefile` contain the build information: The `Dockerfile` and `Makefile` contain the build information:

View File

@@ -163,6 +163,7 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
opdsGroup := apiGroup.Group("/opds") opdsGroup := apiGroup.Group("/opds")
// OPDS Routes // OPDS Routes
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments) opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover) opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument) opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)

View File

@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
@@ -26,6 +27,7 @@ import (
type queryParams struct { type queryParams struct {
Page *int64 `form:"page"` Page *int64 `form:"page"`
Limit *int64 `form:"limit"` Limit *int64 `form:"limit"`
Search *string `form:"search"`
Document *string `form:"document"` Document *string `form:"document"`
} }
@@ -111,8 +113,15 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
qParams := bindQueryParams(c) qParams := bindQueryParams(c)
if routeName == "documents" { if routeName == "documents" {
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{ documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: userID, UserID: userID,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
@@ -122,10 +131,30 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
return return
} }
length, err := api.DB.Queries.GetDocumentsSize(api.DB.Ctx, query)
if err != nil {
log.Error("[createAppResourcesRoute] GetDocumentsSize DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
return
}
if err = api.getDocumentsWordCount(documents); err != nil { if err = api.getDocumentsWordCount(documents); err != nil {
log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err) log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err)
} }
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
nextPage := *qParams.Page + 1
previousPage := *qParams.Page - 1
if nextPage <= totalPages {
templateVars["NextPage"] = nextPage
}
if previousPage >= 0 {
templateVars["PreviousPage"] = previousPage
}
templateVars["PageLimit"] = *qParams.Limit
templateVars["Data"] = documents templateVars["Data"] = documents
} else if routeName == "document" { } else if routeName == "document" {
var rDocID requestDocumentID var rDocID requestDocumentID
@@ -146,7 +175,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
} }
templateVars["Data"] = document templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
} else if routeName == "activity" { } else if routeName == "activity" {
activityFilter := database.GetActivityParams{ activityFilter := database.GetActivityParams{
UserID: userID, UserID: userID,
@@ -177,13 +206,13 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
log.Info("GetDatabaseInfo Performance: ", time.Since(start)) log.Info("GetDatabaseInfo Performance: ", time.Since(start))
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID) streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID)
wpn_leaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx) wpm_leaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
"Streaks": streaks, "Streaks": streaks,
"GraphData": read_graph_data, "GraphData": read_graph_data,
"DatabaseInfo": database_info, "DatabaseInfo": database_info,
"WPMLeaderboard": wpn_leaderboard, "WPMLeaderboard": wpm_leaderboard,
} }
} else if routeName == "settings" { } else if routeName == "settings" {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID) user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
@@ -456,6 +485,14 @@ func (api *API) uploadNewDocument(c *gin.Context) {
return return
} }
// Get Word Count
wordCount, err := metadata.GetWordCount(tempFile.Name())
if err != nil {
log.Error("[uploadNewDocument] Word Count Failure:", err)
errorPage(c, http.StatusInternalServerError, "Unable to calculate word count.")
return
}
// Derive Filename // Derive Filename
var fileName string var fileName string
if *metadataInfo.Author != "" { if *metadataInfo.Author != "" {
@@ -499,6 +536,7 @@ func (api *API) uploadNewDocument(c *gin.Context) {
Title: metadataInfo.Title, Title: metadataInfo.Title,
Author: metadataInfo.Author, Author: metadataInfo.Author,
Description: metadataInfo.Description, Description: metadataInfo.Description,
Words: &wordCount,
Md5: fileHash, Md5: fileHash,
Filepath: &fileName, Filepath: &fileName,
}); err != nil { }); err != nil {
@@ -711,7 +749,7 @@ func (api *API) identifyDocument(c *gin.Context) {
} }
templateVars["Data"] = document templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
c.HTML(http.StatusOK, "document", templateVars) c.HTML(http.StatusOK, "document", templateVars)
} }
@@ -814,6 +852,14 @@ func (api *API) saveNewDocument(c *gin.Context) {
return return
} }
// Get Word Count
wordCount, err := metadata.GetWordCount(safePath)
if err != nil {
log.Error("[saveNewDocument] Word Count Failure:", err)
errorPage(c, http.StatusInternalServerError, "Unable to calculate word count.")
return
}
// Upsert Document // Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: partialMD5, ID: partialMD5,
@@ -821,6 +867,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
Author: rDocAdd.Author, Author: rDocAdd.Author,
Md5: fileHash, Md5: fileHash,
Filepath: &fileName, Filepath: &fileName,
Words: &wordCount,
}); err != nil { }); err != nil {
log.Error("[saveNewDocument] UpsertDocument DB Error:", err) log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
@@ -957,7 +1004,7 @@ func bindQueryParams(c *gin.Context) queryParams {
c.BindQuery(&qParams) c.BindQuery(&qParams)
if qParams.Limit == nil { if qParams.Limit == nil {
var defaultValue int64 = 50 var defaultValue int64 = 9
qParams.Limit = &defaultValue qParams.Limit = &defaultValue
} else if *qParams.Limit < 0 { } else if *qParams.Limit < 0 {
var zeroValue int64 = 0 var zeroValue int64 = 0
@@ -965,7 +1012,7 @@ func bindQueryParams(c *gin.Context) queryParams {
} }
if qParams.Page == nil || *qParams.Page < 1 { if qParams.Page == nil || *qParams.Page < 1 {
var oneValue int64 = 0 var oneValue int64 = 1
qParams.Page = &oneValue qParams.Page = &oneValue
} }

View File

@@ -19,6 +19,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"reichard.io/bbank/database" "reichard.io/bbank/database"
"reichard.io/bbank/metadata"
) )
type activityItem struct { type activityItem struct {
@@ -168,6 +169,13 @@ func (api *API) setProgress(c *gin.Context) {
return return
} }
// Update Statistic
log.Info("[setProgress] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(rPosition.DocumentID, rUser.(string)); err != nil {
log.Error("[setProgress] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[setProgress] UpdateDocumentUserStatistic Complete")
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID, "document": progress.DocumentID,
"timestamp": progress.CreatedAt, "timestamp": progress.CreatedAt,
@@ -263,13 +271,13 @@ func (api *API) addActivities(c *gin.Context) {
// Add All Activity // Add All Activity
for _, item := range rActivity.Activity { for _, item := range rActivity.Activity {
if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{ if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
UserID: rUser.(string), UserID: rUser.(string),
DocumentID: item.DocumentID, DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID, DeviceID: rActivity.DeviceID,
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339), StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
Duration: int64(item.Duration), Duration: int64(item.Duration),
Page: int64(item.Page), StartPercentage: float64(item.Page) / float64(item.Pages),
Pages: int64(item.Pages), EndPercentage: float64(item.Page+1) / float64(item.Pages),
}); err != nil { }); err != nil {
log.Error("[addActivities] AddActivity DB Error:", err) log.Error("[addActivities] AddActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
@@ -284,13 +292,14 @@ func (api *API) addActivities(c *gin.Context) {
return return
} }
// Update Temp Tables // Update Statistic
go func() { for _, doc := range allDocuments {
log.Info("[addActivities] Caching Temp Tables") log.Info("[addActivities] UpdateDocumentUserStatistic Running...")
if err := api.DB.CacheTempTables(); err != nil { if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
log.Warn("[addActivities] CacheTempTables Failure: ", err) log.Error("[addActivities] UpdateDocumentUserStatistic Error:", err)
} }
}() log.Info("[addActivities] UpdateDocumentUserStatistic Complete")
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"added": len(rActivity.Activity), "added": len(rActivity.Activity),
@@ -367,7 +376,7 @@ func (api *API) addDocuments(c *gin.Context) {
// Upsert Documents // Upsert Documents
for _, doc := range rNewDocs.Documents { for _, doc := range rNewDocs.Documents {
doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: doc.ID, ID: doc.ID,
Title: api.sanitizeInput(doc.Title), Title: api.sanitizeInput(doc.Title),
Author: api.sanitizeInput(doc.Author), Author: api.sanitizeInput(doc.Author),
@@ -381,16 +390,6 @@ func (api *API) addDocuments(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return return
} }
if _, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: doc.ID,
Synced: true,
}); err != nil {
log.Error("[addDocuments] UpdateDocumentSync DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
} }
// Commit Transaction // Commit Transaction
@@ -416,7 +415,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
} }
// Upsert Device // Upsert Device
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{ _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID, ID: rCheckDocs.DeviceID,
UserID: rUser.(string), UserID: rUser.(string),
DeviceName: rCheckDocs.Device, DeviceName: rCheckDocs.Device,
@@ -431,22 +430,20 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
missingDocs := []database.Document{} missingDocs := []database.Document{}
deletedDocIDs := []string{} deletedDocIDs := []string{}
if device.Sync == true { // Get Missing Documents
// Get Missing Documents missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have) if err != nil {
if err != nil { log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err)
log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return
return }
}
// Get Deleted Documents // Get Deleted Documents
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have) deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err) log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return return
}
} }
// Get Wanted Documents // Get Wanted Documents
@@ -576,27 +573,26 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
return return
} }
// Get Word Count
wordCount, err := metadata.GetWordCount(safePath)
if err != nil {
log.Error("[uploadExistingDocument] Word Count Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
// Upsert Document // Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID, ID: document.ID,
Md5: fileHash, Md5: fileHash,
Filepath: &fileName, Filepath: &fileName,
Words: &wordCount,
}); err != nil { }); err != nil {
log.Error("[uploadExistingDocument] 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
} }
// Update Document Sync Attribute
if _, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: document.ID,
Synced: true,
}); err != nil {
log.Error("[uploadExistingDocument] UpdateDocumentSync DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "ok", "status": "ok",
}) })

View File

@@ -55,15 +55,30 @@ func (api *API) opdsDocuments(c *gin.Context) {
splitFilepath := strings.Split(*doc.Filepath, ".") splitFilepath := strings.Split(*doc.Filepath, ".")
fileType := splitFilepath[len(splitFilepath)-1] fileType := splitFilepath[len(splitFilepath)-1]
title := "N/A"
if doc.Title != nil {
title = *doc.Title
}
author := "N/A"
if doc.Author != nil {
author = *doc.Author
}
description := "N/A"
if doc.Description != nil {
description = *doc.Description
}
item := opds.Entry{ item := opds.Entry{
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage.(float64)), *doc.Title), Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage), title),
Author: []opds.Author{ Author: []opds.Author{
{ {
Name: *doc.Author, Name: author,
}, },
}, },
Content: &opds.Content{ Content: &opds.Content{
Content: *doc.Description, Content: description,
ContentType: "text", ContentType: "text",
}, },
Links: []opds.Link{ Links: []opds.Link{
@@ -91,7 +106,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
// TODO // TODO
// Links: []opds.Link{ // Links: []opds.Link{
// { // {
// Title: "Search Book Manager", // Title: "Search AnthoLume",
// Rel: "search", // Rel: "search",
// TypeLink: "application/opensearchdescription+xml", // TypeLink: "application/opensearchdescription+xml",
// Href: "search.xml", // Href: "search.xml",
@@ -105,8 +120,8 @@ func (api *API) opdsDocuments(c *gin.Context) {
func (api *API) opdsSearchDescription(c *gin.Context) { func (api *API) opdsSearchDescription(c *gin.Context) {
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Search Book Manager</ShortName> <ShortName>Search AnthoLume</ShortName>
<Description>Search Book Manager</Description> <Description>Search AnthoLume</Description>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/> <Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/>
</OpenSearchDescription>` </OpenSearchDescription>`
c.Data(http.StatusOK, "application/xml", []byte(rawXML)) c.Data(http.StatusOK, "application/xml", []byte(rawXML))

View File

@@ -1,3 +1,6 @@
/**
* Custom Service Worker Convenience Functions Wrapper
**/
const SW = (function () { const SW = (function () {
// Helper Function // Helper Function
function randomID() { function randomID() {
@@ -63,3 +66,57 @@ const SW = (function () {
return { install, send }; return { install, send };
})(); })();
/**
* Custom IndexedDB Convenience Functions Wrapper
**/
const IDB = (function () {
if (!idbKeyval)
return console.error(
"[IDB] idbKeyval not found - Did you load idb-keyval?"
);
let { get, del, entries, update, keys } = idbKeyval;
return {
async set(key, newValue) {
let changeObj = {};
await update(key, (oldValue) => {
if (oldValue != null) changeObj.oldValue = oldValue;
changeObj.newValue = newValue;
return newValue;
});
return changeObj;
},
get(key, defaultValue) {
return get(key).then((resp) => {
return defaultValue && resp == null ? defaultValue : resp;
});
},
del(key) {
return del(key);
},
find(keyRegExp, includeValues = false) {
if (!(keyRegExp instanceof RegExp)) throw new Error("Invalid RegExp");
if (!includeValues)
return keys().then((allKeys) =>
allKeys.filter((key) => keyRegExp.test(key))
);
return entries().then((allItems) => {
const matchingKeys = allItems.filter((keyVal) =>
keyRegExp.test(keyVal[0])
);
return matchingKeys.reduce((obj, keyVal) => {
const [key, val] = keyVal;
obj[key] = val;
return obj;
}, {});
});
},
};
})();

BIN
assets/icons/icon512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,50 +1 @@
function _slicedToArray(t,n){return _arrayWithHoles(t)||_iterableToArrayLimit(t,n)||_unsupportedIterableToArray(t,n)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(t,n){if(t){if("string"==typeof t)return _arrayLikeToArray(t,n);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(t,n):void 0}}function _arrayLikeToArray(t,n){(null==n||n>t.length)&&(n=t.length);for(var r=0,e=new Array(n);r<n;r++)e[r]=t[r];return e}function _iterableToArrayLimit(t,n){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var e,o,u=[],i=!0,a=!1;try{for(r=r.call(t);!(i=(e=r.next()).done)&&(u.push(e.value),!n||u.length!==n);i=!0);}catch(t){a=!0,o=t}finally{try{i||null==r.return||r.return()}finally{if(a)throw o}}return u}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}!function(t,n){"object"===("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).idbKeyval={})}(this,(function(t){"use strict";function n(t){return new Promise((function(n,r){t.oncomplete=t.onsuccess=function(){return n(t.result)},t.onabort=t.onerror=function(){return r(t.error)}}))}function r(t,r){var e=indexedDB.open(t);e.onupgradeneeded=function(){return e.result.createObjectStore(r)};var o=n(e);return function(t,n){return o.then((function(e){return n(e.transaction(r,t).objectStore(r))}))}}var e;function o(){return e||(e=r("keyval-store","keyval")),e}function u(t,r){return t.openCursor().onsuccess=function(){this.result&&(r(this.result),this.result.continue())},n(t.transaction)}t.clear=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readwrite",(function(t){return t.clear(),n(t.transaction)}))},t.createStore=r,t.del=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return r.delete(t),n(r.transaction)}))},t.delMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.delete(t)})),n(r.transaction)}))},t.entries=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(r){if(r.getAll&&r.getAllKeys)return Promise.all([n(r.getAllKeys()),n(r.getAll())]).then((function(t){var n=_slicedToArray(t,2),r=n[0],e=n[1];return r.map((function(t,n){return[t,e[n]]}))}));var e=[];return t("readonly",(function(t){return u(t,(function(t){return e.push([t.key,t.value])})).then((function(){return e}))}))}))},t.get=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return n(r.get(t))}))},t.getMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return Promise.all(t.map((function(t){return n(r.get(t))})))}))},t.keys=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAllKeys)return n(t.getAllKeys());var r=[];return u(t,(function(t){return r.push(t.key)})).then((function(){return r}))}))},t.promisifyRequest=n,t.set=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return e.put(r,t),n(e.transaction)}))},t.setMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.put(t[1],t[0])})),n(r.transaction)}))},t.update=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return new Promise((function(o,u){e.get(t).onsuccess=function(){try{e.put(r(this.result),t),o(n(e.transaction))}catch(t){u(t)}}}))}))},t.values=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAll)return n(t.getAll());var r=[];return u(t,(function(t){return r.push(t.value)})).then((function(){return r}))}))},Object.defineProperty(t,"__esModule",{value:!0})})); function _slicedToArray(t,n){return _arrayWithHoles(t)||_iterableToArrayLimit(t,n)||_unsupportedIterableToArray(t,n)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(t,n){if(t){if("string"==typeof t)return _arrayLikeToArray(t,n);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(t,n):void 0}}function _arrayLikeToArray(t,n){(null==n||n>t.length)&&(n=t.length);for(var r=0,e=new Array(n);r<n;r++)e[r]=t[r];return e}function _iterableToArrayLimit(t,n){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var e,o,u=[],i=!0,a=!1;try{for(r=r.call(t);!(i=(e=r.next()).done)&&(u.push(e.value),!n||u.length!==n);i=!0);}catch(t){a=!0,o=t}finally{try{i||null==r.return||r.return()}finally{if(a)throw o}}return u}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}!function(t,n){"object"===("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).idbKeyval={})}(this,(function(t){"use strict";function n(t){return new Promise((function(n,r){t.oncomplete=t.onsuccess=function(){return n(t.result)},t.onabort=t.onerror=function(){return r(t.error)}}))}function r(t,r){var e=indexedDB.open(t);e.onupgradeneeded=function(){return e.result.createObjectStore(r)};var o=n(e);return function(t,n){return o.then((function(e){return n(e.transaction(r,t).objectStore(r))}))}}var e;function o(){return e||(e=r("keyval-store","keyval")),e}function u(t,r){return t.openCursor().onsuccess=function(){this.result&&(r(this.result),this.result.continue())},n(t.transaction)}t.clear=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readwrite",(function(t){return t.clear(),n(t.transaction)}))},t.createStore=r,t.del=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return r.delete(t),n(r.transaction)}))},t.delMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.delete(t)})),n(r.transaction)}))},t.entries=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(r){if(r.getAll&&r.getAllKeys)return Promise.all([n(r.getAllKeys()),n(r.getAll())]).then((function(t){var n=_slicedToArray(t,2),r=n[0],e=n[1];return r.map((function(t,n){return[t,e[n]]}))}));var e=[];return t("readonly",(function(t){return u(t,(function(t){return e.push([t.key,t.value])})).then((function(){return e}))}))}))},t.get=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return n(r.get(t))}))},t.getMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return Promise.all(t.map((function(t){return n(r.get(t))})))}))},t.keys=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAllKeys)return n(t.getAllKeys());var r=[];return u(t,(function(t){return r.push(t.key)})).then((function(){return r}))}))},t.promisifyRequest=n,t.set=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return e.put(r,t),n(e.transaction)}))},t.setMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.put(t[1],t[0])})),n(r.transaction)}))},t.update=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return new Promise((function(o,u){e.get(t).onsuccess=function(){try{e.put(r(this.result),t),o(n(e.transaction))}catch(t){u(t)}}}))}))},t.values=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAll)return n(t.getAll());var r=[];return u(t,(function(t){return r.push(t.value)})).then((function(){return r}))}))},Object.defineProperty(t,"__esModule",{value:!0})}));
/**
* Custom IDB Convenience Functions Wrapper
**/
const IDB = (function () {
let { get, del, entries, update, keys } = idbKeyval;
return {
async set(key, newValue) {
let changeObj = {};
await update(key, (oldValue) => {
if (oldValue != null) changeObj.oldValue = oldValue;
changeObj.newValue = newValue;
return newValue;
});
return changeObj;
},
get(key, defaultValue) {
return get(key).then((resp) => {
return defaultValue && resp == null ? defaultValue : resp;
});
},
del(key) {
return del(key);
},
find(keyRegExp, includeValues = false) {
if (!(keyRegExp instanceof RegExp)) throw new Error("Invalid RegExp");
if (!includeValues)
return keys().then((allKeys) =>
allKeys.filter((key) => keyRegExp.test(key))
);
return entries().then((allItems) => {
const matchingKeys = allItems.filter((keyVal) =>
keyRegExp.test(keyVal[0])
);
return matchingKeys.reduce((obj, keyVal) => {
const [key, val] = keyVal;
obj[key] = val;
return obj;
}, {});
});
},
};
})();

File diff suppressed because one or more lines are too long

2
assets/lib/no-sleep.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

1
assets/lib/platform.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -22,15 +22,19 @@
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<title>Book Manager - Local</title> <title>AnthoLume - Local</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script> <script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script> <script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/idb-keyval.js"></script> <script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/lib/sw-helper.js"></script> <script src="/assets/lib/sw-helper.min.js"></script>
<!-- Local -->
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script> <script src="/assets/index.js"></script>
<script src="/assets/local/index.js"></script> <script src="/assets/local/index.js"></script>

View File

@@ -1,8 +1,17 @@
{ {
"short_name": "Book Manager", "name": "AnthoLume",
"name": "Book Manager", "short_name": "AnthoLume",
"lang": "en-US",
"theme_color": "#1F2937", "theme_color": "#1F2937",
"display": "standalone", "display": "standalone",
"scope": "/", "scope": "/",
"start_url": "/" "start_url": "/",
"icons": [
{
"purpose": "any",
"sizes": "512x512",
"src": "/assets/icons/icon512.png",
"type": "image/png"
}
]
} }

View File

@@ -14,24 +14,27 @@
/> />
<meta name="theme-color" content="#D2B48C" /> <meta name="theme-color" content="#D2B48C" />
<title>Book Manager - Reader</title> <title>AnthoLume - Reader</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries --> <!-- Libraries -->
<script src="/assets/lib/platform.js"></script> <script src="/assets/lib/platform.min.js"></script>
<script src="/assets/lib/jszip.min.js"></script> <script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script> <script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/no-sleep.js"></script> <script src="/assets/lib/no-sleep.min.js"></script>
<script src="/assets/lib/idb-keyval.js"></script> <script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/lib/sw-helper.js"></script>
<!-- Reader --> <!-- Reader -->
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script> <script src="/assets/index.js"></script>
<script src="/assets/reader/index.js"></script> <script src="/assets/reader/index.js"></script>
<style> <style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html, html,
body { body {
overscroll-behavior-y: none; overscroll-behavior-y: none;

View File

@@ -73,7 +73,6 @@ function populateMetadata(data) {
**/ **/
class EBookReader { class EBookReader {
bookState = { bookState = {
currentWord: 0,
pages: 0, pages: 0,
percentage: 0, percentage: 0,
progress: "", progress: "",
@@ -115,27 +114,11 @@ class EBookReader {
* Load progress and generate locations * Load progress and generate locations
**/ **/
async setupReader() { async setupReader() {
// Get Word Count (If Needed) // Get Word Count
if (this.bookState.words == 0) this.bookState.words = await this.countWords();
this.bookState.words = await this.countWords();
// Load Progress // Load Progress
let { cfi } = await this.getCFIFromXPath(this.bookState.progress); let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
this.bookState.currentWord = cfi
? this.bookState.percentage * (this.bookState.words / 100)
: 0;
let getStats = function () {
// Start Timer
this.bookState.pageStart = Date.now();
// Get Stats
let stats = this.getBookStats();
this.updateBookStatElements(stats);
}.bind(this);
// Register Content Hook
this.rendition.hooks.content.register(getStats);
// Update Position // Update Position
await this.setPosition(cfi); await this.setPosition(cfi);
@@ -143,8 +126,14 @@ class EBookReader {
// 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);
// Set Progress Element & Highlight
this.bookState.progressElement = element; this.bookState.progressElement = element;
this.highlightPositionMarker(); this.highlightPositionMarker();
// Update Stats & Page Start
let stats = await this.getBookStats();
this.updateBookStatElements(stats);
this.bookState.pageStart = Date.now();
} }
initDevice() { initDevice() {
@@ -660,7 +649,7 @@ class EBookReader {
this.bookState.pageStart = Date.now(); this.bookState.pageStart = Date.now();
// Update Stats // Update Stats
let stats = this.getBookStats(); let stats = await this.getBookStats();
this.updateBookStatElements(stats); this.updateBookStatElements(stats);
// Create Progress // Create Progress
@@ -674,16 +663,11 @@ class EBookReader {
// Render Previous Page // Render Previous Page
await this.rendition.prev(); await this.rendition.prev();
// Update Current Word
let pageWords = await this.getVisibleWordCount();
this.bookState.currentWord -= pageWords;
if (this.bookState.currentWord < 0) this.bookState.currentWord = 0;
// Reset Read Timer // Reset Read Timer
this.bookState.pageStart = Date.now(); this.bookState.pageStart = Date.now();
// Update Stats // Update Stats
let stats = this.getBookStats(); let stats = await this.getBookStats();
this.updateBookStatElements(stats); this.updateBookStatElements(stats);
// Create Progress // Create Progress
@@ -719,9 +703,8 @@ class EBookReader {
// Update Current Word // Update Current Word
let pageWords = await this.getVisibleWordCount(); let pageWords = await this.getVisibleWordCount();
let startingWord = this.bookState.currentWord; let currentWord = await this.getBookWordPosition();
let percentRead = pageWords / this.bookState.words; let percentRead = pageWords / this.bookState.words;
this.bookState.currentWord += pageWords;
let pageWPM = pageWords / (elapsedTime / 60000); let pageWPM = pageWords / (elapsedTime / 60000);
console.log("[createActivity] Page WPM:", pageWPM); console.log("[createActivity] Page WPM:", pageWPM);
@@ -740,10 +723,10 @@ class EBookReader {
// Exclude 0 Pages // Exclude 0 Pages
if (totalPages == 0) if (totalPages == 0)
return console.log("[createActivity] Invalid Total Pages (0)"); return console.warn("[createActivity] Invalid Total Pages (0)");
let currentPage = Math.round( let currentPage = Math.round(
(startingWord * totalPages) / this.bookState.words (currentWord * totalPages) / this.bookState.words
); );
// Create Activity Event // Create Activity Event
@@ -805,6 +788,8 @@ class EBookReader {
// Update Pointers // Update Pointers
let currentCFI = await this.rendition.currentLocation(); let currentCFI = await this.rendition.currentLocation();
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi); let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
let currentWord = await this.getBookWordPosition();
console.log("[createProgress] Current Word:", currentWord);
this.bookState.progress = xpath; this.bookState.progress = xpath;
this.bookState.progressElement = element; this.bookState.progressElement = element;
@@ -814,9 +799,7 @@ class EBookReader {
device_id: this.readerSettings.deviceID, device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName, device: this.readerSettings.deviceName,
percentage: percentage:
Math.round( Math.round((currentWord / this.bookState.words) * 100000) / 100000,
(this.bookState.currentWord / this.bookState.words) * 100000
) / 100000,
progress: this.bookState.progress, progress: this.bookState.progress,
}; };
@@ -885,12 +868,13 @@ class EBookReader {
/** /**
* Get chapter pages, name and progress percentage * Get chapter pages, name and progress percentage
**/ **/
getBookStats() { async getBookStats() {
let currentProgress = this.sectionProgress(); let currentProgress = this.sectionProgress();
if (!currentProgress) return; if (!currentProgress) return;
let { sectionPages, sectionCurrentPage } = currentProgress; let { sectionPages, sectionCurrentPage } = currentProgress;
let currentLocation = this.rendition.currentLocation(); let currentLocation = this.rendition.currentLocation();
let currentWord = await this.getBookWordPosition();
let currentTOC = this.book.navigation.toc.find( let currentTOC = this.book.navigation.toc.find(
(item) => item.href == currentLocation.start.href (item) => item.href == currentLocation.start.href
@@ -901,9 +885,7 @@ class EBookReader {
sectionTotalPages: sectionPages, sectionTotalPages: sectionPages,
chapterName: currentTOC ? currentTOC.label.trim() : "N/A", chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
percentage: percentage:
Math.round( Math.round((currentWord / this.bookState.words) * 10000) / 100,
(this.bookState.currentWord / this.bookState.words) * 10000
) / 100,
}; };
} }
@@ -928,7 +910,7 @@ class EBookReader {
* Get XPath from current location * Get XPath from current location
**/ **/
async getXPathFromCFI(cfi) { async getXPathFromCFI(cfi) {
// Get DocFragment (current book spline index) // Get DocFragment (Spine Index)
let startCFI = cfi.replace("epubcfi(", ""); let startCFI = cfi.replace("epubcfi(", "");
let docFragmentIndex = let docFragmentIndex =
this.book.spine.spineItems.find((item) => this.book.spine.spineItems.find((item) =>
@@ -936,58 +918,62 @@ class EBookReader {
).index + 1; ).index + 1;
// Base Progress // Base Progress
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body"; let basePos = "/body/DocFragment[" + docFragmentIndex + "]/body";
// Get first visible node // Get First Node & Element Reference
let contents = this.rendition.getContents()[0]; let contents = this.rendition.getContents()[0];
let node = contents.range(cfi).startContainer; let currentNode = contents.range(cfi).startContainer;
let element = null; let element =
currentNode.nodeType == Node.ELEMENT_NODE
? currentNode
: currentNode.parentElement;
// Walk upwards and build progress until body // XPath Reference
let childPos = ""; let allPos = "";
while (node.nodeName != "BODY") {
let ownValue;
switch (node.nodeType) { // Walk Upwards
case Node.ELEMENT_NODE: while (currentNode.nodeName != "BODY") {
// Store First Element Node // Get Parent
if (!element) element = node; let parentElement = currentNode.parentElement;
let relativeIndex =
Array.from(node.parentNode.children)
.filter((item) => item.nodeName == node.nodeName)
.indexOf(node) + 1;
ownValue = node.nodeName.toLowerCase() + "[" + relativeIndex + "]"; // Unknown Node -> Update Reference
break; if (currentNode.nodeType != Node.ELEMENT_NODE) {
case Node.ATTRIBUTE_NODE: console.log("[getXPathFromCFI] Unknown Node Type:", currentNode);
ownValue = "@" + node.nodeName; currentNode = parentElement;
break; continue;
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
ownValue = "text()";
break;
case Node.PROCESSING_INSTRUCTION_NODE:
ownValue = "processing-instruction()";
break;
case Node.COMMENT_NODE:
ownValue = "comment()";
break;
case Node.DOCUMENT_NODE:
ownValue = "";
break;
default:
ownValue = "";
break;
} }
// Prepend childPos & Update node reference /**
childPos = "/" + ownValue + childPos; * Exclude A tags. This could potentially be all inline elements:
node = node.parentNode; * https://github.com/koreader/crengine/blob/master/cr3gui/data/epub.css#L149
**/
while (parentElement.nodeName == "A") {
parentElement = parentElement.parentElement;
}
/**
* Note: This is depth / document order first, which means that this
* _could_ return incorrect results when dealing with nested "A" tags
* (dependent on how KOReader deals with nested "A" tags)
**/
let allDescendents = parentElement.querySelectorAll(currentNode.nodeName);
let relativeIndex = Array.from(allDescendents).indexOf(currentNode) + 1;
// Get Node Position
let nodePos =
currentNode.nodeName.toLowerCase() + "[" + relativeIndex + "]";
// Update Reference
currentNode = parentElement;
// Update Position
allPos = "/" + nodePos + allPos;
} }
let xpath = newPos + childPos; // Combine XPath
let xpath = basePos + allPos;
// Return derived progress // Return Derived Progress
return { xpath, element }; return { xpath, element };
} }
@@ -995,19 +981,13 @@ class EBookReader {
* Get CFI from current location * Get CFI from current location
**/ **/
async getCFIFromXPath(xpath) { async getCFIFromXPath(xpath) {
// XPath Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
//
// - /body/DocFragment[15] = 15th item in book spline
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
// - [...]/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("[getCFIFromXPath] No XPath Match");
return {}; return {};
} }
@@ -1039,7 +1019,7 @@ class EBookReader {
.find((item) => item.sectionIndex == spinePosition)?.document || .find((item) => item.sectionIndex == spinePosition)?.document ||
sectionItem.document; sectionItem.document;
// Derive XPath & Namespace // Derive Namespace & XPath
let namespaceURI = docItem.documentElement.namespaceURI; let namespaceURI = docItem.documentElement.namespaceURI;
let remainingXPath = xpath let remainingXPath = xpath
// Replace with new base // Replace with new base
@@ -1049,6 +1029,26 @@ class EBookReader {
// Remove potential trailing `text()` // Remove potential trailing `text()`
.replace(/\/text\(\)(\[\d+\])?$/, ""); .replace(/\/text\(\)(\[\d+\])?$/, "");
// XPath to Element
let derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, "body")
.split("/")
.reduce((el, item) => {
// No Match
if (!el) return null;
// Non Index
let indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) return el.querySelector(item);
// Get @ Index
let tag = indexMatch[1];
let index = parseInt(indexMatch[2]) - 1;
return el.querySelectorAll(tag)[index];
}, docItem);
console.log("[getCFIFromXPath] Selector Element:", derivedSelectorElement);
// Validate Namespace // Validate Namespace
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:"); if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
@@ -1065,8 +1065,25 @@ class EBookReader {
} }
); );
// Get Element & CFI /**
let element = docSearch.iterateNext(); * There are two ways to do this. One via XPath, and the other via derived
* CSS selectors. Unfortunately it seems like KOReaders XPath implementation
* is a little wonky, requiring the need for CSS Selectors.
*
* For example the following XPath was generated by KOReader:
* "/body/DocFragment[19]/body/h1/img.0"
*
* In reality, the XPath should have been (note the 'a'):
* "/body/DocFragment[19]/body/h1/a/img.0"
*
* Unfortunately due to the above, `docItem.evaluate` will not find the
* element. So as an alternative I thought it would be possible to derive
* a CSS selector. I think this should be fully comprehensive; AFAICT
* KOReader only creates XPaths referencing HTML tag names and indexes.
**/
// Get Element & CFI (XPath -> CSS Selector Fallback)
let element = docSearch.iterateNext() || derivedSelectorElement;
let cfi = sectionItem.cfiFromElement(element); let cfi = sectionItem.cfiFromElement(element);
return { cfi, element }; return { cfi, element };
@@ -1076,6 +1093,43 @@ class EBookReader {
* Get visible word count - used for reading stats * Get visible word count - used for reading stats
**/ **/
async getVisibleWordCount() { async getVisibleWordCount() {
let visibleText = await this.getVisibleText();
return visibleText.trim().split(/\s+/).length;
}
/**
* Gets the word number of the whole book for the first visible word.
**/
async getBookWordPosition() {
// Get Contents & Spine
let contents = this.rendition.getContents()[0];
let spineItem = this.book.spine.get(contents.sectionIndex);
// Get CFI Range
let firstCFI = spineItem.cfiFromElement(
spineItem.document.body.children[0]
);
let currentLocation = await this.rendition.currentLocation();
let cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
// Get Chapter Text (Before Current Position)
let textRange = await this.book.getRange(cfiRange);
let chapterText = textRange.toString();
// Get Chapter & Book Positions
let chapterWordPosition = chapterText.trim().split(/\s+/).length;
let preChapterWordPosition = this.book.spine.spineItems
.slice(0, contents.sectionIndex)
.reduce((totalCount, item) => totalCount + item.wordCount, 0);
// Return Current Word Pointer
return chapterWordPosition + preChapterWordPosition;
}
/**
* Get visible text - used for word counts
**/
async getVisibleText() {
// Force Expand & Resize (Race Condition Issue) // Force Expand & Resize (Race Condition Issue)
this.rendition.manager.visible().forEach((item) => item.expand()); this.rendition.manager.visible().forEach((item) => item.expand());
@@ -1092,7 +1146,7 @@ class EBookReader {
let visibleText = textRange.toString(); let visibleText = textRange.toString();
// Split on Whitespace // Split on Whitespace
return visibleText.trim().split(/\s+/).length; return visibleText;
} }
/** /**
@@ -1147,14 +1201,17 @@ class EBookReader {
* of progress percentage. Implementation returns the same number as the * of progress percentage. Implementation returns the same number as the
* server side implementation. * server side implementation.
**/ **/
countWords() { async countWords() {
// Iterate over each item in the spine, render, and count words. let spineWC = await Promise.all(
return this.book.spine.spineItems.reduce(async (totalCount, item) => { this.book.spine.spineItems.map(async (item) => {
let currentCount = await totalCount; let newDoc = await item.load(this.book.load.bind(this.book));
let newDoc = await item.load(this.book.load.bind(this.book)); let spineWords = newDoc.innerText.trim().split(/\s+/).length;
let itemCount = newDoc.innerText.trim().split(/\s+/).length; item.wordCount = spineWords;
return currentCount + itemCount; return spineWords;
}, 0); })
);
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
} }
/** /**

File diff suppressed because one or more lines are too long

View File

@@ -63,6 +63,7 @@ const PRECACHE_ASSETS = [
"/reader", "/reader",
"/assets/local/index.js", "/assets/local/index.js",
"/assets/reader/index.js", "/assets/reader/index.js",
"/assets/icons/icon512.png",
"/assets/images/no-cover.jpg", "/assets/images/no-cover.jpg",
"/assets/reader/readerThemes.css", "/assets/reader/readerThemes.css",
@@ -70,14 +71,14 @@ const PRECACHE_ASSETS = [
"/manifest.json", "/manifest.json",
"/assets/index.js", "/assets/index.js",
"/assets/style.css", "/assets/style.css",
"/assets/common.js",
// Library Assets // Library Assets
"/assets/lib/platform.js", "/assets/lib/platform.min.js",
"/assets/lib/jszip.min.js", "/assets/lib/jszip.min.js",
"/assets/lib/epub.min.js", "/assets/lib/epub.min.js",
"/assets/lib/no-sleep.js", "/assets/lib/no-sleep.min.js",
"/assets/lib/idb-keyval.js", "/assets/lib/idb-keyval.min.js",
"/assets/lib/sw-helper.js",
]; ];
// ------------------------------------------------------- // // ------------------------------------------------------- //

BIN
banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
banner.xcf Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# Book Manager - SyncNinja KOReader Plugin # AnthoLume - SyncNinja KOReader Plugin
This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include: This is AnthoLume's KOReader Plugin called `syncninja.koplugin`. Features include:
- Syncing read activity - Syncing read activity
- Uploading documents - Uploading documents
@@ -12,10 +12,10 @@ Copy the `syncninja.koplugin` directory to the `plugins` directory for your KORe
## Configuration ## Configuration
You must configure the BookManager server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves. You must configure the AnthoLume server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves.
## KOSync Compatibility ## KOSync Compatibility
BookManager implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu. AnthoLume implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu.
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above. The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above.

View File

@@ -853,8 +853,8 @@ function SyncNinja:getLocalDocumentMetadata()
docsettings:saveSetting("partial_md5_checksum", pmd5) docsettings:saveSetting("partial_md5_checksum", pmd5)
end end
-- Get Document Props -- Get Document Props & Ensure Not Nil
local doc_props = docsettings:readSetting("doc_props") local doc_props = docsettings:readSetting("doc_props") or {}
local fdoc = bookinfo_books[v.file] or {} local fdoc = bookinfo_books[v.file] or {}
-- Update or Create -- Update or Create

View File

@@ -31,9 +31,9 @@ type Config struct {
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Version: "0.0.2", Version: "0.0.1",
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")), DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")), DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
ConfigPath: getEnv("CONFIG_PATH", "/config"), ConfigPath: getEnv("CONFIG_PATH", "/config"),
DataPath: getEnv("DATA_PATH", "/data"), DataPath: getEnv("DATA_PATH", "/data"),
ListenPort: getEnv("LISTEN_PORT", "8585"), ListenPort: getEnv("LISTEN_PORT", "8585"),

View File

@@ -23,6 +23,9 @@ var ddl string
//go:embed update_temp_tables.sql //go:embed update_temp_tables.sql
var tsql string var tsql string
//go:embed update_document_user_statistics.sql
var doc_user_stat_sql string
func NewMgr(c *config.Config) *DBManager { func NewMgr(c *config.Config) *DBManager {
// Create Manager // Create Manager
dbm := &DBManager{ dbm := &DBManager{
@@ -56,6 +59,25 @@ func NewMgr(c *config.Config) *DBManager {
return dbm return dbm
} }
func (dbm *DBManager) Shutdown() error {
return dbm.DB.Close()
}
func (dbm *DBManager) UpdateDocumentUserStatistic(documentID string, userID string) error {
// Prepare Statement
stmt, err := dbm.DB.PrepareContext(dbm.Ctx, doc_user_stat_sql)
if err != nil {
return err
}
defer stmt.Close()
// Execute
if _, err := stmt.ExecContext(dbm.Ctx, documentID, userID); err != nil {
return err
}
return nil
}
func (dbm *DBManager) CacheTempTables() error { func (dbm *DBManager) CacheTempTables() error {
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil { if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
return err return err

View File

@@ -122,13 +122,13 @@ func (dt *databaseTest) TestActivity() {
// Add Item // Add Item
activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{ activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{
DocumentID: documentID, DocumentID: documentID,
DeviceID: deviceID, DeviceID: deviceID,
UserID: userID, UserID: userID,
StartTime: d.UTC().Format(time.RFC3339), StartTime: d.UTC().Format(time.RFC3339),
Duration: 60, Duration: 60,
Page: counter, StartPercentage: float64(counter) / 100.0,
Pages: 100, EndPercentage: float64(counter+1) / 100.0,
}) })
// Validate No Error // Validate No Error
@@ -143,9 +143,7 @@ func (dt *databaseTest) TestActivity() {
} }
// Initiate Cache // Initiate Cache
if err := dt.dbm.CacheTempTables(); err != nil { dt.dbm.CacheTempTables()
t.Fatalf(`Error: %v`, err)
}
// Validate Exists // Validate Exists
existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{

View File

@@ -9,14 +9,15 @@ import (
) )
type Activity struct { type Activity struct {
UserID string `json:"user_id"` ID int64 `json:"id"`
DocumentID string `json:"document_id"` UserID string `json:"user_id"`
DeviceID string `json:"device_id"` DocumentID string `json:"document_id"`
CreatedAt string `json:"created_at"` DeviceID string `json:"device_id"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
Page int64 `json:"page"` StartPercentage float64 `json:"start_percentage"`
Pages int64 `json:"pages"` EndPercentage float64 `json:"end_percentage"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
CreatedAt string `json:"created_at"`
} }
type Device struct { type Device struct {
@@ -63,10 +64,8 @@ type DocumentUserStatistic struct {
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
LastRead string `json:"last_read"` LastRead string `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds int64 `json:"total_time_seconds"` TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"` ReadPercentage float64 `json:"read_percentage"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
WordsRead int64 `json:"words_read"` WordsRead int64 `json:"words_read"`
Wpm float64 `json:"wpm"` Wpm float64 `json:"wpm"`
@@ -85,18 +84,6 @@ type Metadatum struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
type RawActivity struct {
ID int64 `json:"id"`
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
StartTime string `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
CreatedAt string `json:"created_at"`
}
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Pass *string `json:"-"` Pass *string `json:"-"`
@@ -119,27 +106,14 @@ type UserStreak struct {
type ViewDocumentUserStatistic struct { type ViewDocumentUserStatistic struct {
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
LastRead string `json:"last_read"` LastRead interface{} `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"` TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"` ReadPercentage sql.NullFloat64 `json:"read_percentage"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
WordsRead interface{} `json:"words_read"` WordsRead interface{} `json:"words_read"`
Wpm int64 `json:"wpm"` Wpm int64 `json:"wpm"`
} }
type ViewRescaledActivity struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
CreatedAt string `json:"created_at"`
StartTime string `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
}
type ViewUserStreak struct { type ViewUserStreak struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Window string `json:"window"` Window string `json:"window"`

View File

@@ -1,12 +1,12 @@
-- name: AddActivity :one -- name: AddActivity :one
INSERT INTO raw_activity ( INSERT INTO activity (
user_id, user_id,
document_id, document_id,
device_id, device_id,
start_time, start_time,
duration, duration,
page, start_percentage,
pages end_percentage
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
@@ -43,8 +43,7 @@ WITH filtered_activity AS (
user_id, user_id,
start_time, start_time,
duration, duration,
page, ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
pages
FROM activity FROM activity
WHERE WHERE
activity.user_id = $user_id activity.user_id = $user_id
@@ -65,8 +64,7 @@ SELECT
title, title,
author, author,
duration, duration,
page, read_percentage
pages
FROM filtered_activity AS activity FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id LEFT JOIN documents ON documents.id = activity.document_id
LEFT JOIN users ON users.id = activity.user_id; LEFT JOIN users ON users.id = activity.user_id;
@@ -82,9 +80,9 @@ WITH RECURSIVE last_30_days AS (
), ),
filtered_activity AS ( filtered_activity AS (
SELECT SELECT
user_id, user_id,
start_time, start_time,
duration duration
FROM activity FROM activity
WHERE start_time > DATE('now', '-31 days') WHERE start_time > DATE('now', '-31 days')
AND activity.user_id = $user_id AND activity.user_id = $user_id
@@ -142,41 +140,6 @@ ORDER BY devices.last_synced DESC;
SELECT * FROM documents SELECT * FROM documents
WHERE id = $document_id LIMIT 1; WHERE id = $document_id LIMIT 1;
-- name: GetDocumentDaysRead :one
WITH document_days AS (
SELECT DATE(start_time, time_offset) AS dates
FROM activity
JOIN users ON users.id = activity.user_id
WHERE document_id = $document_id
AND user_id = $user_id
GROUP BY dates
)
SELECT CAST(COUNT(*) AS INTEGER) AS days_read
FROM document_days;
-- name: GetDocumentReadStats :one
SELECT
COUNT(DISTINCT page) AS pages_read,
SUM(duration) AS total_time
FROM activity
WHERE document_id = $document_id
AND user_id = $user_id
AND start_time >= $start_time;
-- name: GetDocumentReadStatsCapped :one
WITH capped_stats AS (
SELECT MIN(SUM(duration), CAST($page_duration_cap AS INTEGER)) AS durations
FROM activity
WHERE document_id = $document_id
AND user_id = $user_id
AND start_time >= $start_time
GROUP BY page
)
SELECT
CAST(COUNT(*) AS INTEGER) AS pages_read,
CAST(SUM(durations) AS INTEGER) AS total_time
FROM capped_stats;
-- name: GetDocumentWithStats :one -- name: GetDocumentWithStats :one
SELECT SELECT
docs.id, docs.id,
@@ -189,23 +152,21 @@ SELECT
docs.words, docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm, CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page, COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds, COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset) STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read, AS last_read,
CASE ROUND(CAST(CASE
WHEN dus.percentage > 97.0 THEN 100.0
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
END AS percentage, ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL) / (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_page END AS INTEGER) AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = $user_id LEFT JOIN users ON users.id = $user_id
LEFT JOIN LEFT JOIN
@@ -221,6 +182,16 @@ ORDER BY created_at DESC
LIMIT $limit LIMIT $limit
OFFSET $offset; OFFSET $offset;
-- name: GetDocumentsSize :one
SELECT
COUNT(rowid) AS length
FROM documents AS docs
WHERE $query IS NULL OR (
docs.title LIKE $query OR
docs.author LIKE $query
)
LIMIT 1;
-- name: GetDocumentsWithStats :many -- name: GetDocumentsWithStats :many
SELECT SELECT
docs.id, docs.id,
@@ -233,31 +204,36 @@ SELECT
docs.words, docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm, CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page, COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds, COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset) STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read, AS last_read,
CASE ROUND(CAST(CASE
WHEN dus.percentage > 97.0 THEN 100.0
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
END AS percentage, ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CASE CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
ROUND( ROUND(
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL) / (dus.read_percentage * 100.0)
) )
END AS seconds_per_page END AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = $user_id LEFT JOIN users ON users.id = $user_id
LEFT JOIN LEFT JOIN
document_user_statistics AS dus document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE docs.deleted = false WHERE
docs.deleted = false AND (
$query IS NULL OR (
docs.title LIKE $query OR
docs.author LIKE $query
)
)
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT $limit LIMIT $limit
OFFSET $offset; OFFSET $offset;
@@ -298,20 +274,6 @@ WHERE id = $user_id LIMIT 1;
SELECT * FROM user_streaks SELECT * FROM user_streaks
WHERE user_id = $user_id; WHERE user_id = $user_id;
-- name: GetUsers :many
SELECT * FROM users
WHERE
users.id = $user
OR ?1 IN (
SELECT id
FROM users
WHERE id = $user
AND admin = 1
)
ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
-- name: GetWPMLeaderboard :many -- name: GetWPMLeaderboard :many
SELECT SELECT
user_id, user_id,
@@ -328,35 +290,18 @@ ORDER BY wpm DESC;
SELECT SELECT
CAST(value AS TEXT) AS id, CAST(value AS TEXT) AS id,
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file, CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata CAST((documents.id IS NULL) AS BOOLEAN) AS want_metadata
FROM json_each(?1) FROM json_each(?1)
LEFT JOIN documents LEFT JOIN documents
ON value = documents.id ON value = documents.id
WHERE ( WHERE (
documents.id IS NOT NULL documents.id IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
AND ( AND documents.filepath IS NULL
documents.synced = false
OR documents.filepath IS NULL
)
) )
OR (documents.id IS NULL) OR (documents.id IS NULL)
OR CAST($document_ids AS TEXT) != CAST($document_ids AS TEXT); OR CAST($document_ids AS TEXT) != CAST($document_ids AS TEXT);
-- name: UpdateDocumentDeleted :one
UPDATE documents
SET
deleted = $deleted
WHERE id = $id
RETURNING *;
-- name: UpdateDocumentSync :one
UPDATE documents
SET
synced = $synced
WHERE id = $id
RETURNING *;
-- name: UpdateProgress :one -- name: UpdateProgress :one
INSERT OR REPLACE INTO document_progress ( INSERT OR REPLACE INTO document_progress (
user_id, user_id,

View File

@@ -7,53 +7,52 @@ package database
import ( import (
"context" "context"
"database/sql"
"strings" "strings"
) )
const addActivity = `-- name: AddActivity :one const addActivity = `-- name: AddActivity :one
INSERT INTO raw_activity ( INSERT INTO activity (
user_id, user_id,
document_id, document_id,
device_id, device_id,
start_time, start_time,
duration, duration,
page, start_percentage,
pages end_percentage
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, user_id, document_id, device_id, start_time, page, pages, duration, created_at RETURNING id, user_id, document_id, device_id, start_time, start_percentage, end_percentage, duration, created_at
` `
type AddActivityParams struct { type AddActivityParams struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
Page int64 `json:"page"` StartPercentage float64 `json:"start_percentage"`
Pages int64 `json:"pages"` EndPercentage float64 `json:"end_percentage"`
} }
func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (RawActivity, error) { func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (Activity, error) {
row := q.db.QueryRowContext(ctx, addActivity, row := q.db.QueryRowContext(ctx, addActivity,
arg.UserID, arg.UserID,
arg.DocumentID, arg.DocumentID,
arg.DeviceID, arg.DeviceID,
arg.StartTime, arg.StartTime,
arg.Duration, arg.Duration,
arg.Page, arg.StartPercentage,
arg.Pages, arg.EndPercentage,
) )
var i RawActivity var i Activity
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.UserID, &i.UserID,
&i.DocumentID, &i.DocumentID,
&i.DeviceID, &i.DeviceID,
&i.StartTime, &i.StartTime,
&i.Page, &i.StartPercentage,
&i.Pages, &i.EndPercentage,
&i.Duration, &i.Duration,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -154,8 +153,7 @@ WITH filtered_activity AS (
user_id, user_id,
start_time, start_time,
duration, duration,
page, ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
pages
FROM activity FROM activity
WHERE WHERE
activity.user_id = ?1 activity.user_id = ?1
@@ -176,8 +174,7 @@ SELECT
title, title,
author, author,
duration, duration,
page, read_percentage
pages
FROM filtered_activity AS activity FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id LEFT JOIN documents ON documents.id = activity.document_id
LEFT JOIN users ON users.id = activity.user_id LEFT JOIN users ON users.id = activity.user_id
@@ -192,13 +189,12 @@ type GetActivityParams struct {
} }
type GetActivityRow struct { type GetActivityRow struct {
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
Title *string `json:"title"` Title *string `json:"title"`
Author *string `json:"author"` Author *string `json:"author"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
Page int64 `json:"page"` ReadPercentage float64 `json:"read_percentage"`
Pages int64 `json:"pages"`
} }
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) { func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
@@ -222,8 +218,7 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
&i.Title, &i.Title,
&i.Author, &i.Author,
&i.Duration, &i.Duration,
&i.Page, &i.ReadPercentage,
&i.Pages,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -249,9 +244,9 @@ WITH RECURSIVE last_30_days AS (
), ),
filtered_activity AS ( filtered_activity AS (
SELECT SELECT
user_id, user_id,
start_time, start_time,
duration duration
FROM activity FROM activity
WHERE start_time > DATE('now', '-31 days') WHERE start_time > DATE('now', '-31 days')
AND activity.user_id = ?1 AND activity.user_id = ?1
@@ -465,98 +460,6 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
return i, err return i, err
} }
const getDocumentDaysRead = `-- name: GetDocumentDaysRead :one
WITH document_days AS (
SELECT DATE(start_time, time_offset) AS dates
FROM activity
JOIN users ON users.id = activity.user_id
WHERE document_id = ?1
AND user_id = ?2
GROUP BY dates
)
SELECT CAST(COUNT(*) AS INTEGER) AS days_read
FROM document_days
`
type GetDocumentDaysReadParams struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
}
func (q *Queries) GetDocumentDaysRead(ctx context.Context, arg GetDocumentDaysReadParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getDocumentDaysRead, arg.DocumentID, arg.UserID)
var days_read int64
err := row.Scan(&days_read)
return days_read, err
}
const getDocumentReadStats = `-- name: GetDocumentReadStats :one
SELECT
COUNT(DISTINCT page) AS pages_read,
SUM(duration) AS total_time
FROM activity
WHERE document_id = ?1
AND user_id = ?2
AND start_time >= ?3
`
type GetDocumentReadStatsParams struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
StartTime string `json:"start_time"`
}
type GetDocumentReadStatsRow struct {
PagesRead int64 `json:"pages_read"`
TotalTime sql.NullFloat64 `json:"total_time"`
}
func (q *Queries) GetDocumentReadStats(ctx context.Context, arg GetDocumentReadStatsParams) (GetDocumentReadStatsRow, error) {
row := q.db.QueryRowContext(ctx, getDocumentReadStats, arg.DocumentID, arg.UserID, arg.StartTime)
var i GetDocumentReadStatsRow
err := row.Scan(&i.PagesRead, &i.TotalTime)
return i, err
}
const getDocumentReadStatsCapped = `-- name: GetDocumentReadStatsCapped :one
WITH capped_stats AS (
SELECT MIN(SUM(duration), CAST(?1 AS INTEGER)) AS durations
FROM activity
WHERE document_id = ?2
AND user_id = ?3
AND start_time >= ?4
GROUP BY page
)
SELECT
CAST(COUNT(*) AS INTEGER) AS pages_read,
CAST(SUM(durations) AS INTEGER) AS total_time
FROM capped_stats
`
type GetDocumentReadStatsCappedParams struct {
PageDurationCap int64 `json:"page_duration_cap"`
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
StartTime string `json:"start_time"`
}
type GetDocumentReadStatsCappedRow struct {
PagesRead int64 `json:"pages_read"`
TotalTime int64 `json:"total_time"`
}
func (q *Queries) GetDocumentReadStatsCapped(ctx context.Context, arg GetDocumentReadStatsCappedParams) (GetDocumentReadStatsCappedRow, error) {
row := q.db.QueryRowContext(ctx, getDocumentReadStatsCapped,
arg.PageDurationCap,
arg.DocumentID,
arg.UserID,
arg.StartTime,
)
var i GetDocumentReadStatsCappedRow
err := row.Scan(&i.PagesRead, &i.TotalTime)
return i, err
}
const getDocumentWithStats = `-- name: GetDocumentWithStats :one const getDocumentWithStats = `-- name: GetDocumentWithStats :one
SELECT SELECT
docs.id, docs.id,
@@ -569,23 +472,21 @@ SELECT
docs.words, docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm, CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page, COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds, COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset) STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read, AS last_read,
CASE ROUND(CAST(CASE
WHEN dus.percentage > 97.0 THEN 100.0
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
END AS percentage, ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL) / (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_page END AS INTEGER) AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = ?1 LEFT JOIN users ON users.id = ?1
LEFT JOIN LEFT JOIN
@@ -602,22 +503,20 @@ type GetDocumentWithStatsParams struct {
} }
type GetDocumentWithStatsRow struct { type GetDocumentWithStatsRow struct {
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title"` Title *string `json:"title"`
Author *string `json:"author"` Author *string `json:"author"`
Description *string `json:"description"` Description *string `json:"description"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"` Isbn13 *string `json:"isbn13"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Words *int64 `json:"words"` Words *int64 `json:"words"`
Wpm int64 `json:"wpm"` Wpm int64 `json:"wpm"`
Page int64 `json:"page"` ReadPercentage float64 `json:"read_percentage"`
Pages int64 `json:"pages"` TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"` LastRead interface{} `json:"last_read"`
TotalTimeSeconds int64 `json:"total_time_seconds"` Percentage float64 `json:"percentage"`
LastRead interface{} `json:"last_read"` SecondsPerPercent int64 `json:"seconds_per_percent"`
Percentage interface{} `json:"percentage"`
SecondsPerPage int64 `json:"seconds_per_page"`
} }
func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) { func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) {
@@ -633,13 +532,11 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
&i.Filepath, &i.Filepath,
&i.Words, &i.Words,
&i.Wpm, &i.Wpm,
&i.Page, &i.ReadPercentage,
&i.Pages,
&i.ReadPages,
&i.TotalTimeSeconds, &i.TotalTimeSeconds,
&i.LastRead, &i.LastRead,
&i.Percentage, &i.Percentage,
&i.SecondsPerPage, &i.SecondsPerPercent,
) )
return i, err return i, err
} }
@@ -699,6 +596,24 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
return items, nil return items, nil
} }
const getDocumentsSize = `-- name: GetDocumentsSize :one
SELECT
COUNT(rowid) AS length
FROM documents AS docs
WHERE ?1 IS NULL OR (
docs.title LIKE ?1 OR
docs.author LIKE ?1
)
LIMIT 1
`
func (q *Queries) GetDocumentsSize(ctx context.Context, query interface{}) (int64, error) {
row := q.db.QueryRowContext(ctx, getDocumentsSize, query)
var length int64
err := row.Scan(&length)
return length, err
}
const getDocumentsWithStats = `-- name: GetDocumentsWithStats :many const getDocumentsWithStats = `-- name: GetDocumentsWithStats :many
SELECT SELECT
docs.id, docs.id,
@@ -711,63 +626,72 @@ SELECT
docs.words, docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm, CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page, COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds, COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset) STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read, AS last_read,
CASE ROUND(CAST(CASE
WHEN dus.percentage > 97.0 THEN 100.0
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
END AS percentage, ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CASE CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
ROUND( ROUND(
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL) / (dus.read_percentage * 100.0)
) )
END AS seconds_per_page END AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = ?1 LEFT JOIN users ON users.id = ?1
LEFT JOIN LEFT JOIN
document_user_statistics AS dus document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = ?1 ON dus.document_id = docs.id AND dus.user_id = ?1
WHERE docs.deleted = false WHERE
docs.deleted = false AND (
?2 IS NULL OR (
docs.title LIKE ?2 OR
docs.author LIKE ?2
)
)
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT ?3 LIMIT ?4
OFFSET ?2 OFFSET ?3
` `
type GetDocumentsWithStatsParams struct { type GetDocumentsWithStatsParams struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Offset int64 `json:"offset"` Query interface{} `json:"query"`
Limit int64 `json:"limit"` Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
} }
type GetDocumentsWithStatsRow struct { type GetDocumentsWithStatsRow struct {
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title"` Title *string `json:"title"`
Author *string `json:"author"` Author *string `json:"author"`
Description *string `json:"description"` Description *string `json:"description"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"` Isbn13 *string `json:"isbn13"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Words *int64 `json:"words"` Words *int64 `json:"words"`
Wpm int64 `json:"wpm"` Wpm int64 `json:"wpm"`
Page int64 `json:"page"` ReadPercentage float64 `json:"read_percentage"`
Pages int64 `json:"pages"` TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"` LastRead interface{} `json:"last_read"`
TotalTimeSeconds int64 `json:"total_time_seconds"` Percentage float64 `json:"percentage"`
LastRead interface{} `json:"last_read"` SecondsPerPercent interface{} `json:"seconds_per_percent"`
Percentage interface{} `json:"percentage"`
SecondsPerPage interface{} `json:"seconds_per_page"`
} }
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) { func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats, arg.UserID, arg.Offset, arg.Limit) rows, err := q.db.QueryContext(ctx, getDocumentsWithStats,
arg.UserID,
arg.Query,
arg.Offset,
arg.Limit,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -785,13 +709,11 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
&i.Filepath, &i.Filepath,
&i.Words, &i.Words,
&i.Wpm, &i.Wpm,
&i.Page, &i.ReadPercentage,
&i.Pages,
&i.ReadPages,
&i.TotalTimeSeconds, &i.TotalTimeSeconds,
&i.LastRead, &i.LastRead,
&i.Percentage, &i.Percentage,
&i.SecondsPerPage, &i.SecondsPerPercent,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -987,56 +909,6 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
return items, nil return items, nil
} }
const getUsers = `-- name: GetUsers :many
SELECT id, pass, admin, time_offset, created_at FROM users
WHERE
users.id = ?1
OR ?1 IN (
SELECT id
FROM users
WHERE id = ?1
AND admin = 1
)
ORDER BY created_at DESC
LIMIT ?3
OFFSET ?2
`
type GetUsersParams struct {
User string `json:"user"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsers, arg.User, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Pass,
&i.Admin,
&i.TimeOffset,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many
SELECT SELECT
user_id, user_id,
@@ -1089,17 +961,14 @@ const getWantedDocuments = `-- name: GetWantedDocuments :many
SELECT SELECT
CAST(value AS TEXT) AS id, CAST(value AS TEXT) AS id,
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file, CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata CAST((documents.id IS NULL) AS BOOLEAN) AS want_metadata
FROM json_each(?1) FROM json_each(?1)
LEFT JOIN documents LEFT JOIN documents
ON value = documents.id ON value = documents.id
WHERE ( WHERE (
documents.id IS NOT NULL documents.id IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
AND ( AND documents.filepath IS NULL
documents.synced = false
OR documents.filepath IS NULL
)
) )
OR (documents.id IS NULL) OR (documents.id IS NULL)
OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT) OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT)
@@ -1134,86 +1003,6 @@ func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([
return items, nil return items, nil
} }
const updateDocumentDeleted = `-- name: UpdateDocumentDeleted :one
UPDATE documents
SET
deleted = ?1
WHERE id = ?2
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpdateDocumentDeletedParams struct {
Deleted bool `json:"-"`
ID string `json:"id"`
}
func (q *Queries) UpdateDocumentDeleted(ctx context.Context, arg UpdateDocumentDeletedParams) (Document, error) {
row := q.db.QueryRowContext(ctx, updateDocumentDeleted, arg.Deleted, arg.ID)
var i Document
err := row.Scan(
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Words,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
&i.CreatedAt,
)
return i, err
}
const updateDocumentSync = `-- name: UpdateDocumentSync :one
UPDATE documents
SET
synced = ?1
WHERE id = ?2
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
`
type UpdateDocumentSyncParams struct {
Synced bool `json:"-"`
ID string `json:"id"`
}
func (q *Queries) UpdateDocumentSync(ctx context.Context, arg UpdateDocumentSyncParams) (Document, error) {
row := q.db.QueryRowContext(ctx, updateDocumentSync, arg.Synced, arg.ID)
var i Document
err := row.Scan(
&i.ID,
&i.Md5,
&i.Filepath,
&i.Coverfile,
&i.Title,
&i.Author,
&i.Series,
&i.SeriesIndex,
&i.Lang,
&i.Description,
&i.Words,
&i.Gbid,
&i.Olid,
&i.Isbn10,
&i.Isbn13,
&i.Synced,
&i.Deleted,
&i.UpdatedAt,
&i.CreatedAt,
)
return i, err
}
const updateProgress = `-- name: UpdateProgress :one const updateProgress = `-- name: UpdateProgress :one
INSERT OR REPLACE INTO document_progress ( INSERT OR REPLACE INTO document_progress (
user_id, user_id,

View File

@@ -91,16 +91,17 @@ CREATE TABLE IF NOT EXISTS document_progress (
PRIMARY KEY (user_id, document_id, device_id) PRIMARY KEY (user_id, document_id, device_id)
); );
-- Raw Read Activity -- Read Activity
CREATE TABLE IF NOT EXISTS raw_activity ( CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
start_time DATETIME NOT NULL, start_time DATETIME NOT NULL,
page INTEGER NOT NULL, start_percentage REAL NOT NULL,
pages INTEGER NOT NULL, end_percentage REAL NOT NULL,
duration INTEGER NOT NULL, duration INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')), created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')),
@@ -113,19 +114,6 @@ CREATE TABLE IF NOT EXISTS raw_activity (
----------------------- Temporary Tables ---------------------- ----------------------- Temporary Tables ----------------------
--------------------------------------------------------------- ---------------------------------------------------------------
-- Temporary Activity Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS activity (
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
start_time DATETIME NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
duration INTEGER NOT NULL
);
-- Temporary User Streaks Table (Cached from View) -- Temporary User Streaks Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks ( CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -144,13 +132,13 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
last_read TEXT NOT NULL, last_read TEXT NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
total_time_seconds INTEGER NOT NULL, total_time_seconds INTEGER NOT NULL,
read_pages INTEGER NOT NULL, read_percentage REAL NOT NULL,
percentage REAL NOT NULL, percentage REAL NOT NULL,
words_read INTEGER NOT NULL, words_read INTEGER NOT NULL,
wpm REAL NOT NULL wpm REAL NOT NULL,
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
); );
@@ -158,9 +146,9 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
--------------------------- Indexes --------------------------- --------------------------- Indexes ---------------------------
--------------------------------------------------------------- ---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS temp.activity_start_time ON activity (start_time); CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS temp.activity_user_id ON activity (user_id); CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity ( CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
user_id, user_id,
document_id document_id
); );
@@ -169,100 +157,6 @@ CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
---------------------------- Views ---------------------------- ---------------------------- Views ----------------------------
--------------------------------------------------------------- ---------------------------------------------------------------
--------------------------------
------- Rescaled Activity ------
--------------------------------
CREATE VIEW IF NOT EXISTS view_rescaled_activity AS
WITH RECURSIVE nums (idx) AS (
SELECT 1 AS idx
UNION ALL
SELECT idx + 1
FROM nums
LIMIT 1000
),
current_pages AS (
SELECT
document_id,
user_id,
pages
FROM raw_activity
GROUP BY document_id, user_id
HAVING MAX(start_time)
ORDER BY start_time DESC
),
intermediate AS (
SELECT
raw_activity.document_id,
raw_activity.device_id,
raw_activity.user_id,
raw_activity.created_at,
raw_activity.start_time,
raw_activity.duration,
raw_activity.page,
current_pages.pages,
-- Derive first page
((raw_activity.page - 1) * current_pages.pages) / raw_activity.pages
+ 1 AS first_page,
-- Derive last page
MAX(
((raw_activity.page - 1) * current_pages.pages)
/ raw_activity.pages
+ 1,
(raw_activity.page * current_pages.pages) / raw_activity.pages
) AS last_page
FROM raw_activity
INNER JOIN current_pages ON
current_pages.document_id = raw_activity.document_id
AND current_pages.user_id = raw_activity.user_id
),
num_limit AS (
SELECT * FROM nums
LIMIT (SELECT MAX(last_page - first_page + 1) FROM intermediate)
),
rescaled_raw AS (
SELECT
intermediate.document_id,
intermediate.device_id,
intermediate.user_id,
intermediate.created_at,
intermediate.start_time,
intermediate.last_page,
intermediate.pages,
intermediate.first_page + num_limit.idx - 1 AS page,
intermediate.duration / (
intermediate.last_page - intermediate.first_page + 1.0
) AS duration
FROM intermediate
LEFT JOIN num_limit ON
num_limit.idx <= (intermediate.last_page - intermediate.first_page + 1)
)
SELECT
user_id,
document_id,
device_id,
created_at,
start_time,
page,
pages,
-- Round up if last page (maintains total duration)
CAST(CASE
WHEN page = last_page AND duration != CAST(duration AS INTEGER)
THEN duration + 1
ELSE duration
END AS INTEGER) AS duration
FROM rescaled_raw;
-------------------------------- --------------------------------
--------- User Streaks --------- --------- User Streaks ---------
-------------------------------- --------------------------------
@@ -279,7 +173,7 @@ WITH document_windows AS (
'weekday 0', '-7 day' 'weekday 0', '-7 day'
) AS weekly_read, ) AS weekly_read,
DATE(activity.start_time, users.time_offset) AS daily_read DATE(activity.start_time, users.time_offset) AS daily_read
FROM raw_activity AS activity FROM activity
LEFT JOIN users ON users.id = activity.user_id LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read GROUP BY activity.user_id, weekly_read, daily_read
), ),
@@ -387,38 +281,86 @@ LEFT JOIN current_streak ON
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
WITH true_progress AS ( WITH intermediate_ga AS (
SELECT
ga1.id AS row_id,
ga1.user_id,
ga1.document_id,
ga1.duration,
ga1.start_time,
ga1.start_percentage,
ga1.end_percentage,
-- Find Overlapping Events (Assign Unique ID)
(
SELECT MIN(id)
FROM activity AS ga2
WHERE
ga1.document_id = ga2.document_id
AND ga1.user_id = ga2.user_id
AND ga1.start_percentage <= ga2.end_percentage
AND ga1.end_percentage >= ga2.start_percentage
) AS group_leader
FROM activity AS ga1
),
grouped_activity AS (
SELECT SELECT
document_id,
user_id, user_id,
start_time AS last_read, document_id,
page, MAX(start_time) AS start_time,
pages, MIN(start_percentage) AS start_percentage,
SUM(duration) AS total_time_seconds, MAX(end_percentage) AS end_percentage,
MAX(end_percentage) - MIN(start_percentage) AS read_percentage,
SUM(duration) AS duration
FROM intermediate_ga
GROUP BY group_leader
),
-- Determine Read Pages current_progress AS (
COUNT(DISTINCT page) AS read_pages, SELECT
user_id,
-- Derive Percentage of Book document_id,
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage COALESCE((
FROM view_rescaled_activity SELECT percentage
GROUP BY document_id, user_id FROM document_progress AS dp
WHERE
dp.user_id = iga.user_id
AND dp.document_id = iga.document_id
ORDER BY created_at DESC
LIMIT 1
), end_percentage) AS percentage
FROM intermediate_ga AS iga
GROUP BY user_id, document_id
HAVING MAX(start_time) HAVING MAX(start_time)
) )
SELECT SELECT
true_progress.*, ga.document_id,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages) ga.user_id,
MAX(start_time) AS last_read,
SUM(duration) AS total_time_seconds,
SUM(read_percentage) AS read_percentage,
cp.percentage,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
AS words_read, AS words_read,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
/ (total_time_seconds / 60.0) AS wpm (CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
FROM true_progress / (SUM(duration) / 60.0) AS wpm
INNER JOIN documents ON documents.id = true_progress.document_id FROM grouped_activity AS ga
INNER JOIN
current_progress AS cp
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
INNER JOIN
documents AS d
ON d.id = ga.document_id
GROUP BY ga.document_id, ga.user_id
ORDER BY wpm DESC; ORDER BY wpm DESC;
--------------------------------------------------------------- ---------------------------------------------------------------
------------------ Populate Temporary Tables ------------------ ------------------ Populate Temporary Tables ------------------
--------------------------------------------------------------- ---------------------------------------------------------------
INSERT INTO activity SELECT * FROM view_rescaled_activity;
INSERT INTO user_streaks SELECT * FROM view_user_streaks; INSERT INTO user_streaks SELECT * FROM view_user_streaks;
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics; INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;

View File

@@ -0,0 +1,77 @@
INSERT INTO document_user_statistics
WITH intermediate_ga AS (
SELECT
ga1.id AS row_id,
ga1.user_id,
ga1.document_id,
ga1.duration,
ga1.start_time,
ga1.start_percentage,
ga1.end_percentage,
-- Find Overlapping Events (Assign Unique ID)
(
SELECT MIN(id)
FROM activity AS ga2
WHERE
ga1.document_id = ga2.document_id
AND ga1.user_id = ga2.user_id
AND ga1.start_percentage <= ga2.end_percentage
AND ga1.end_percentage >= ga2.start_percentage
) AS group_leader
FROM activity AS ga1
WHERE
document_id = ?
AND user_id = ?
),
grouped_activity AS (
SELECT
user_id,
document_id,
MAX(start_time) AS start_time,
MIN(start_percentage) AS start_percentage,
MAX(end_percentage) AS end_percentage,
MAX(end_percentage) - MIN(start_percentage) AS read_percentage,
SUM(duration) AS duration
FROM intermediate_ga
GROUP BY group_leader
),
current_progress AS (
SELECT
user_id,
document_id,
COALESCE((
SELECT percentage
FROM document_progress AS dp
WHERE
dp.user_id = iga.user_id
AND dp.document_id = iga.document_id
ORDER BY created_at DESC
LIMIT 1
), end_percentage) AS percentage
FROM intermediate_ga AS iga
GROUP BY user_id, document_id
HAVING MAX(start_time)
)
SELECT
ga.document_id,
ga.user_id,
MAX(start_time) AS last_read,
SUM(duration) AS total_time_seconds,
SUM(read_percentage) AS read_percentage,
cp.percentage,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
AS words_read,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
/ (SUM(duration) / 60.0) AS wpm
FROM grouped_activity AS ga
INNER JOIN
current_progress AS cp
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
INNER JOIN
documents AS d
ON d.id = ga.document_id
GROUP BY ga.document_id, ga.user_id
ORDER BY wpm DESC;

View File

@@ -1,5 +1,3 @@
DELETE FROM activity;
INSERT INTO activity SELECT * FROM view_rescaled_activity;
DELETE FROM user_streaks; DELETE FROM user_streaks;
INSERT INTO user_streaks SELECT * FROM view_user_streaks; INSERT INTO user_streaks SELECT * FROM view_user_streaks;
DELETE FROM document_user_statistics; DELETE FROM document_user_statistics;

View File

@@ -1,6 +1,6 @@
--- ---
services: services:
bookmanager: antholume:
environment: environment:
- CONFIG_PATH=/data - CONFIG_PATH=/data
- DATA_PATH=/data - DATA_PATH=/data

View File

@@ -101,9 +101,6 @@ func getSVGBezierOpposedLine(pointA SVGGraphPoint, pointB SVGGraphPoint) SVGBezi
Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))), Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))),
Angle: int(math.Atan2(lengthY, lengthX)), Angle: int(math.Atan2(lengthY, lengthX)),
} }
// length = Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),
// angle = Math.atan2(lengthY, lengthX)
} }
func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPoint, nextPoint *SVGGraphPoint, isReverse bool) SVGGraphPoint { func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPoint, nextPoint *SVGGraphPoint, isReverse bool) SVGGraphPoint {

28
main.go
View File

@@ -3,6 +3,8 @@ package main
import ( import (
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@@ -22,13 +24,13 @@ func main() {
log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}}) log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}})
app := &cli.App{ app := &cli.App{
Name: "Book Bank", Name: "AnthoLume",
Usage: "A self hosted e-book progress tracker.", Usage: "A self hosted e-book progress tracker.",
Commands: []*cli.Command{ Commands: []*cli.Command{
{ {
Name: "serve", Name: "serve",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "Start Book Bank web server.", Usage: "Start AnthoLume web server.",
Action: cmdServer, Action: cmdServer,
}, },
}, },
@@ -40,17 +42,23 @@ func main() {
} }
func cmdServer(ctx *cli.Context) error { func cmdServer(ctx *cli.Context) error {
log.Info("Starting Book Bank Server") log.Info("Starting AnthoLume Server")
// Create Channel
wg := sync.WaitGroup{}
done := make(chan struct{})
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
// Start Server
server := server.NewServer() server := server.NewServer()
server.StartServer() server.StartServer(&wg, done)
c := make(chan os.Signal, 1) // Wait & Close
signal.Notify(c, os.Interrupt) <-interrupt
<-c server.StopServer(&wg, done)
log.Info("Stopping Server") // Stop Server
server.StopServer()
log.Info("Server Stopped")
os.Exit(0) os.Exit(0)
return nil return nil

16
notes/README.md Normal file
View File

@@ -0,0 +1,16 @@
# Notes
This folder consists of various notes / files that I want to save and may come back to at some point.
# Ideas / To Do
- Rename!
- Google Fonts -> SW Cache and/or Local
- Search Documents
- Title, Author, Description
- Change Device Name / Assume Device
- Hide Document per User (Another Table?)
- Admin User?
- Reset Passwords
- Actually Delete Documents
- Document & Activity Pagination

View File

@@ -1,25 +1,25 @@
# PWA Screenshots # PWA Screenshots
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="32%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="32%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/activity.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/activity.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png" width="32%">
</a> </a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="32%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="32%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png" width="32%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="32%">
</a> </a>
</p> </p>

View File

@@ -1,28 +1,28 @@
# Web Screenshots # Web Screenshots
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/login.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/login.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/login.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/login.png" width="49%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/home.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/home.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/home.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/home.png" width="49%">
</a> </a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/activity.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/activity.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/activity.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/activity.png" width="49%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/documents.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/documents.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/documents.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/documents.png" width="49%">
</a> </a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/document.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/document.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/document.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/document.png" width="49%">
</a> </a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/metadata.png"> <a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/metadata.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/metadata.png" width="49%"> <img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/metadata.png" width="49%">
</a> </a>
</p> </p>

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -29,35 +30,72 @@ func NewServer() *Server {
// Create Paths // Create Paths
docDir := filepath.Join(c.DataPath, "documents") docDir := filepath.Join(c.DataPath, "documents")
coversDir := filepath.Join(c.DataPath, "covers") coversDir := filepath.Join(c.DataPath, "covers")
_ = os.Mkdir(docDir, os.ModePerm) os.Mkdir(docDir, os.ModePerm)
_ = os.Mkdir(coversDir, os.ModePerm) os.Mkdir(coversDir, os.ModePerm)
return &Server{ return &Server{
API: api, API: api,
Config: c, Config: c,
Database: db, Database: db,
httpServer: &http.Server{
Handler: api.Router,
Addr: (":" + c.ListenPort),
},
} }
} }
func (s *Server) StartServer() { func (s *Server) StartServer(wg *sync.WaitGroup, done <-chan struct{}) {
listenAddr := (":" + s.Config.ListenPort) ticker := time.NewTicker(15 * time.Minute)
s.httpServer = &http.Server{ wg.Add(2)
Handler: s.API.Router,
Addr: listenAddr,
}
go func() { go func() {
defer wg.Done()
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
if err != nil { if err != nil && err != http.ErrServerClosed {
log.Error("Error starting server ", err) log.Error("Error Starting Server:", err)
}
}()
go func() {
defer wg.Done()
defer ticker.Stop()
s.RunScheduledTasks()
for {
select {
case <-ticker.C:
s.RunScheduledTasks()
case <-done:
log.Info("Stopping Task Runner...")
return
}
} }
}() }()
} }
func (s *Server) StopServer() { func (s *Server) RunScheduledTasks() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) log.Info("[RunScheduledTasks] Refreshing Temp Table Cache")
defer cancel() if err := s.API.DB.CacheTempTables(); err != nil {
s.httpServer.Shutdown(ctx) log.Warn("[RunScheduledTasks] Refreshing Temp Table Cache Failure:", err)
s.API.DB.DB.Close() }
log.Info("[RunScheduledTasks] Refreshing Temp Table Success")
}
func (s *Server) StopServer(wg *sync.WaitGroup, done chan<- struct{}) {
log.Info("Stopping HTTP Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Info("Shutting Error")
}
s.API.DB.Shutdown()
close(done)
wg.Wait()
log.Info("Server Stopped")
} }

View File

@@ -4,5 +4,6 @@ pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
go go
nodePackages.tailwindcss nodePackages.tailwindcss
python311Packages.grip
]; ];
} }

View File

@@ -3,32 +3,20 @@
{{end}} {{define "content"}} {{end}} {{define "content"}}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"> <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Document Document
</th> </th>
<th <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Time Time
</th> </th>
<th <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Duration Duration
</th> </th>
<th <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
scope="col" Percent
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Page
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -38,7 +26,6 @@
<td class="text-center p-3" colspan="4">No Results</td> <td class="text-center p-3" colspan="4">No Results</td>
</tr> </tr>
{{ end }} {{ end }}
{{range $activity := .Data }} {{range $activity := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
@@ -51,7 +38,7 @@
<p>{{ $activity.Duration }}</p> <p>{{ $activity.Duration }}</p>
</td> </td>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $activity.Page }} / {{ $activity.Pages }}</p> <p>{{ $activity.ReadPercentage }}%</p>
</td> </td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -9,14 +9,14 @@
<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)">
<title>Book Manager - {{block "title" .}}{{end}}</title> <title>AnthoLume - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css"> <link rel="stylesheet" href="/assets/style.css">
<!-- Service Worker / Offline Cache Flush --> <!-- Service Worker / Offline Cache Flush -->
<script src="/assets/lib/idb-keyval.js"></script> <script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/lib/sw-helper.js"></script> <script src="/assets/common.js"></script>
<script src="/assets/index.js"></script> <script src="/assets/index.js"></script>
<style> <style>
@@ -127,16 +127,16 @@
<body class="bg-gray-100 dark:bg-gray-800"> <body class="bg-gray-100 dark:bg-gray-800">
<div class="flex items-center justify-between w-full h-16"> <div class="flex items-center justify-between w-full h-16">
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6"> <div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0 w-12 h-12" /> <input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0" />
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<div id="menu" class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"> <div id="menu" class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
<div class="h-16 flex justify-end lg:justify-around"> <div class="h-16 flex justify-end lg:justify-around">
<p class="text-xl font-bold dark:text-white text-right my-auto pr-4 lg:pr-0">Book Manager</p> <p class="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">AnthoLume</p>
</div> </div>
<div class="mt-6"> <div>
<a <a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}" class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/" href="/"
@@ -232,12 +232,47 @@
</a> </a>
{{ end }} {{ end }}
</div> </div>
<a class="flex justify-center items-center p-6 w-full absolute bottom-0" target="_blank" href="https://gitea.va.reichard.io/evan/AnthoLume">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-black dark:text-white"
height="20"
viewBox="0 0 219 92"
fill="currentColor"
>
<defs>
<clipPath id="a"><path d="M159 .79h25V69h-25Zm0 0" /></clipPath>
<clipPath id="b"><path d="M183 9h35.371v60H183Zm0 0" /></clipPath>
<clipPath id="c"><path d="M0 .79h92V92H0Zm0 0" /></clipPath>
</defs>
<path
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
/>
<g clip-path="url(#a)">
<path
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
/>
</g>
<g clip-path="url(#b)">
<path
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
/>
</g>
<g clip-path="url(#c)">
<path
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
/>
</g>
</svg>
</a>
</div> </div>
</div> </div>
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1> <h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
<div <div class="relative flex items-center justify-end w-full p-4 space-x-4">
class="relative flex items-center justify-end w-full p-4 space-x-4"
>
<a href="#" class="relative block"> <a href="#" class="relative block">
<svg <svg
width="20" width="20"
@@ -268,7 +303,7 @@
> >
<a <a
href="/settings" href="/settings"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600" class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem" role="menuitem"
> >
<span class="flex flex-col"> <span class="flex flex-col">
@@ -277,7 +312,7 @@
</a> </a>
<a <a
href="/logout" href="/logout"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600" class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem" role="menuitem"
> >
<span class="flex flex-col"> <span class="flex flex-col">
@@ -309,9 +344,7 @@
</div> </div>
</div> </div>
<main <main class="relative overflow-hidden">
class="relative overflow-hidden"
>
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"> <div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</div> </div>

View File

@@ -1,166 +0,0 @@
{{template "base.html" .}}
{{define "title"}}Documents{{end}}
{{define "header"}}
<a href="../documents">Documents</a>
{{end}}
{{define "content"}}
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-6">
<div class="flex flex-col gap-2 float-left mr-6 mb-6">
<img class="rounded w-40 md:w-60 lg:w-80 object-fill h-full" src="../documents/{{.Data.ID}}/cover"></img>
<div class="flex gap-2 justify-end text-gray-500 dark:text-gray-400">
<div class="relative">
<label for="delete-button">
<svg
width="24"
height="24"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6.52381C3 6.12932 3.32671 5.80952 3.72973 5.80952H8.51787C8.52437 4.9683 8.61554 3.81504 9.45037 3.01668C10.1074 2.38839 11.0081 2 12 2C12.9919 2 13.8926 2.38839 14.5496 3.01668C15.3844 3.81504 15.4756 4.9683 15.4821 5.80952H20.2703C20.6733 5.80952 21 6.12932 21 6.52381C21 6.9183 20.6733 7.2381 20.2703 7.2381H3.72973C3.32671 7.2381 3 6.9183 3 6.52381Z"
/>
<path
d="M11.6066 22H12.3935C15.101 22 16.4547 22 17.3349 21.1368C18.2151 20.2736 18.3052 18.8576 18.4853 16.0257L18.7448 11.9452C18.8425 10.4086 18.8913 9.64037 18.4498 9.15352C18.0082 8.66667 17.2625 8.66667 15.7712 8.66667H8.22884C6.7375 8.66667 5.99183 8.66667 5.55026 9.15352C5.1087 9.64037 5.15756 10.4086 5.25528 11.9452L5.51479 16.0257C5.69489 18.8576 5.78494 20.2736 6.66513 21.1368C7.54532 22 8.89906 22 11.6066 22Z"
/>
</svg>
</label>
<input type="checkbox" id="delete-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/delete"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<p class="font-medium w-24 pb-2">Are you sure?</p>
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
</form>
</div>
<a href="../activity?document={{ .Data.ID }}">
<svg
width="24"
height="24"
class="hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.5 2C8.67157 2 8 2.67157 8 3.5V4.5C8 5.32843 8.67157 6 9.5 6H14.5C15.3284 6 16 5.32843 16 4.5V3.5C16 2.67157 15.3284 2 14.5 2H9.5Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 4.03662C5.24209 4.10719 4.44798 4.30764 3.87868 4.87694C3 5.75562 3 7.16983 3 9.99826V15.9983C3 18.8267 3 20.2409 3.87868 21.1196C4.75736 21.9983 6.17157 21.9983 9 21.9983H15C17.8284 21.9983 19.2426 21.9983 20.1213 21.1196C21 20.2409 21 18.8267 21 15.9983V9.99826C21 7.16983 21 5.75562 20.1213 4.87694C19.552 4.30764 18.7579 4.10719 17.5 4.03662V4.5C17.5 6.15685 16.1569 7.5 14.5 7.5H9.5C7.84315 7.5 6.5 6.15685 6.5 4.5V4.03662ZM7 9.75C6.58579 9.75 6.25 10.0858 6.25 10.5C6.25 10.9142 6.58579 11.25 7 11.25H7.5C7.91421 11.25 8.25 10.9142 8.25 10.5C8.25 10.0858 7.91421 9.75 7.5 9.75H7ZM10.5 9.75C10.0858 9.75 9.75 10.0858 9.75 10.5C9.75 10.9142 10.0858 11.25 10.5 11.25H17C17.4142 11.25 17.75 10.9142 17.75 10.5C17.75 10.0858 17.4142 9.75 17 9.75H10.5ZM7 13.25C6.58579 13.25 6.25 13.5858 6.25 14C6.25 14.4142 6.58579 14.75 7 14.75H7.5C7.91421 14.75 8.25 14.4142 8.25 14C8.25 13.5858 7.91421 13.25 7.5 13.25H7ZM10.5 13.25C10.0858 13.25 9.75 13.5858 9.75 14C9.75 14.4142 10.0858 14.75 10.5 14.75H17C17.4142 14.75 17.75 14.4142 17.75 14C17.75 13.5858 17.4142 13.25 17 13.25H10.5ZM7 16.75C6.58579 16.75 6.25 17.0858 6.25 17.5C6.25 17.9142 6.58579 18.25 7 18.25H7.5C7.91421 18.25 8.25 17.9142 8.25 17.5C8.25 17.0858 7.91421 16.75 7.5 16.75H7ZM10.5 16.75C10.0858 16.75 9.75 17.0858 9.75 17.5C9.75 17.9142 10.0858 18.25 10.5 18.25H17C17.4142 18.25 17.75 17.9142 17.75 17.5C17.75 17.0858 17.4142 16.75 17 16.75H10.5Z"/>
</svg>
</a>
<div class="relative">
<label for="edit-button">
<svg
width="24"
height="24"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>
</svg>
</label>
<input type="checkbox" id="edit-button" class="hidden css-button"/>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="absolute bottom-7 left-5 text-black bg-gray-200 transition-all duration-200 rounded shadow-inner shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<label class="font-medium" for="isbn">ISBN</label>
<input class="mt-1 mb-2 p-1 bg-gray-400 text-black dark:bg-gray-700 dark:text-white" type="text" id="isbn" name="ISBN"><br>
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
</form>
</div>
{{ if .Data.Filepath }}
<a href="./{{.Data.ID}}/file">
<svg
width="24"
height="24"
class="hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>
</svg>
</a>
{{ else }}
<svg
width="24"
height="24"
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="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>
</svg>
{{ end }}
</div>
</div>
<div class="flex flex-wrap justify-between gap-6 pb-6">
<div>
<p class="text-gray-400">Title</p>
<!-- <input type="text" class="font-medium bg-transparent" value="{{ or .Data.Title "Unknown" }}"/> -->
<p class="font-medium text-lg">
{{ or .Data.Title "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium text-lg">
{{ or .Data.Author "N/A" }}
</p>
</div>
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium text-lg">
{{ .Data.Page }} / {{ .Data.Pages }} ({{ .Data.Percentage }}%)
</p>
</div>
<div>
<p class="text-gray-400">Time Read</p>
<p class="font-medium text-lg">
{{ .Data.TotalTimeMinutes }} Minutes
</p>
</div>
</div>
<p class="text-gray-400">Description</p>
<p class="font-medium text-justify hyphens-auto">
{{ or .Data.Description "N/A" }}
</p>
</div>
<style>
.css-button:checked + form {
visibility: visible;
opacity: 1;
}
.css-button + form {
visibility: hidden;
opacity: 0;
}
</style>
{{end}}

View File

@@ -67,7 +67,6 @@
type="submit" type="submit"
>Remove Cover</button> >Remove Cover</button>
</form> </form>
</div> </div>
<div class="relative"> <div class="relative">
<label for="delete-button"> <label for="delete-button">
@@ -323,12 +322,11 @@
</svg> </svg>
</label> </label>
<input type="checkbox" id="progress-info-button" class="hidden css-button"/> <input type="checkbox" id="progress-info-button" class="hidden css-button"/>
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"> <div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<div class="text-xs flex"> <div class="text-xs flex">
<p class="text-gray-400 w-32">Seconds / Page</p> <p class="text-gray-400 w-32">Seconds / Percent</p>
<p class="font-medium dark:text-white"> <p class="font-medium dark:text-white">
{{ .Data.SecondsPerPage }} {{ .Data.SecondsPerPercent }}
</p> </p>
</div> </div>
<div class="text-xs flex"> <div class="text-xs flex">
@@ -352,23 +350,9 @@
<div> <div>
<p class="text-gray-500">Progress</p> <p class="text-gray-500">Progress</p>
<p class="font-medium text-lg"> <p class="font-medium text-lg">
{{ .Data.Page }} / {{ .Data.Pages }} ({{ .Data.Percentage }}%) {{ .Data.Percentage }}%
</p> </p>
</div> </div>
<!--
<div>
<p class="text-gray-500">ISBN 10</p>
<p class="font-medium text-lg">
{{ or .Data.Isbn10 "N/A" }}
</p>
</div>
<div>
<p class="text-gray-500">ISBN 13</p>
<p class="font-medium text-lg">
{{ or .Data.Isbn13 "N/A" }}
</p>
</div>
-->
</div> </div>
<div class="relative"> <div class="relative">

View File

@@ -7,6 +7,49 @@
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<div
class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form class="flex gap-4 flex-col lg:flex-row" action="./documents" method="GET">
<div class="flex flex-col w-full grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="24" height="24" fill="none" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
/>
</svg>
</span>
<input
type="text"
id="search"
name="search"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Search Author / Title"
/>
</div>
</div>
<button
type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Search</span>
</button>
</form>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{range $doc := .Data }} {{range $doc := .Data }}
<div class="w-full relative"> <div class="w-full relative">
@@ -37,7 +80,7 @@
<div> <div>
<p class="text-gray-400">Progress</p> <p class="text-gray-400">Progress</p>
<p class="font-medium"> <p class="font-medium">
{{ $doc.Page }} / {{ $doc.Pages }} ({{ $doc.Percentage }}%) {{ $doc.Percentage }}%
</p> </p>
</div> </div>
</div> </div>
@@ -104,6 +147,16 @@
{{end}} {{end}}
</div> </div>
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
{{ if .PreviousPage }}
<a href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"></a>
{{ end }}
{{ if .NextPage }}
<a href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"></a>
{{ end }}
</div>
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"> <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"/> <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"> <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">

View File

@@ -22,7 +22,7 @@
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<title>Book Manager - Error</title> <title>AnthoLume - Error</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
@@ -30,29 +30,27 @@
<body <body
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen" 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="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">
<div class="mx-auto max-w-screen-sm text-center"> <h1
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500" >
> {{ .Status }}
{{ .Status }} </h1>
</h1> <p
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white"
class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white" >
> {{ .Error }}
{{ .Error }} </p>
</p> <p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400"> {{ .Message }}
{{ .Message }} </p>
</p> <a
<a href="/"
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"
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
>Back to Homepage</a >
>
</div>
</div> </div>
</section> </div>
</body> </body>
</html> </html>

View File

@@ -22,14 +22,14 @@
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title> <title>AnthoLume - {{if .Register}}Register{{else}}Login{{end}}</title>
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./assets/style.css" /> <link rel="stylesheet" href="./assets/style.css" />
<!-- Service Worker / Offline Cache Flush --> <!-- Service Worker / Offline Cache Flush -->
<script src="/assets/lib/idb-keyval.js"></script> <script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/lib/sw-helper.js"></script> <script src="/assets/common.js"></script>
<script src="/assets/index.js"></script> <script src="/assets/index.js"></script>
<style> <style>

View File

@@ -2,7 +2,7 @@ package utils
import "testing" import "testing"
func TestCalculatePartialPD5(t *testing.T) { func TestCalculatePartialMD5(t *testing.T) {
partialMD5, err := CalculatePartialMD5("../_test_files/alice.epub") partialMD5, err := CalculatePartialMD5("../_test_files/alice.epub")
want := "386d1cb51fe4a72e5c9fdad5e059bad9" want := "386d1cb51fe4a72e5c9fdad5e059bad9"