Compare commits

30 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
67dedaa886 [add] demo readme link
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-31 17:45:33 -04:00
5f1de4ec67 [fix] 0 page issue, [add] wpm logging
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-31 17:30:42 -04:00
d27b9061bb [add] demo mode 2023-10-31 06:28:22 -04:00
0f271ac2fb [fix] copy error
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 23:35:37 -04:00
20560ed246 [fix] negative current word values
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 19:36:04 -04:00
aacf5a7195 [fix] login PWA styling, [add] login local link, [add] home local link
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 19:23:38 -04:00
5880d3beb6 [fix] handle sw unsupported, [fix] sw install / upgrade, [add] local file upload / delete
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 18:25:43 -04:00
0917172d1c Merge pull request 'Service Worker & Offline Reader' (#1) from add_service_worker into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: evan/BookManager#1
2023-10-30 01:27:18 +00:00
f74c81dc9b [add] service worker & offline reader
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-10-29 21:17:47 -04:00
1b8b5060f1 [fix] server word count, [add] client word count
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-26 21:50:05 -04:00
b9b9ad2098 [add] better search error handling 2023-10-26 06:20:56 -04:00
67 changed files with 2575 additions and 5154 deletions

View File

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

View File

@@ -10,17 +10,17 @@ WORKDIR /src
COPY . .
# Create Package Directory
RUN mkdir -p /opt/bookmanager
RUN mkdir -p /opt/antholume
# Compile
RUN go build -o /opt/bookmanager/server; \
cp -a ./templates /opt/bookmanager/templates; \
cp -a ./assets /opt/bookmanager/assets;
RUN go build -o /opt/antholume/server; \
cp -a ./templates /opt/antholume/templates; \
cp -a ./assets /opt/antholume/assets;
# Create Image
FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager
WORKDIR /opt/bookmanager
COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume
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
WORKDIR /src
RUN mkdir -p /opt/bookmanager
RUN mkdir -p /opt/antholume
# Cache Dependencies & Compile
ARG TARGETOS
@@ -15,14 +15,14 @@ ARG TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/bookmanager/server; \
cp -a ./templates /opt/bookmanager/templates; \
cp -a ./assets /opt/bookmanager/assets;
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/antholume/server; \
cp -a ./templates /opt/antholume/templates; \
cp -a ./assets /opt/antholume/assets;
# Create Image
FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager
WORKDIR /opt/bookmanager
COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume
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
docker_build_local: build_tailwind
docker build -t bookmanager:latest .
docker build -t antholume:latest .
docker_build_release_dev: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:dev \
-t gitea.va.reichard.io/evan/antholume:dev \
-f Dockerfile-BuildKit \
--push .
docker_build_release_latest: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:latest \
-t gitea.va.reichard.io/evan/bookmanager:`git describe --tags` \
-t gitea.va.reichard.io/evan/antholume:latest \
-t gitea.va.reichard.io/evan/antholume:`git describe --tags` \
-f Dockerfile-BuildKit \
--push .
build_tailwind:
tailwind build -o ./assets/style.css
tailwind build -o ./assets/style.css --minify
clean:

104
README.md
View File

@@ -1,86 +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">
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
</a>
</p>
<p align="center">Screenshots</p>
<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 align="center"><strong>user:</strong> demo • <strong>pass:</strong> demo</p>
<p align="center">
<a href="https://drone.va.reichard.io/evan/BookManager" target="_blank">
<img src="https://drone.va.reichard.io/api/badges/evan/BookManager/status.svg">
<a href="https://drone.va.reichard.io/evan/AnthoLume" target="_blank">
<img src="https://drone.va.reichard.io/api/badges/evan/AnthoLume/status.svg">
</a>
</p>
---
This is BookManager! Will probably be renamed at some point. This repository contains:
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:
- Web App / Progressive Web App (PWA)
- [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
- OPDS API Endpoint
- 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)
In additional to the compatible KOSync API's, we add:
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.
- Additional APIs to automatically upload reading statistics
- 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))
- No JavaScript for the main app! All information is generated server side with go templates.
- JavaScript is used for the ePub reader. Goals to make it service worker to enable a complete offline PWA reading experience.
## Server
# Server
Docker Image: `docker pull gitea.va.reichard.io/evan/antholume:latest`
Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest`
### Local / Offline Reader
## KOSync API
The Local / Offline reader allows you to use any AnthoLume server as a standalone offline accessible reading app! Some features:
- Add local EPUB documents
- Read both local and any cached server documents
- Maintains progress for all types of documents (server / local)
- Uploads any progress or activity for cached server documents once the internet is accessible
### KOSync API
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`
## Quick Start
### Quick Start
```bash
# Make Data Directory
mkdir -p bookmanager_data
mkdir -p antholume_data
# Run Server
docker run \
-p 8585:8585 \
-e REGISTRATION_ENABLED=true \
-v ./bookmanager_data:/config \
-v ./bookmanager_data:/data \
gitea.va.reichard.io/evan/bookmanager:latest
-v ./antholume_data:/config \
-v ./antholume_data:/data \
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.
## Configuration
### Configuration
| Environment Variable | Default Value | Description |
| -------------------- | ------------- | -------------------------------------------------------------------- |
| -------------------- | ------------- | ------------------------------------------------------------------- |
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
| DATABASE_NAME | bbank | The database name, or in SQLite's case, the filename |
| DATABASE_PASSWORD | <EMPTY> | Currently not used. Placeholder for potential alternative DB support |
| DATABASE_NAME | antholume | The database name, or in SQLite's case, the filename |
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at |
@@ -94,7 +98,7 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
### Authentication
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
- _KOSync & SyncNinja API_ - Header based (KOSync compatibility)
- _KOSync & SyncNinja API_ - Header based - `X-Auth-User` & `X-Auth-Key` (KOSync compatibility)
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
### Notes
@@ -103,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:
- 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):
@@ -119,10 +123,10 @@ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Run Development:
```bash
CONFIG_PATH=./data DATA_PATH=./data 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:
@@ -136,6 +140,16 @@ make docker_build_local
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
make docker_build_release_latest
make docker_build_release_dev
# Generate Tailwind CSS
make build_tailwind
# Clean Local Build
make clean
# Tests (Unit & Integration - Google Books API)
make tests_unit
make tests_integration
```
## Notes

View File

@@ -78,40 +78,57 @@ func (api *API) registerWebAppRoutes() {
"NiceSeconds": niceSeconds,
}
// Templates
render.AddFromFiles("error", "templates/error.html")
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
api.Router.HTMLRender = render
// Static Assets (Required @ Root)
api.Router.GET("/manifest.json", api.webManifest)
api.Router.GET("/sw.js", api.serviceWorker)
// Local / Offline Static Pages (No Template, No Auth)
api.Router.GET("/local", api.localDocuments)
api.Router.GET("/reader", api.documentReader)
// Web App
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/documents/:document/progress", api.authWebAppMiddleware, api.getDocumentProgress)
api.Router.GET("/login", api.createAppResourcesRoute("login"))
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
api.Router.POST("/login", api.authFormLogin)
api.Router.POST("/register", api.authFormRegister)
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
// Demo Mode Enabled Configuration
if api.Config.DemoMode {
api.Router.POST("/documents", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.demoModeAppError)
api.Router.POST("/settings", api.authWebAppMiddleware, api.demoModeAppError)
} else {
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
}
// Behind Configuration Flag
// Search Enabled Configuration
if api.Config.SearchEnabled {
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
@@ -121,28 +138,36 @@ func (api *API) registerWebAppRoutes() {
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
koGroup := apiGroup.Group("/ko")
koGroup.POST("/users/create", api.createUser)
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
// KO Sync Routes (WebApp Uses - Progress & Activity)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.getProgress)
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
koGroup.POST("/users/create", api.createUser)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
// Demo Mode Enabled Configuration
if api.Config.DemoMode {
koGroup.POST("/documents", api.authKOMiddleware, api.demoModeJSONError)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.demoModeJSONError)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.demoModeJSONError)
} else {
koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
}
}
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
opdsGroup := apiGroup.Group("/opds")
// OPDS Routes
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
}
func generateToken(n int) ([]byte, error) {

View File

@@ -4,6 +4,8 @@ import (
"crypto/md5"
"database/sql"
"fmt"
"io"
"math"
"mime/multipart"
"net/http"
"os"
@@ -25,6 +27,7 @@ import (
type queryParams struct {
Page *int64 `form:"page"`
Limit *int64 `form:"limit"`
Search *string `form:"search"`
Document *string `form:"document"`
}
@@ -72,6 +75,18 @@ func (api *API) webManifest(c *gin.Context) {
c.File("./assets/manifest.json")
}
func (api *API) serviceWorker(c *gin.Context) {
c.File("./assets/sw.js")
}
func (api *API) localDocuments(c *gin.Context) {
c.File("./assets/local/index.html")
}
func (api *API) documentReader(c *gin.Context) {
c.File("./assets/reader/index.html")
}
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
// Merge Optional Template Data
var templateVarsBase = gin.H{}
@@ -98,8 +113,15 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
qParams := bindQueryParams(c)
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{
UserID: userID,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
@@ -109,10 +131,30 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
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 {
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
} else if routeName == "document" {
var rDocID requestDocumentID
@@ -133,7 +175,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}
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" {
activityFilter := database.GetActivityParams{
UserID: userID,
@@ -164,13 +206,13 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
log.Info("GetDatabaseInfo Performance: ", time.Since(start))
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{
"Streaks": streaks,
"GraphData": read_graph_data,
"DatabaseInfo": database_info,
"WPMLeaderboard": wpn_leaderboard,
"WPMLeaderboard": wpm_leaderboard,
}
} else if routeName == "settings" {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
@@ -209,7 +251,12 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}
// Search
searchResults := search.SearchBook(*sParams.Query, bType)
searchResults, err := search.SearchBook(*sParams.Query, bType)
if err != nil {
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return
}
templateVars["Data"] = searchResults
templateVars["BookType"] = *sParams.BookType
}
@@ -240,7 +287,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@@ -251,7 +298,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
_, err = os.Stat(safePath)
if err != nil {
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@@ -304,7 +351,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
// Return Unknown Cover
if coverFile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@@ -312,12 +359,12 @@ func (api *API) getDocumentCover(c *gin.Context) {
c.File(coverFilePath)
}
func (api *API) documentReader(c *gin.Context) {
func (api *API) getDocumentProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[documentReader] Invalid URI Bind")
log.Error("[getDocumentProgress] Invalid URI Bind")
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
@@ -328,7 +375,7 @@ func (api *API) documentReader(c *gin.Context) {
})
if err != nil && err != sql.ErrNoRows {
log.Error("[documentReader] UpsertDocument DB Error:", err)
log.Error("[getDocumentProgress] UpsertDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return
}
@@ -338,15 +385,18 @@ func (api *API) documentReader(c *gin.Context) {
DocumentID: rDoc.DocumentID,
})
if err != nil {
log.Error("[documentReader] GetDocumentWithStats DB Error:", err)
log.Error("[getDocumentProgress] GetDocumentWithStats DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "reader", gin.H{
"SearchEnabled": api.Config.SearchEnabled,
"Progress": progress.Progress,
"Data": document,
c.JSON(http.StatusOK, gin.H{
"id": document.ID,
"title": document.Title,
"author": document.Author,
"words": document.Words,
"progress": progress.Progress,
"percentage": document.Percentage,
})
}
@@ -393,6 +443,7 @@ func (api *API) uploadNewDocument(c *gin.Context) {
errorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
return
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Save Temp
@@ -434,6 +485,14 @@ func (api *API) uploadNewDocument(c *gin.Context) {
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
var fileName string
if *metadataInfo.Author != "" {
@@ -454,12 +513,19 @@ func (api *API) uploadNewDocument(c *gin.Context) {
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
// Generate Storage Path
// Generate Storage Path & Open File
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
destFile, err := os.Create(safePath)
if err != nil {
log.Error("[uploadNewDocument] Dest File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
defer destFile.Close()
// Move File
if err := os.Rename(tempFile.Name(), safePath); err != nil {
log.Error("[uploadNewDocument] Move Temp File Error:", err)
// Copy File
if _, err = io.Copy(destFile, tempFile); err != nil {
log.Error("[uploadNewDocument] Copy Temp File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
@@ -470,6 +536,7 @@ func (api *API) uploadNewDocument(c *gin.Context) {
Title: metadataInfo.Title,
Author: metadataInfo.Author,
Description: metadataInfo.Description,
Words: &wordCount,
Md5: fileHash,
Filepath: &fileName,
}); err != nil {
@@ -682,7 +749,7 @@ func (api *API) identifyDocument(c *gin.Context) {
}
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)
}
@@ -750,12 +817,29 @@ func (api *API) saveNewDocument(c *gin.Context) {
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
// Generate Storage Path
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
// Open Source File
sourceFile, err := os.Open(tempFilePath)
if err != nil {
log.Error("[saveNewDocument] Source File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
defer os.Remove(tempFilePath)
defer sourceFile.Close()
// Move File
if err := os.Rename(tempFilePath, safePath); err != nil {
log.Warn("[saveNewDocument] Move Temp File Error: ", err)
// Generate Storage Path & Open File
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
destFile, err := os.Create(safePath)
if err != nil {
log.Error("[saveNewDocument] Dest File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
defer destFile.Close()
// Copy File
if _, err = io.Copy(destFile, sourceFile); err != nil {
log.Error("[saveNewDocument] Copy Temp File Error:", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
return
}
@@ -768,6 +852,14 @@ func (api *API) saveNewDocument(c *gin.Context) {
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
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: partialMD5,
@@ -775,6 +867,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
Author: rDocAdd.Author,
Md5: fileHash,
Filepath: &fileName,
Words: &wordCount,
}); err != nil {
log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
@@ -911,7 +1004,7 @@ func bindQueryParams(c *gin.Context) queryParams {
c.BindQuery(&qParams)
if qParams.Limit == nil {
var defaultValue int64 = 50
var defaultValue int64 = 9
qParams.Limit = &defaultValue
} else if *qParams.Limit < 0 {
var zeroValue int64 = 0
@@ -919,7 +1012,7 @@ func bindQueryParams(c *gin.Context) queryParams {
}
if qParams.Page == nil || *qParams.Page < 1 {
var oneValue int64 = 0
var oneValue int64 = 1
qParams.Page = &oneValue
}

View File

@@ -219,6 +219,14 @@ func (api *API) authLogout(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
}
func (api *API) demoModeAppError(c *gin.Context) {
errorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
}
func (api *API) demoModeJSONError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not Allowed in Demo Mode"})
}
func getSession(session sessions.Session) (user string, ok bool) {
// Check Session
authorizedUser := session.Get("authorizedUser")

View File

@@ -19,6 +19,7 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"reichard.io/bbank/database"
"reichard.io/bbank/metadata"
)
type activityItem struct {
@@ -168,6 +169,13 @@ func (api *API) setProgress(c *gin.Context) {
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{
"document": progress.DocumentID,
"timestamp": progress.CreatedAt,
@@ -268,8 +276,8 @@ func (api *API) addActivities(c *gin.Context) {
DeviceID: rActivity.DeviceID,
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
Duration: int64(item.Duration),
Page: int64(item.Page),
Pages: int64(item.Pages),
StartPercentage: float64(item.Page) / float64(item.Pages),
EndPercentage: float64(item.Page+1) / float64(item.Pages),
}); err != nil {
log.Error("[addActivities] AddActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
@@ -284,13 +292,14 @@ func (api *API) addActivities(c *gin.Context) {
return
}
// Update Temp Tables
go func() {
log.Info("[addActivities] Caching Temp Tables")
if err := api.DB.CacheTempTables(); err != nil {
log.Warn("[addActivities] CacheTempTables Failure: ", err)
// Update Statistic
for _, doc := range allDocuments {
log.Info("[addActivities] UpdateDocumentUserStatistic Running...")
if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
log.Error("[addActivities] UpdateDocumentUserStatistic Error:", err)
}
log.Info("[addActivities] UpdateDocumentUserStatistic Complete")
}
}()
c.JSON(http.StatusOK, gin.H{
"added": len(rActivity.Activity),
@@ -367,7 +376,7 @@ func (api *API) addDocuments(c *gin.Context) {
// Upsert 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,
Title: api.sanitizeInput(doc.Title),
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"})
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
@@ -416,7 +415,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
}
// 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,
UserID: rUser.(string),
DeviceName: rCheckDocs.Device,
@@ -431,7 +430,6 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
missingDocs := []database.Document{}
deletedDocIDs := []string{}
if device.Sync == true {
// Get Missing Documents
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
@@ -447,7 +445,6 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
}
// Get Wanted Documents
jsonHaves, err := json.Marshal(rCheckDocs.Have)
@@ -576,27 +573,26 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
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
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Md5: fileHash,
Filepath: &fileName,
Words: &wordCount,
}); err != nil {
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
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{
"status": "ok",
})
@@ -636,7 +632,7 @@ func (api *API) downloadDocument(c *gin.Context) {
}
// Force Download (Security)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(*document.Filepath)))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
c.File(filePath)
}

View File

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

122
assets/common.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Custom Service Worker Convenience Functions Wrapper
**/
const SW = (function () {
// Helper Function
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
.toString(16)
.toUpperCase()
);
}
// Variables
let swInstance = null;
let outstandingMessages = {};
navigator.serviceWorker?.addEventListener("message", ({ data }) => {
let { id } = data;
data = data.data;
console.log("[SW] Received Message:", { id, data });
if (!outstandingMessages[id])
return console.warn("[SW] Invalid Outstanding Message:", { id, data });
outstandingMessages[id](data);
delete outstandingMessages[id];
});
async function install() {
if (!navigator.serviceWorker)
throw new Error("Service Worker Not Supported");
// Register Service Worker
swInstance = await navigator.serviceWorker.register("/sw.js");
swInstance.onupdatefound = (data) =>
console.log("[SW.install] Update Found:", data);
// Wait for Registration / Update
let serviceWorker =
swInstance.installing || swInstance.waiting || swInstance.active;
// Await Installation
await new Promise((resolve) => {
serviceWorker.onstatechange = (data) => {
console.log("[SW.install] State Change:", serviceWorker.state);
if (["installed", "activated"].includes(serviceWorker.state)) resolve();
};
console.log("[SW.install] Current State:", serviceWorker.state);
if (["installed", "activated"].includes(serviceWorker.state)) resolve();
});
}
function send(data) {
if (!swInstance?.active) return Promise.reject("Inactive Service Worker");
let id = randomID();
let msgPromise = new Promise((resolve) => {
outstandingMessages[id] = resolve;
});
swInstance.active.postMessage({ id, data });
return msgPromise;
}
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

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 699 KiB

After

Width:  |  Height:  |  Size: 699 KiB

View File

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 462 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

78
assets/index.js Normal file
View File

@@ -0,0 +1,78 @@
// Install Service Worker
async function installServiceWorker() {
// Attempt Installation
await SW.install()
.then(() => console.log("[installServiceWorker] Service Worker Installed"))
.catch((e) =>
console.log("[installServiceWorker] Service Worker Install Error:", e)
);
}
// Flush Cached Progress & Activity
async function flushCachedData() {
let allProgress = await IDB.find(/^PROGRESS-/, true);
let allActivity = await IDB.get("ACTIVITY");
console.log("[flushCachedData] Flushing Data:", { allProgress, allActivity });
Object.entries(allProgress).forEach(([id, progressEvent]) => {
flushProgress(progressEvent)
.then(() => {
console.log("[flushCachedData] Progress Flush Success:", id);
return IDB.del(id);
})
.catch((e) => {
console.log("[flushCachedData] Progress Flush Failure:", id, e);
});
});
if (!allActivity) return;
flushActivity(allActivity)
.then(() => {
console.log("[flushCachedData] Activity Flush Success");
return IDB.del("ACTIVITY");
})
.catch((e) => {
console.log("[flushCachedData] Activity Flush Failure", e);
});
}
function flushActivity(activityEvent) {
console.log("[flushActivity] Flushing Activity...");
// Flush Activity
return fetch("/api/ko/activity", {
method: "POST",
body: JSON.stringify(activityEvent),
}).then(async (r) =>
console.log("[flushActivity] Flushed Activity:", {
response: r,
json: await r.json(),
data: activityEvent,
})
);
}
function flushProgress(progressEvent) {
console.log("[flushProgress] Flushing Progress...");
// Flush Progress
return fetch("/api/ko/syncs/progress", {
method: "PUT",
body: JSON.stringify(progressEvent),
}).then(async (r) =>
console.log("[flushProgress] Flushed Progress:", {
response: r,
json: await r.json(),
data: progressEvent,
})
);
}
// Event Listeners
window.addEventListener("online", flushCachedData);
// Initial Load
flushCachedData();
installServiceWorker();

1
assets/lib/idb-keyval.min.js vendored Normal file
View File

@@ -0,0 +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})}));

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

282
assets/local/index.html Normal file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume - Local</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/idb-keyval.min.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/local/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
}
main {
height: calc(100dvh - 4rem - env(safe-area-inset-top));
}
#container {
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
}
/* No Scrollbar - IE, Edge, Firefox */
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* No Scrollbar - WebKit */
*::-webkit-scrollbar {
display: none;
}
.css-button:checked + div {
display: block;
opacity: 1;
}
.css-button + div {
display: none;
opacity: 0;
}
.css-button:checked + div + label {
display: none;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-800">
<div class="flex items-center justify-between w-full h-16">
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-48">
Local Documents
</h1>
</div>
<main class="relative overflow-hidden">
<div
id="container"
class="h-[100dvh] px-4 overflow-auto md:px-6 lg:mx-48"
>
<div
id="online"
class="rounded text-black dark:text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
>
You're Online:
<a
href="/"
class="p-2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>Go Home</a
>
</div>
<div
id="message"
class="rounded text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
>
Loading...
</div>
<div
id="items"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
></div>
<div
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
>
<input
type="checkbox"
id="add-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="flex flex-col gap-2">
<input
type="file"
accept=".epub"
id="document_file"
name="document_file"
/>
<button
class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
>
Add File
</button>
</div>
<label for="add-file-button">
<div
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</div>
</label>
</div>
<label
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
for="add-file-button"
>
<svg
width="34"
height="34"
class="text-gray-200 dark:text-gray-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
/>
<path
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
/>
</svg>
</label>
</div>
</div>
</main>
<!-- Template HTML Elements -->
<div class="hidden">
<svg id="local-svg-template" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.23869 2 10.0298 2C10.6358 2 11.1214 2 11.53 2.01666C11.5166 2.09659 11.5095 2.17813 11.5092 2.26057L11.5 5.09497C11.4999 6.19207 11.4998 7.16164 11.6049 7.94316C11.7188 8.79028 11.9803 9.63726 12.6716 10.3285C13.3628 11.0198 14.2098 11.2813 15.0569 11.3952C15.8385 11.5003 16.808 11.5002 17.9051 11.5001L18 11.5001H21.9574C22 12.0344 22 12.6901 22 13.5629V14C22 17.7712 22 19.6569 20.8284 20.8284C19.6569 22 17.7712 22 14 22Z" />
<path d="M19.3517 7.61665L15.3929 4.05375C14.2651 3.03868 13.7012 2.53114 13.0092 2.26562L13 5.00011C13 7.35713 13 8.53564 13.7322 9.26787C14.4645 10.0001 15.643 10.0001 18 10.0001H21.5801C21.2175 9.29588 20.5684 8.71164 19.3517 7.61665Z" />
</svg>
<svg id="remote-svg-template" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3517 7.61665L15.3929 4.05375C14.2651 3.03868 13.7012 2.53114 13.0092 2.26562L13 5.00011C13 7.35713 13 8.53564 13.7322 9.26787C14.4645 10.0001 15.643 10.0001 18 10.0001H21.5801C21.2175 9.29588 20.5684 8.71164 19.3517 7.61665Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 22H14C17.7712 22 19.6569 22 20.8284 20.8284C22 19.6569 22 17.7712 22 14V13.5629C22 12.6901 22 12.0344 21.9574 11.5001H18L17.9051 11.5001C16.808 11.5002 15.8385 11.5003 15.0569 11.3952C14.2098 11.2813 13.3628 11.0198 12.6716 10.3285C11.9803 9.63726 11.7188 8.79028 11.6049 7.94316C11.4998 7.16164 11.4999 6.19207 11.5 5.09497L11.5092 2.26057C11.5095 2.17813 11.5166 2.09659 11.53 2.01666C11.1214 2 10.6358 2 10.0298 2C6.23869 2 4.34315 2 3.17157 3.17157C2 4.34315 2 6.22876 2 10V14C2 17.7712 2 19.6569 3.17157 20.8284C4.34315 22 6.22876 22 10 22ZM11 18C12.1046 18 13 17.2099 13 16.2353C13 15.4629 12.4375 14.8063 11.6543 14.5672C11.543 13.6855 10.6956 13 9.66667 13C8.5621 13 7.66667 13.7901 7.66667 14.7647C7.66667 14.9803 7.71047 15.1868 7.79066 15.3778C7.69662 15.3615 7.59944 15.3529 7.5 15.3529C6.67157 15.3529 6 15.9455 6 16.6765C6 17.4074 6.67157 18 7.5 18H11Z"/>
</svg>
<div id="item-template" class="w-full relative">
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div class="min-w-fit my-auto h-48 relative">
<a href="#">
<img class="rounded object-cover h-full" src="/assets/images/no-cover.jpg"></img>
</a>
</div>
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Title</p>
<p class="font-medium">
N/A
</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium">
N/A
</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">
0%
</p>
</div>
</div>
</div>
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
<div class="relative">
<label for="delete-button">
<svg
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"/>
<div class="absolute z-30 bottom-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">
<span
class="block cursor-pointer font-medium text-sm text-center w-32 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
>Delete</span>
</div>
</div>
<a href="#">
<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
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>
</div>
</div>
<div class="absolute top-0 right-0">
<strong class="bg-blue-100 text-blue-700 inline-flex items-center gap-1 rounded-tr rounded-bl p-1">
<div class="w-4 h-4"></div>
<span class="text-xs font-medium">REMOTE</span>
</strong>
</div>
</div>
</div>
</body>
</html>

319
assets/local/index.js Normal file
View File

@@ -0,0 +1,319 @@
const GET_SW_CACHE = "GET_SW_CACHE";
const DEL_SW_CACHE = "DEL_SW_CACHE";
// ----------------------------------------------------------------------- //
// --------------------------- Event Listeners --------------------------- //
// ----------------------------------------------------------------------- //
/**
* Initial load handler. Gets called on DOMContentLoaded.
**/
async function handleLoad() {
handleOnlineChange();
// If SW Redirected
if (document.location.pathname !== "/local")
window.history.replaceState(null, null, "/local");
// Create Upload Listener
let uploadButton = document.querySelector("button");
uploadButton.addEventListener("click", handleFileAdd);
// Ensure Installed -> Get Cached Items
let swCache = await SW.install()
// Get Service Worker Cache Books
.then(async () => {
let swResponse = await SW.send({ type: GET_SW_CACHE });
return Promise.all(
// Normalize Cached Results
swResponse.map(async (item) => {
let localCache = await IDB.get("PROGRESS-" + item.id);
if (localCache) {
item.progress = localCache.progress;
item.percentage = Math.round(localCache.percentage * 10000) / 100;
}
// Additional Values
item.fileURL = "/documents/" + item.id + "/file";
item.coverURL = "/documents/" + item.id + "/cover";
item.type = "REMOTE";
return item;
})
);
})
// Fail Nicely -> Allows Local Feature
.catch((e) => {
console.log("[loadContent] Service Worker Cache Error:", e);
return [];
});
// Get & Normalize Local Books
let localResponse = await IDB.find(/^FILE-.{32}$/, false);
let localCache = await Promise.all(localResponse.map(getLocalProgress));
// Populate DOM with Cache & Local Books
populateDOMBooks([...swCache, ...localCache]);
}
/**
* Update DOM to indicate online status. If no argument is passed, we attempt
* to determine online status via `navigator.onLine`.
**/
function handleOnlineChange(isOnline) {
let onlineEl = document.querySelector("#online");
isOnline = isOnline == undefined ? navigator.onLine : isOnline;
onlineEl.hidden = !isOnline;
}
/**
* Allow deleting local or remote cached files. Deleting remotely cached files
* does not remove progress. Progress will still be flushed once online.
**/
async function handleFileDelete(event, item) {
let mainEl =
event.target.parentElement.parentElement.parentElement.parentElement
.parentElement;
if (item.type == "LOCAL") {
await IDB.del("FILE-" + item.id);
await IDB.del("FILE-METADATA-" + item.id);
} else if (item.type == "REMOTE") {
let swResp = await SW.send({ type: DEL_SW_CACHE, id: item.id });
if (swResp != "SUCCESS")
throw new Error("[handleFileDelete] Service Worker Error");
}
console.log("[handleFileDelete] Item Deleted");
mainEl.remove();
updateMessage();
}
/**
* Allow adding file to offline reader. Add to IndexedDB,
* and later upload? Add style indicating external file?
**/
async function handleFileAdd() {
const fileInput = document.getElementById("document_file");
const file = fileInput.files[0];
if (!file) return console.log("[handleFileAdd] No File");
function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(file);
});
}
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
);
}
let newID = randomID();
readFile(file)
// Store Blob in IDB
.then((fileData) => {
if (!isEpubFile(fileData)) throw new Error("Invalid File Type");
return IDB.set(
"FILE-" + newID,
new Blob([fileData], { type: "application/octet-binary" })
);
})
// Process File
.then(() => getLocalProgress("FILE-" + newID))
// Populate in DOM
.then((item) => populateDOMBooks([item]))
// Hide Add File Button
.then(() => {
let addButtonEl = document.querySelector("#add-file-button");
addButtonEl.checked = false;
})
// Logging
.then(() => console.log("[handleFileAdd] File Add Successfully"))
.catch((e) => console.log("[handleFileAdd] File Add Failed:", e));
}
// Add Event Listeners
window.addEventListener("DOMContentLoaded", handleLoad);
window.addEventListener("online", () => handleOnlineChange(true));
window.addEventListener("offline", () => handleOnlineChange(false));
// ----------------------------------------------------------------------- //
// ------------------------------- Helpers ------------------------------- //
// ----------------------------------------------------------------------- //
/**
* Update the message element. Called after initial load, on item add or on
* item delete.
**/
function updateMessage() {
// Update Loader / No Results Indicator
let itemsEl = document.querySelector("#items");
let messageEl = document.querySelector("#message");
if (itemsEl.children.length == 0) {
messageEl.innerText = "No Results";
messageEl.hidden = false;
} else messageEl.hidden = true;
}
/**
* Populate DOM with cached documents.
**/
function populateDOMBooks(data) {
let allDocuments = document.querySelector("#items");
// Create Document Items
data.forEach((item) => {
// Create Main Element
let baseEl = document.querySelector("#item-template").cloneNode(true);
baseEl.removeAttribute("id");
// Get Elements
let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p");
let [svgDivEl, textEl] = baseEl.querySelector("strong").children;
let coverEl = baseEl.querySelector("a img");
let downloadEl = baseEl.querySelector("svg").parentElement;
let deleteInputEl = baseEl.querySelector("#delete-button");
let deleteLabelEl = deleteInputEl.previousElementSibling;
let deleteTextEl = baseEl.querySelector("input + div span");
// Set Download Attributes
downloadEl.setAttribute("href", item.fileURL);
downloadEl.setAttribute(
"download",
item.title + " - " + item.author + ".epub"
);
// Set Cover Attributes
coverEl.setAttribute("src", item.coverURL);
coverEl.parentElement.setAttribute(
"href",
"/reader#id=" + item.id + "&type=" + item.type
);
// Set Additional Metadata Attributes
titleEl.textContent = item.title;
authorEl.textContent = item.author;
percentageEl.textContent = item.percentage + "%";
// Set Remote / Local Indicator
let newSvgEl =
item.type == "LOCAL"
? document.querySelector("#local-svg-template").cloneNode(true)
: document.querySelector("#remote-svg-template").cloneNode(true);
svgDivEl.append(newSvgEl);
textEl.textContent = item.type;
// Delete Item
deleteInputEl.setAttribute("id", "delete-button-" + item.id);
deleteLabelEl.setAttribute("for", "delete-button-" + item.id);
deleteTextEl.addEventListener("click", (e) => handleFileDelete(e, item));
deleteTextEl.textContent =
item.type == "LOCAL" ? "Delete Local" : "Delete Cache";
allDocuments.append(baseEl);
});
updateMessage();
}
/**
* Given an item id, generate expected item format from IDB data store.
**/
async function getLocalProgress(id) {
// Get Metadata (Cover Always Needed)
let fileBlob = await IDB.get(id);
let fileURL = URL.createObjectURL(fileBlob);
let metadata = await getMetadata(fileURL);
// Attempt Cache
let documentID = id.replace("FILE-", "");
let documentData = await IDB.get("FILE-METADATA-" + documentID);
if (documentData)
return { ...documentData, fileURL, coverURL: metadata.coverURL };
// Create Starting Progress
let newProgress = {
id: documentID,
title: metadata.title,
author: metadata.author,
type: "LOCAL",
percentage: 0,
progress: "",
words: 0,
};
// Update Cache
await IDB.set("FILE-METADATA-" + documentID, newProgress);
// Return Cache + coverURL
return { ...newProgress, fileURL, coverURL: metadata.coverURL };
}
/**
* Retrieve the Title, Author, and CoverURL (blob) for a given file.
**/
async function getMetadata(fileURL) {
let book = ePub(fileURL, { openAs: "epub" });
console.log({ book });
let coverURL = (await book.coverUrl()) || "/assets/images/no-cover.jpg";
let metadata = await book.loaded.metadata;
let title =
metadata.title && metadata.title != "" ? metadata.title : "Unknown";
let author =
metadata.creator && metadata.creator != "" ? metadata.creator : "Unknown";
book.destroy();
return { title, author, coverURL };
}
/**
* Validate filetype. We check the headers and validate that they are ZIP.
* After which we validate contents. This isn't 100% effective, but unless
* someone is trying to trick it, it should be fine.
**/
function isEpubFile(arrayBuffer) {
const view = new DataView(arrayBuffer);
// Too Small
if (view.byteLength < 4) {
return false;
}
// Check for the ZIP file signature (PK)
const littleEndianSignature = view.getUint16(0, true);
const bigEndianSignature = view.getUint16(0, false);
if (littleEndianSignature !== 0x504b && bigEndianSignature !== 0x504b) {
return false;
}
// Additional Checks (No FP on ZIP)
const textDecoder = new TextDecoder();
const zipContent = textDecoder.decode(new Uint8Array(arrayBuffer));
if (
zipContent.includes("mimetype") &&
zipContent.includes("META-INF/container.xml")
) {
return true;
}
return false;
}

View File

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

View File

@@ -14,12 +14,27 @@
/>
<meta name="theme-color" content="#D2B48C" />
<title>Book Manager - {{block "title" .}}{{end}}</title>
<title>AnthoLume - Reader</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/platform.min.js"></script>
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/no-sleep.min.js"></script>
<script src="/assets/lib/idb-keyval.min.js"></script>
<!-- Reader -->
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script>
<script src="/assets/reader/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
@@ -66,7 +81,7 @@
>
<div class="w-full h-32 flex items-center justify-around relative">
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
<a href="../{{ .Data.ID }}">
<a href="#">
<svg
width="32"
height="32"
@@ -101,8 +116,11 @@
<div class="flex gap-10 h-full p-4 pl-14 rounded">
<div class="h-full my-auto relative">
<a href="../{{ .Data.ID }}">
<img class="rounded object-cover h-full" src="./cover" />
<a href="#">
<img
class="rounded object-cover h-full"
src="/assets/images/no-cover.jpg"
/>
</a>
</div>
<div class="flex gap-7 justify-around dark:text-white text-sm">
@@ -113,7 +131,7 @@
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
{{ or .Data.Title "N/A" }}
"N/A"
</p>
</div>
</div>
@@ -123,7 +141,7 @@
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
{{ or .Data.Author "N/A" }}
"N/A"
</p>
</div>
</div>
@@ -240,7 +258,7 @@
</div>
</div>
</div>
{{block "content" .}}{{end}}
<div id="viewer" class="w-full h-full"></div>
</main>
</body>
</html>

View File

@@ -1,9 +1,78 @@
const THEMES = ["light", "tan", "blue", "gray", "black"];
const THEME_FILE = "/assets/reader/readerThemes.css";
/**
* Initial load handler. Gets called on DOMContentLoaded. Responsible for
* normalizing the documentData depending on type (REMOTE or LOCAL), and
* populating the metadata of the book into the DOM.
**/
async function initReader() {
let documentData;
let filePath;
// Get Document ID & Type
const urlParams = new URLSearchParams(window.location.hash.slice(1));
const documentID = urlParams.get("id");
const documentType = urlParams.get("type");
if (documentType == "REMOTE") {
// Get Server / Cached Document
let progressResp = await fetch("/documents/" + documentID + "/progress");
documentData = await progressResp.json();
// Update With Local Cache
let localCache = await IDB.get("PROGRESS-" + documentID);
if (localCache) {
documentData.progress = localCache.progress;
documentData.percentage = Math.round(localCache.percentage * 10000) / 100;
}
filePath = "/documents/" + documentID + "/file";
} else if (documentType == "LOCAL") {
documentData = await IDB.get("FILE-METADATA-" + documentID);
let fileBlob = await IDB.get("FILE-" + documentID);
filePath = URL.createObjectURL(fileBlob);
} else {
throw new Error("Invalid Type");
}
// Update Type
documentData.type = documentType;
// Populate Metadata & Create Reader
window.currentReader = new EBookReader(filePath, documentData);
populateMetadata(documentData);
}
/**
* Populates metadata into the DOM. Specifically for the top "drop" down.
**/
function populateMetadata(data) {
let documentLocation =
data.type == "LOCAL" ? "/local" : "/documents/" + data.id;
let documentCoverLocation =
data.type == "LOCAL"
? "/assets/images/no-cover.jpg"
: "/documents/" + data.id + "/cover";
let [backEl, coverEl] = document.querySelectorAll("a");
backEl.setAttribute("href", documentLocation);
coverEl.setAttribute("href", documentLocation);
coverEl.firstElementChild.setAttribute("src", documentCoverLocation);
let [titleEl, authorEl] = document.querySelectorAll("#top-bar p + p");
titleEl.innerText = data.title;
authorEl.innerText = data.author;
}
/**
* This is the main reader class. All functionality is wrapped in this class.
* Responsible for handling gesture / clicks, flushing progress & activity,
* storing and processing themes, etc.
**/
class EBookReader {
bookState = {
currentWord: 0,
pages: 0,
percentage: 0,
progress: "",
@@ -45,21 +114,11 @@ class EBookReader {
* Load progress and generate locations
**/
async setupReader() {
// Get Word Count
this.bookState.words = await this.countWords();
// Load Progress
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
if (!cfi) this.bookState.currentWord = 0;
let getStats = function () {
// Start Timer
this.bookState.pageStart = Date.now();
// Get Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
}.bind(this);
// Register Content Hook
this.rendition.hooks.content.register(getStats);
// Update Position
await this.setPosition(cfi);
@@ -67,8 +126,14 @@ class EBookReader {
// Highlight Element - DOM Has Element
let { element } = await this.getCFIFromXPath(this.bookState.progress);
// Set Progress Element & Highlight
this.bookState.progressElement = element;
this.highlightPositionMarker();
// Update Stats & Page Start
let stats = await this.getBookStats();
this.updateBookStatElements(stats);
this.bookState.pageStart = Date.now();
}
initDevice() {
@@ -375,25 +440,41 @@ class EBookReader {
// ------------------------------------------------ //
// --------------- Bottom & Top Bar --------------- //
// ------------------------------------------------ //
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
renderDoc.addEventListener("click", function (event) {
let barPixels = emSize * 5;
renderDoc.addEventListener(
"click",
function (event) {
// Get Window Dimensions
let windowWidth = window.innerWidth;
let windowHeight = window.innerHeight;
// Calculate X & Y Hot Zones
let barPixels = windowHeight * 0.2;
let pagePixels = windowWidth * 0.2;
// Calculate Top & Bottom Thresholds
let top = barPixels;
let bottom = window.innerHeight - top;
let left = barPixels / 2;
let right = window.innerWidth - left;
// Calculate Left & Right Thresholds
let left = pagePixels;
let right = windowWidth - left;
if (event.clientY < top) handleSwipeDown();
else if (event.clientY > bottom) handleSwipeUp();
else if (event.screenX < left) prevPage();
else if (event.screenX > right) nextPage();
// Calculate Relative Coords
let leftOffset = this.views().container.scrollLeft;
let yCoord = event.clientY;
let xCoord = event.clientX - leftOffset;
// Handle Event
if (yCoord < top) handleSwipeDown();
else if (yCoord > bottom) handleSwipeUp();
else if (xCoord < left) prevPage();
else if (xCoord > right) nextPage();
else {
bottomBar.classList.remove("bottom-0");
topBar.classList.remove("top-0");
}
});
}.bind(this)
);
renderDoc.addEventListener(
"wheel",
@@ -500,7 +581,6 @@ class EBookReader {
"click",
function (event) {
let colorScheme = event.target.innerText;
console.log(colorScheme);
this.setTheme({ colorScheme });
}.bind(this)
);
@@ -559,26 +639,8 @@ class EBookReader {
* Progresses to the next page & monitors reading activity
**/
async nextPage() {
// Flush Activity
this.flushActivity();
// Get Elapsed Time
let elapsedTime = Date.now() - this.bookState.pageStart;
// Update Current Word
let pageWords = await this.getVisibleWordCount();
let startingWord = this.bookState.currentWord;
let percentRead = pageWords / this.bookState.words;
this.bookState.currentWord += pageWords;
// Add Read Event
this.bookState.readActivity.push({
percentRead,
startingWord,
pageWords,
elapsedTime,
startTime: this.bookState.pageStart,
});
// Create Activity
await this.createActivity();
// Render Next Page
await this.rendition.next();
@@ -587,45 +649,29 @@ class EBookReader {
this.bookState.pageStart = Date.now();
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
let stats = await this.getBookStats();
this.updateBookStatElements(stats);
// Update & Flush Progress
let currentCFI = await this.rendition.currentLocation();
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
this.bookState.progress = xpath;
this.bookState.progressElement = element;
this.flushProgress();
// Create Progress
this.createProgress();
}
/**
* Progresses to the previous page & monitors reading activity
**/
async prevPage() {
// Flush Activity
this.flushActivity();
// Render Previous Page
await this.rendition.prev();
// Update Current Word
let pageWords = await this.getVisibleWordCount();
this.bookState.currentWord -= pageWords;
// Reset Read Timer
this.bookState.pageStart = Date.now();
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
let stats = await this.getBookStats();
this.updateBookStatElements(stats);
// Update & Flush Progress
let currentCFI = await this.rendition.currentLocation();
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
this.bookState.progress = xpath;
this.bookState.progressElement = element;
this.flushProgress();
// Create Progress
this.createProgress();
}
/**
@@ -646,116 +692,156 @@ class EBookReader {
this.highlightPositionMarker();
}
/**
* Normalize and flush activity
**/
async flushActivity() {
// Process & Reset Activity
let allActivity = this.bookState.readActivity;
this.bookState.readActivity = [];
async createActivity() {
// WPM MAX & MIN
const WPM_MAX = 2000;
const WPM_MIN = 100;
let normalizedActivity = allActivity
// Exclude Fast WPM
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
.map((item) => {
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
// Get Elapsed Time
let pageStart = this.bookState.pageStart;
let elapsedTime = Date.now() - pageStart;
// Min WPM
if (pageWPM < WPM_MIN) {
// TODO - Exclude Event?
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
}
// Update Current Word
let pageWords = await this.getVisibleWordCount();
let currentWord = await this.getBookWordPosition();
let percentRead = pageWords / this.bookState.words;
item.pages = Math.round(1 / item.percentRead);
let pageWPM = pageWords / (elapsedTime / 60000);
console.log("[createActivity] Page WPM:", pageWPM);
item.page = Math.round(
(item.startingWord * item.pages) / this.bookState.words
// Exclude Ridiculous WPM
if (pageWPM >= WPM_MAX)
return console.log(
"[createActivity] Page WPM Exceeds Max (2000):",
pageWPM
);
// Estimate Accuracy Loss (Debugging)
// let wordLoss = Math.abs(
// item.pageWords - this.bookState.words / item.pages
// );
// console.log("Word Loss:", wordLoss);
// Ensure WPM Minimum
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
return {
document: this.bookState.id,
duration: Math.round(item.elapsedTime / 1000),
start_time: Math.round(item.startTime / 1000),
page: item.page,
pages: item.pages,
};
});
let totalPages = Math.round(1 / percentRead);
if (normalizedActivity.length == 0) return;
// Exclude 0 Pages
if (totalPages == 0)
return console.warn("[createActivity] Invalid Total Pages (0)");
console.log("Flushing Activity...");
let currentPage = Math.round(
(currentWord * totalPages) / this.bookState.words
);
// Create Activity Event
let activityEvent = {
device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName,
activity: normalizedActivity,
activity: [
{
document: this.bookState.id,
duration: Math.round(elapsedTime / 1000),
start_time: Math.round(pageStart / 1000),
page: currentPage,
pages: totalPages,
},
],
};
// Local Files
if (this.bookState.type == "LOCAL") return;
// Remote Flush -> Offline Cache IDB
this.flushActivity(activityEvent).catch(async (e) => {
console.error("[createActivity] Activity Flush Failed:", {
error: e,
data: activityEvent,
});
// Get & Update Activity
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
existingActivity.device_id = activityEvent.device_id;
existingActivity.device = activityEvent.device;
existingActivity.activity.push(...activityEvent.activity);
// Update IDB
await IDB.set("ACTIVITY", existingActivity);
});
}
/**
* Normalize and flush activity
**/
flushActivity(activityEvent) {
console.log("[flushActivity] Flushing Activity...");
// Flush Activity
fetch("/api/ko/activity", {
return fetch("/api/ko/activity", {
method: "POST",
body: JSON.stringify(activityEvent),
})
.then(async (r) =>
console.log("Flushed Activity:", {
}).then(async (r) =>
console.log("[flushActivity] Flushed Activity:", {
response: r,
json: await r.json(),
data: activityEvent,
})
)
.catch((e) =>
console.error("Activity Flush Failed:", {
error: e,
data: activityEvent,
})
);
}
/**
* Flush progress to the API. Called when the page changes.
**/
async flushProgress() {
console.log("Flushing Progress...");
async createProgress() {
// Update Pointers
let currentCFI = await this.rendition.currentLocation();
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.progressElement = element;
// Create Progress Event
// Create Event
let progressEvent = {
document: this.bookState.id,
device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName,
percentage:
Math.round(
(this.bookState.currentWord / this.bookState.words) * 100000
) / 100000,
Math.round((currentWord / this.bookState.words) * 100000) / 100000,
progress: this.bookState.progress,
};
// Update Local Metadata
if (this.bookState.type == "LOCAL") {
let currentMetadata = await IDB.get("FILE-METADATA-" + this.bookState.id);
return IDB.set("FILE-METADATA-" + this.bookState.id, {
...currentMetadata,
progress: progressEvent.progress,
percentage: Math.round(progressEvent.percentage * 10000) / 100,
words: this.bookState.words,
});
}
// Remote Flush -> Offline Cache IDB
this.flushProgress(progressEvent).catch(async (e) => {
console.error("[createProgress] Progress Flush Failed:", {
error: e,
data: progressEvent,
});
// Update IDB
await IDB.set("PROGRESS-" + progressEvent.document, progressEvent);
});
}
/**
* Flush progress to the API. Called when the page changes.
**/
flushProgress(progressEvent) {
console.log("[flushProgress] Flushing Progress...");
// Flush Progress
fetch("/api/ko/syncs/progress", {
return fetch("/api/ko/syncs/progress", {
method: "PUT",
body: JSON.stringify(progressEvent),
})
.then(async (r) =>
console.log("Flushed Progress:", {
}).then(async (r) =>
console.log("[flushProgress] Flushed Progress:", {
response: r,
json: await r.json(),
data: progressEvent,
})
)
.catch((e) =>
console.error("Progress Flush Failed:", {
error: e,
data: progressEvent,
})
);
}
@@ -764,7 +850,8 @@ class EBookReader {
**/
sectionProgress() {
let visibleItems = this.rendition.manager.visible();
if (visibleItems.length == 0) return console.log("No Items");
if (visibleItems.length == 0)
return console.log("[sectionProgress] No Items");
let visibleSection = visibleItems[0];
let visibleIndex = visibleSection.index;
let pagesPerBlock = visibleSection.layout.divisor;
@@ -781,12 +868,13 @@ class EBookReader {
/**
* Get chapter pages, name and progress percentage
**/
getBookStats() {
async getBookStats() {
let currentProgress = this.sectionProgress();
if (!currentProgress) return;
let { sectionPages, sectionCurrentPage } = currentProgress;
let currentLocation = this.rendition.currentLocation();
let currentWord = await this.getBookWordPosition();
let currentTOC = this.book.navigation.toc.find(
(item) => item.href == currentLocation.start.href
@@ -797,16 +885,14 @@ class EBookReader {
sectionTotalPages: sectionPages,
chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
percentage:
Math.round(
(this.bookState.currentWord / this.bookState.words) * 10000
) / 100,
Math.round((currentWord / this.bookState.words) * 10000) / 100,
};
}
/**
* Update elements with stats
**/
updateBookStats(data) {
updateBookStatElements(data) {
if (!data) return;
let chapterStatus = document.querySelector("#chapter-status");
@@ -824,7 +910,7 @@ class EBookReader {
* Get XPath from current location
**/
async getXPathFromCFI(cfi) {
// Get DocFragment (current book spline index)
// Get DocFragment (Spine Index)
let startCFI = cfi.replace("epubcfi(", "");
let docFragmentIndex =
this.book.spine.spineItems.find((item) =>
@@ -832,58 +918,62 @@ class EBookReader {
).index + 1;
// 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 node = contents.range(cfi).startContainer;
let element = null;
let currentNode = contents.range(cfi).startContainer;
let element =
currentNode.nodeType == Node.ELEMENT_NODE
? currentNode
: currentNode.parentElement;
// Walk upwards and build progress until body
let childPos = "";
while (node.nodeName != "BODY") {
let ownValue;
// XPath Reference
let allPos = "";
switch (node.nodeType) {
case Node.ELEMENT_NODE:
// Store First Element Node
if (!element) element = node;
let relativeIndex =
Array.from(node.parentNode.children)
.filter((item) => item.nodeName == node.nodeName)
.indexOf(node) + 1;
// Walk Upwards
while (currentNode.nodeName != "BODY") {
// Get Parent
let parentElement = currentNode.parentElement;
ownValue = node.nodeName.toLowerCase() + "[" + relativeIndex + "]";
break;
case Node.ATTRIBUTE_NODE:
ownValue = "@" + node.nodeName;
break;
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;
// Unknown Node -> Update Reference
if (currentNode.nodeType != Node.ELEMENT_NODE) {
console.log("[getXPathFromCFI] Unknown Node Type:", currentNode);
currentNode = parentElement;
continue;
}
// Prepend childPos & Update node reference
childPos = "/" + ownValue + childPos;
node = node.parentNode;
/**
* Exclude A tags. This could potentially be all inline elements:
* https://github.com/koreader/crengine/blob/master/cr3gui/data/epub.css#L149
**/
while (parentElement.nodeName == "A") {
parentElement = parentElement.parentElement;
}
let xpath = newPos + childPos;
/**
* 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;
// Return derived progress
// Get Node Position
let nodePos =
currentNode.nodeName.toLowerCase() + "[" + relativeIndex + "]";
// Update Reference
currentNode = parentElement;
// Update Position
allPos = "/" + nodePos + allPos;
}
// Combine XPath
let xpath = basePos + allPos;
// Return Derived Progress
return { xpath, element };
}
@@ -891,19 +981,13 @@ class EBookReader {
* Get CFI from current location
**/
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
if (!xpath || xpath == "") return {};
// Match Document Fragment Index
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch) {
console.warn("No XPath Match");
console.warn("[getCFIFromXPath] No XPath Match");
return {};
}
@@ -935,7 +1019,7 @@ class EBookReader {
.find((item) => item.sectionIndex == spinePosition)?.document ||
sectionItem.document;
// Derive XPath & Namespace
// Derive Namespace & XPath
let namespaceURI = docItem.documentElement.namespaceURI;
let remainingXPath = xpath
// Replace with new base
@@ -945,6 +1029,26 @@ class EBookReader {
// Remove potential trailing `text()`
.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
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
@@ -961,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);
return { cfi, element };
@@ -972,6 +1093,43 @@ class EBookReader {
* Get visible word count - used for reading stats
**/
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)
this.rendition.manager.visible().forEach((item) => item.expand());
@@ -988,7 +1146,7 @@ class EBookReader {
let visibleText = textRange.toString();
// Split on Whitespace
return visibleText.trim().split(/\s+/).length;
return visibleText;
}
/**
@@ -1038,6 +1196,24 @@ class EBookReader {
);
}
/**
* Count the words of the book. Useful for keeping a more accurate track
* of progress percentage. Implementation returns the same number as the
* server side implementation.
**/
async countWords() {
let spineWC = await Promise.all(
this.book.spine.spineItems.map(async (item) => {
let newDoc = await item.load(this.book.load.bind(this.book));
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
item.wordCount = spineWords;
return spineWords;
})
);
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
}
/**
* Save settings to localStorage
**/
@@ -1055,3 +1231,5 @@ class EBookReader {
);
}
}
document.addEventListener("DOMContentLoaded", initReader);

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

233
assets/sw.js Normal file
View File

@@ -0,0 +1,233 @@
// Misc Consts
const SW_VERSION = 1;
const SW_CACHE_NAME = "OFFLINE_V1";
// Message Types
const PURGE_SW_CACHE = "PURGE_SW_CACHE";
const DEL_SW_CACHE = "DEL_SW_CACHE";
const GET_SW_CACHE = "GET_SW_CACHE";
const GET_SW_VERSION = "GET_SW_VERSION";
// Cache Types
const CACHE_ONLY = "CACHE_ONLY";
const CACHE_NEVER = "CACHE_NEVER";
const CACHE_UPDATE_SYNC = "CACHE_UPDATE_SYNC";
const CACHE_UPDATE_ASYNC = "CACHE_UPDATE_ASYNC";
/**
* Define routes and their directives. Takes `routes`, `type`, and `fallback`.
*
* Routes (Required):
* Either a string of the exact request, or a RegExp. Order precedence.
*
* Fallback (Optional):
* A fallback function. If the request fails, this function is executed and
* its return value is returned as the result.
*
* Types (Required):
* - CACHE_ONLY
* Cache once & never refresh.
* - CACHE_NEVER
* Never cache & always perform a request.
* - CACHE_UPDATE_SYNC
* Update cache & return result.
* - CACHE_UPDATE_ASYNC
* Return cache if exists & update cache in background.
**/
const ROUTES = [
{ route: "/local", type: CACHE_UPDATE_ASYNC },
{ route: "/reader", type: CACHE_UPDATE_ASYNC },
{ route: "/manifest.json", type: CACHE_UPDATE_ASYNC },
{ route: /^\/assets\//, type: CACHE_UPDATE_ASYNC },
{
route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/,
type: CACHE_UPDATE_ASYNC,
},
{
route: /^\/documents\/[a-zA-Z0-9]{32}\/progress$/,
type: CACHE_UPDATE_SYNC,
},
{
route: /.*/,
type: CACHE_NEVER,
fallback: (event) => caches.match("/local"),
},
];
/**
* These are assets that are cached on initial service worker installation.
**/
const PRECACHE_ASSETS = [
// Offline & Reader Assets
"/local",
"/reader",
"/assets/local/index.js",
"/assets/reader/index.js",
"/assets/icons/icon512.png",
"/assets/images/no-cover.jpg",
"/assets/reader/readerThemes.css",
// Main App Assets
"/manifest.json",
"/assets/index.js",
"/assets/style.css",
"/assets/common.js",
// Library Assets
"/assets/lib/platform.min.js",
"/assets/lib/jszip.min.js",
"/assets/lib/epub.min.js",
"/assets/lib/no-sleep.min.js",
"/assets/lib/idb-keyval.min.js",
];
// ------------------------------------------------------- //
// ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- //
function purgeCache() {
console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) {
for (let name of names) caches.delete(name);
});
}
async function updateCache(request) {
let url = request.url ? new URL(request.url).pathname : request;
console.log("[updateCache] Updating Cache:", url);
let cache = await caches.open(SW_CACHE_NAME);
return fetch(request)
.then((response) => {
const resClone = response.clone();
if (response.status < 400) cache.put(request, resClone);
return response;
})
.catch((e) => {
console.log("[updateCache] Updating Cache Failed:", url);
throw e;
});
}
// ------------------------------------------------------- //
// ------------------- Event Listeners ------------------- //
// ------------------------------------------------------- //
async function handleFetch(event) {
// Get Path
let url = new URL(event.request.url).pathname;
// Find Directive
const directive = ROUTES.find(
(item) => url.match(item.route) || url == item.route
) || { type: CACHE_NEVER };
// Get Fallback
const fallbackFunc = (event) => {
console.log("[handleFetch] Fallback:", { url, directive });
if (directive.fallback) return directive.fallback(event);
};
console.log("[handleFetch] Processing:", { url, directive });
// Get Current Cache
let currentCache = await caches.match(event.request);
// Perform Caching Method
switch (directive.type) {
case CACHE_NEVER:
return fetch(event.request).catch((e) => fallbackFunc(event));
case CACHE_ONLY:
return (
currentCache ||
updateCache(event.request).catch((e) => fallbackFunc(event))
);
case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event)
);
case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event)
);
return currentCache || newResponse;
}
}
function handleMessage(event) {
console.log("[handleMessage] Received Message:", event.data);
let { id, data } = event.data;
if (data.type === GET_SW_VERSION) {
event.source.postMessage({ id, data: SW_VERSION });
} else if (data.type === PURGE_SW_CACHE) {
purgeCache()
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
} else if (data.type === GET_SW_CACHE) {
caches.open(SW_CACHE_NAME).then(async (cache) => {
let allKeys = await cache.keys();
let docResources = allKeys
.map((item) => new URL(item.url).pathname)
.filter((item) => item.startsWith("/documents/"));
let documentIDs = Array.from(
new Set(docResources.map((item) => item.split("/")[2]))
);
/**
* Filter for cached items only. Attempt to fetch updated result. If
* failure, return cached version. This ensures we return the most up to
* date version possible.
**/
let cachedDocuments = await Promise.all(
documentIDs
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/documents/" + id + "/progress")
)
.map(async (id) => {
let url = "/documents/" + id + "/progress";
let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache);
return resp.json();
})
);
event.source.postMessage({ id, data: cachedDocuments });
});
} else if (data.type === DEL_SW_CACHE) {
let basePath = "/documents/" + data.id;
caches
.open(SW_CACHE_NAME)
.then((cache) =>
Promise.all([
cache.delete(basePath + "/file"),
cache.delete(basePath + "/progress"),
])
)
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
} else {
event.source.postMessage({ id, data: { pong: 1 } });
}
}
async function handleInstall(event) {
let cache = await caches.open(SW_CACHE_NAME);
return cache.addAll(PRECACHE_ASSETS);
}
self.addEventListener("message", handleMessage);
self.addEventListener("install", function (event) {
event.waitUntil(handleInstall(event));
});
self.addEventListener("fetch", (event) =>
event.respondWith(handleFetch(event))
);

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
- Uploading documents
@@ -12,10 +12,10 @@ Copy the `syncninja.koplugin` directory to the `plugins` directory for your KORe
## 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
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.

View File

@@ -649,7 +649,7 @@ end
function SyncNinja:checkDocuments(interactive)
logger.dbg("SyncNinja: checkDocuments")
-- ensure document sync enabled
-- Ensure Document Sync Enabled
if self.settings.sync_documents ~= true then return end
-- API Request Data
@@ -723,6 +723,8 @@ function SyncNinja:downloadDocuments(doc_metadata, interactive)
logger.dbg("SyncNinja: downloadDocuments")
-- TODO
-- - OPDS Sufficient?
-- - Auto Configure OPDS?
end
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)
@@ -851,8 +853,8 @@ function SyncNinja:getLocalDocumentMetadata()
docsettings:saveSetting("partial_md5_checksum", pmd5)
end
-- Get Document Props
local doc_props = docsettings:readSetting("doc_props")
-- Get Document Props & Ensure Not Nil
local doc_props = docsettings:readSetting("doc_props") or {}
local fdoc = bookinfo_books[v.file] or {}
-- Update or Create

View File

@@ -21,6 +21,7 @@ type Config struct {
// Miscellaneous Settings
RegistrationEnabled bool
SearchEnabled bool
DemoMode bool
// Cookie Settings
CookieSessionKey string
@@ -30,13 +31,14 @@ type Config struct {
func Load() *Config {
return &Config{
Version: "0.0.2",
Version: "0.0.1",
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
ConfigPath: getEnv("CONFIG_PATH", "/config"),
DataPath: getEnv("DATA_PATH", "/data"),
ListenPort: getEnv("LISTEN_PORT", "8585"),
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",

View File

@@ -23,6 +23,9 @@ var ddl string
//go:embed update_temp_tables.sql
var tsql string
//go:embed update_document_user_statistics.sql
var doc_user_stat_sql string
func NewMgr(c *config.Config) *DBManager {
// Create Manager
dbm := &DBManager{
@@ -56,6 +59,25 @@ func NewMgr(c *config.Config) *DBManager {
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 {
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
return err

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
-- name: AddActivity :one
INSERT INTO raw_activity (
INSERT INTO activity (
user_id,
document_id,
device_id,
start_time,
duration,
page,
pages
start_percentage,
end_percentage
)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *;
@@ -43,8 +43,7 @@ WITH filtered_activity AS (
user_id,
start_time,
duration,
page,
pages
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
FROM activity
WHERE
activity.user_id = $user_id
@@ -65,8 +64,7 @@ SELECT
title,
author,
duration,
page,
pages
read_percentage
FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id
LEFT JOIN users ON users.id = activity.user_id;
@@ -142,41 +140,6 @@ ORDER BY devices.last_synced DESC;
SELECT * FROM documents
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
SELECT
docs.id,
@@ -189,23 +152,21 @@ SELECT
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.read_percentage, 0) AS read_percentage,
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)
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL)
END AS INTEGER) AS seconds_per_page
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
@@ -221,6 +182,16 @@ ORDER BY created_at DESC
LIMIT $limit
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
SELECT
docs.id,
@@ -233,31 +204,36 @@ SELECT
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.read_percentage, 0) AS read_percentage,
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)
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
ROUND(
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
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
document_user_statistics AS dus
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
LIMIT $limit
OFFSET $offset;
@@ -298,20 +274,6 @@ WHERE id = $user_id LIMIT 1;
SELECT * FROM user_streaks
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
SELECT
user_id,
@@ -328,35 +290,18 @@ ORDER BY wpm DESC;
SELECT
CAST(value AS TEXT) AS id,
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)
LEFT JOIN documents
ON value = documents.id
WHERE (
documents.id IS NOT NULL
AND documents.deleted = false
AND (
documents.synced = false
OR documents.filepath IS NULL
)
AND documents.filepath IS NULL
)
OR (documents.id IS NULL)
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
INSERT OR REPLACE INTO document_progress (
user_id,

View File

@@ -7,22 +7,21 @@ package database
import (
"context"
"database/sql"
"strings"
)
const addActivity = `-- name: AddActivity :one
INSERT INTO raw_activity (
INSERT INTO activity (
user_id,
document_id,
device_id,
start_time,
duration,
page,
pages
start_percentage,
end_percentage
)
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 {
@@ -31,29 +30,29 @@ type AddActivityParams struct {
DeviceID string `json:"device_id"`
StartTime string `json:"start_time"`
Duration int64 `json:"duration"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
StartPercentage float64 `json:"start_percentage"`
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,
arg.UserID,
arg.DocumentID,
arg.DeviceID,
arg.StartTime,
arg.Duration,
arg.Page,
arg.Pages,
arg.StartPercentage,
arg.EndPercentage,
)
var i RawActivity
var i Activity
err := row.Scan(
&i.ID,
&i.UserID,
&i.DocumentID,
&i.DeviceID,
&i.StartTime,
&i.Page,
&i.Pages,
&i.StartPercentage,
&i.EndPercentage,
&i.Duration,
&i.CreatedAt,
)
@@ -154,8 +153,7 @@ WITH filtered_activity AS (
user_id,
start_time,
duration,
page,
pages
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
FROM activity
WHERE
activity.user_id = ?1
@@ -176,8 +174,7 @@ SELECT
title,
author,
duration,
page,
pages
read_percentage
FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id
LEFT JOIN users ON users.id = activity.user_id
@@ -197,8 +194,7 @@ type GetActivityRow struct {
Title *string `json:"title"`
Author *string `json:"author"`
Duration int64 `json:"duration"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
ReadPercentage float64 `json:"read_percentage"`
}
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.Author,
&i.Duration,
&i.Page,
&i.Pages,
&i.ReadPercentage,
); err != nil {
return nil, err
}
@@ -465,98 +460,6 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
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
SELECT
docs.id,
@@ -569,23 +472,21 @@ SELECT
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.read_percentage, 0) AS read_percentage,
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)
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL)
END AS INTEGER) AS seconds_per_page
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = ?1
LEFT JOIN
@@ -611,13 +512,11 @@ type GetDocumentWithStatsRow struct {
Filepath *string `json:"filepath"`
Words *int64 `json:"words"`
Wpm int64 `json:"wpm"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
ReadPages int64 `json:"read_pages"`
ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead interface{} `json:"last_read"`
Percentage interface{} `json:"percentage"`
SecondsPerPage int64 `json:"seconds_per_page"`
Percentage float64 `json:"percentage"`
SecondsPerPercent int64 `json:"seconds_per_percent"`
}
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.Words,
&i.Wpm,
&i.Page,
&i.Pages,
&i.ReadPages,
&i.ReadPercentage,
&i.TotalTimeSeconds,
&i.LastRead,
&i.Percentage,
&i.SecondsPerPage,
&i.SecondsPerPercent,
)
return i, err
}
@@ -699,6 +596,24 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
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
SELECT
docs.id,
@@ -711,38 +626,44 @@ SELECT
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.read_percentage, 0) AS read_percentage,
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)
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
ROUND(
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
LEFT JOIN users ON users.id = ?1
LEFT JOIN
document_user_statistics AS dus
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
LIMIT ?3
OFFSET ?2
LIMIT ?4
OFFSET ?3
`
type GetDocumentsWithStatsParams struct {
UserID string `json:"user_id"`
Query interface{} `json:"query"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
@@ -757,17 +678,20 @@ type GetDocumentsWithStatsRow struct {
Filepath *string `json:"filepath"`
Words *int64 `json:"words"`
Wpm int64 `json:"wpm"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
ReadPages int64 `json:"read_pages"`
ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead interface{} `json:"last_read"`
Percentage interface{} `json:"percentage"`
SecondsPerPage interface{} `json:"seconds_per_page"`
Percentage float64 `json:"percentage"`
SecondsPerPercent interface{} `json:"seconds_per_percent"`
}
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 {
return nil, err
}
@@ -785,13 +709,11 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
&i.Filepath,
&i.Words,
&i.Wpm,
&i.Page,
&i.Pages,
&i.ReadPages,
&i.ReadPercentage,
&i.TotalTimeSeconds,
&i.LastRead,
&i.Percentage,
&i.SecondsPerPage,
&i.SecondsPerPercent,
); err != nil {
return nil, err
}
@@ -987,56 +909,6 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
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
SELECT
user_id,
@@ -1089,17 +961,14 @@ const getWantedDocuments = `-- name: GetWantedDocuments :many
SELECT
CAST(value AS TEXT) AS id,
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)
LEFT JOIN documents
ON value = documents.id
WHERE (
documents.id IS NOT NULL
AND documents.deleted = false
AND (
documents.synced = false
OR documents.filepath IS NULL
)
AND documents.filepath IS NULL
)
OR (documents.id IS NULL)
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
}
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
INSERT OR REPLACE INTO document_progress (
user_id,

View File

@@ -91,16 +91,17 @@ CREATE TABLE IF NOT EXISTS document_progress (
PRIMARY KEY (user_id, document_id, device_id)
);
-- Raw Read Activity
CREATE TABLE IF NOT EXISTS raw_activity (
-- Read Activity
CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
device_id TEXT NOT NULL,
start_time DATETIME NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
start_percentage REAL NOT NULL,
end_percentage REAL NOT NULL,
duration INTEGER NOT NULL,
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 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)
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL,
@@ -144,13 +132,13 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read TEXT NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
total_time_seconds INTEGER NOT NULL,
read_pages INTEGER NOT NULL,
read_percentage REAL NOT NULL,
percentage REAL 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 ---------------------------
---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS temp.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 temp.activity_user_id_document_id ON activity (
CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
user_id,
document_id
);
@@ -169,100 +157,6 @@ CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
---------------------------- 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 ---------
--------------------------------
@@ -279,7 +173,7 @@ WITH document_windows AS (
'weekday 0', '-7 day'
) AS weekly_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
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
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
document_id,
user_id,
start_time AS last_read,
page,
pages,
SUM(duration) AS total_time_seconds,
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
),
-- Determine Read Pages
COUNT(DISTINCT page) AS read_pages,
-- Derive Percentage of Book
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
FROM view_rescaled_activity
GROUP BY document_id, user_id
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
true_progress.*,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
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(documents.words, 0.0) AS REAL) / pages * read_pages)
/ (total_time_seconds / 60.0) AS wpm
FROM true_progress
INNER JOIN documents ON documents.id = true_progress.document_id
(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;
---------------------------------------------------------------
------------------ Populate Temporary Tables ------------------
---------------------------------------------------------------
INSERT INTO activity SELECT * FROM view_rescaled_activity;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
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;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
DELETE FROM document_user_statistics;

View File

@@ -1,6 +1,6 @@
---
services:
bookmanager:
antholume:
environment:
- CONFIG_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))),
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 {

28
main.go
View File

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

View File

@@ -1,11 +1,10 @@
package metadata
import (
"io"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/taylorskalyo/goreader/epub"
"golang.org/x/net/html"
)
func getEPUBMetadata(filepath string) (*MetadataInfo, error) {
@@ -32,33 +31,9 @@ func countEPUBWords(filepath string) (int64, error) {
var completeCount int64
for _, item := range rf.Spine.Itemrefs {
f, _ := item.Open()
tokenizer := html.NewTokenizer(f)
newCount, err := countTokenizerWords(*tokenizer)
if err != nil {
return 0, err
}
completeCount = completeCount + newCount
doc, _ := goquery.NewDocumentFromReader(f)
completeCount = completeCount + int64(len(strings.Fields(doc.Text())))
}
return completeCount, nil
}
func countTokenizerWords(tokenizer html.Tokenizer) (int64, error) {
var err error
var totalWords int64
for {
tokenType := tokenizer.Next()
token := tokenizer.Token()
if tokenType == html.TextToken {
currStr := string(token.Data)
totalWords = totalWords + int64(len(strings.Fields(currStr)))
} else if tokenType == html.ErrorToken {
err = tokenizer.Err()
}
if err == io.EOF {
return totalWords, nil
} else if err != nil {
return 0, err
}
}
}

View File

@@ -5,7 +5,7 @@ import (
)
func TestGetWordCount(t *testing.T) {
var want int64 = 30477
var want int64 = 30080
wordCount, err := countEPUBWords("../_test_files/alice.epub")
if wordCount != want {

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
<p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="32%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="32%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png" width="32%">
</a>
</p>
<p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="32%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="32%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/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%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="32%">
</a>
</p>

View File

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

View File

@@ -38,29 +38,37 @@ type SearchItem struct {
UploadDate string
}
func SearchBook(query string, bookType BookType) (allEntries []SearchItem) {
log.Info(query)
func SearchBook(query string, bookType BookType) ([]SearchItem, error) {
if bookType == BOOK_FICTION {
// Search Fiction
url := "https://libgen.is/fiction/?q=" + url.QueryEscape(query) + "&language=English&format=epub"
body := getPage(url)
allEntries = parseLibGenFiction(body)
body, err := getPage(url)
if err != nil {
return nil, err
}
return parseLibGenFiction(body)
} else if bookType == BOOK_NON_FICTION {
// Search NonFiction
url := "https://libgen.is/search.php?req=" + url.QueryEscape(query)
body := getPage(url)
allEntries = parseLibGenNonFiction(body)
body, err := getPage(url)
if err != nil {
return nil, err
}
return parseLibGenNonFiction(body)
} else {
return nil, errors.New("Invalid Book Type")
}
return
}
func GoodReadsMostRead(c Cadence) []SearchItem {
body := getPage("https://www.goodreads.com/book/most_read?category=all&country=US&duration=" + string(c))
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
body, err := getPage("https://www.goodreads.com/book/most_read?category=all&country=US&duration=" + string(c))
if err != nil {
return nil, err
}
return parseGoodReads(body)
}
func GetBookURL(id string, bookType BookType) string {
func GetBookURL(id string, bookType BookType) (string, error) {
// Derive Info URL
var infoURL string
if bookType == BOOK_FICTION {
@@ -70,7 +78,10 @@ func GetBookURL(id string, bookType BookType) string {
}
// Parse & Derive Download URL
body := getPage(infoURL)
body, err := getPage(infoURL)
if err != nil {
return "", err
}
// downloadURL := parseLibGenDownloadURL(body)
return parseLibGenDownloadURL(body)
@@ -86,8 +97,15 @@ func SaveBook(id string, bookType BookType) (string, error) {
}
// Parse & Derive Download URL
body := getPage(infoURL)
bookURL := parseLibGenDownloadURL(body)
body, err := getPage(infoURL)
if err != nil {
return "", err
}
bookURL, err := parseLibGenDownloadURL(body)
if err != nil {
log.Error("[SaveBook] Parse Download URL Error: ", err)
return "", errors.New("Download Failure")
}
// Create File
tempFile, err := os.CreateTemp("", "book")
@@ -119,15 +137,29 @@ func SaveBook(id string, bookType BookType) (string, error) {
return tempFile.Name(), nil
}
func getPage(page string) io.ReadCloser {
resp, _ := http.Get(page)
return resp.Body
func getPage(page string) (io.ReadCloser, error) {
// Set 10s Timeout
client := http.Client{
Timeout: 10 * time.Second,
}
// Get Page
resp, err := client.Get(page)
if err != nil {
return nil, err
}
// Return Body
return resp.Body, err
}
func parseLibGenFiction(body io.ReadCloser) []SearchItem {
func parseLibGenFiction(body io.ReadCloser) ([]SearchItem, error) {
// Parse
defer body.Close()
doc, _ := goquery.NewDocumentFromReader(body)
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results
var allEntries []SearchItem
@@ -171,13 +203,16 @@ func parseLibGenFiction(body io.ReadCloser) []SearchItem {
})
// Return Results
return allEntries
return allEntries, nil
}
func parseLibGenNonFiction(body io.ReadCloser) []SearchItem {
func parseLibGenNonFiction(body io.ReadCloser) ([]SearchItem, error) {
// Parse
defer body.Close()
doc, _ := goquery.NewDocumentFromReader(body)
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results
var allEntries []SearchItem
@@ -213,25 +248,31 @@ func parseLibGenNonFiction(body io.ReadCloser) []SearchItem {
})
// Return Results
return allEntries
return allEntries, nil
}
func parseLibGenDownloadURL(body io.ReadCloser) string {
func parseLibGenDownloadURL(body io.ReadCloser) (string, error) {
// Parse
defer body.Close()
doc, _ := goquery.NewDocumentFromReader(body)
// Return Download URL
// downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href")
downloadURL, _ := doc.Find("#download h2 a").Attr("href")
downloadURL, exists := doc.Find("#download h2 a").Attr("href")
if exists == false {
return "", errors.New("Download URL not found")
}
return downloadURL
return downloadURL, nil
}
func parseGoodReads(body io.ReadCloser) []SearchItem {
func parseGoodReads(body io.ReadCloser) ([]SearchItem, error) {
// Parse
defer body.Close()
doc, _ := goquery.NewDocumentFromReader(body)
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results
var allEntries []SearchItem
@@ -249,5 +290,5 @@ func parseGoodReads(body io.ReadCloser) []SearchItem {
})
// Return Results
return allEntries
return allEntries, nil
}

View File

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

View File

@@ -1,6 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**/*.html", "./assets/reader/index.js"],
content: [
"./templates/**/*.html",
"./assets/local/*.{html,js}",
"./assets/reader/*.{html,js}",
],
theme: {
extend: {},
},

View File

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

View File

@@ -9,11 +9,16 @@
<meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)">
<title>Book Manager - {{block "title" .}}{{end}}</title>
<title>AnthoLume - {{block "title" .}}{{end}}</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css">
<!-- Service Worker / Offline Cache Flush -->
<script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
@@ -122,16 +127,16 @@
<body class="bg-gray-100 dark:bg-gray-800">
<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">
<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-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 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 class="mt-6">
<div>
<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}}"
href="/"
@@ -167,6 +172,24 @@
</span>
<span class="mx-4 text-sm font-normal">Documents</span>
</a>
<a
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
href="/local"
>
<span class="text-left">
<svg
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27103 2.11151C5.46135 2.21816 5.03258 2.41324 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.82716 15.513 6.44305 15.5132 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.2092 2 7.10452 2.00172 6.27103 2.11151ZM6.75862 6.59459C6.75862 6.1468 7.12914 5.78378 7.58621 5.78378H16.4138C16.8709 5.78378 17.2414 6.1468 17.2414 6.59459C17.2414 7.04239 16.8709 7.40541 16.4138 7.40541H7.58621C7.12914 7.40541 6.75862 7.04239 6.75862 6.59459ZM7.58621 9.56757C7.12914 9.56757 6.75862 9.93058 6.75862 10.3784C6.75862 10.8262 7.12914 11.1892 7.58621 11.1892H13.1034C13.5605 11.1892 13.931 10.8262 13.931 10.3784C13.931 9.93058 13.5605 9.56757 13.1034 9.56757H7.58621Z" />
<path d="M7.47341 17.1351H8.68965H13.1034H19.9991C19.9956 18.2657 19.9776 19.1088 19.8862 19.775C19.7773 20.5683 19.5782 20.9884 19.2728 21.2876C18.9674 21.5868 18.5387 21.7818 17.729 21.8885C16.8955 21.9983 15.7908 22 14.2069 22H9.7931C8.2092 22 7.10452 21.9983 6.27103 21.8885C5.46135 21.7818 5.03258 21.5868 4.72718 21.2876C4.42179 20.9884 4.22268 20.5683 4.11382 19.775C4.07259 19.4746 4.0463 19.1382 4.02952 18.7558C4.30088 18.0044 4.93365 17.4264 5.72738 17.218C6.01657 17.1421 6.39395 17.1351 7.47341 17.1351Z" />
</svg>
</span>
<span class="mx-4 text-sm font-normal">Local</span>
</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 "activity"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
href="/activity"
@@ -209,12 +232,47 @@
</a>
{{ end }}
</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>
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
<div
class="relative flex items-center justify-end w-full p-4 space-x-4"
>
<div class="relative flex items-center justify-end w-full p-4 space-x-4">
<a href="#" class="relative block">
<svg
width="20"
@@ -245,7 +303,7 @@
>
<a
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"
>
<span class="flex flex-col">
@@ -254,7 +312,7 @@
</a>
<a
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"
>
<span class="flex flex-col">
@@ -286,9 +344,7 @@
</div>
</div>
<main
class="relative overflow-hidden"
>
<main class="relative overflow-hidden">
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
{{block "content" .}}{{end}}
</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

@@ -17,7 +17,7 @@
{{ if .Data.Filepath }}
<a
href="./{{ .Data.ID }}/reader"
href="/reader#id={{ .Data.ID }}&type=REMOTE"
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>Read</a>
{{ end }}
@@ -67,7 +67,6 @@
type="submit"
>Remove Cover</button>
</form>
</div>
<div class="relative">
<label for="delete-button">
@@ -323,12 +322,11 @@
</svg>
</label>
<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="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">
{{ .Data.SecondsPerPage }}
{{ .Data.SecondsPerPercent }}
</p>
</div>
<div class="text-xs flex">
@@ -352,23 +350,9 @@
<div>
<p class="text-gray-500">Progress</p>
<p class="font-medium text-lg">
{{ .Data.Page }} / {{ .Data.Pages }} ({{ .Data.Percentage }}%)
{{ .Data.Percentage }}%
</p>
</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 class="relative">

View File

@@ -7,6 +7,49 @@
{{end}}
{{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">
{{range $doc := .Data }}
<div class="w-full relative">
@@ -37,7 +80,7 @@
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">
{{ $doc.Page }} / {{ $doc.Pages }} ({{ $doc.Percentage }}%)
{{ $doc.Percentage }}%
</p>
</div>
</div>
@@ -104,6 +147,16 @@
{{end}}
</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">
<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">

View File

@@ -22,7 +22,7 @@
media="(prefers-color-scheme: dark)"
/>
<title>Book Manager - Error</title>
<title>AnthoLume - Error</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
@@ -30,7 +30,6 @@
<body
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"
>
<section>
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1
@@ -53,6 +52,5 @@
>
</div>
</div>
</section>
</body>
</html>

View File

@@ -22,10 +22,43 @@
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="stylesheet" href="./assets/style.css" />
<!-- Service Worker / Offline Cache Flush -->
<script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
}
/* No Scrollbar - IE, Edge, Firefox */
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* No Scrollbar - WebKit */
*::-webkit-scrollbar {
display: none;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<div class="flex flex-wrap w-full">
@@ -108,9 +141,8 @@
{{end}}
</button>
</form>
{{ if .RegistrationEnabled }}
<div class="pt-12 pb-12 text-center">
{{ if .Register }}
{{ if .RegistrationEnabled }} {{ if .Register }}
<p>
Trying to login?
<a href="./login" class="font-semibold underline">
@@ -124,9 +156,13 @@
Register here.
</a>
</p>
{{end}}
{{end}} {{ end }}
<p class="mt-4">
<a href="./local" class="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
{{ end }}
</div>
</div>
<div
@@ -134,19 +170,19 @@
>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book1.jpg"
src="/assets/images/book1.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book2.jpg"
src="/assets/images/book2.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book3.jpg"
src="/assets/images/book3.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book4.jpg"
src="/assets/images/book4.jpg"
/>
</div>
</div>

View File

@@ -1,22 +0,0 @@
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
<a href="../">Documents</a>
{{end}} {{define "content"}}
<div id="viewer" class="w-full h-full"></div>
<script src="../../assets/reader/platform.js"></script>
<script src="../../assets/reader/jszip.min.js"></script>
<script src="../../assets/reader/epub.min.js"></script>
<script src="../../assets/reader/no-sleep.js"></script>
<script src="../../assets/reader/index.js"></script>
<script>
let currentReader = new EBookReader("./file", {
id: "{{ .Data.ID }}",
words: {{ .Data.Words }},
pages: {{ .Data.Pages }},
progress: "{{ .Progress }}",
percentage: {{ .Data.Percentage }},
currentWord: {{ .Data.Percentage }} * ({{ .Data.Words }} / 100),
});
</script>
{{ end}}

View File

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