Compare commits
28 Commits
1b8b5060f1
...
0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| c3410b7833 | |||
| 1403bae036 | |||
| af41946a65 | |||
| 243ae2a001 | |||
| d94e79f39c | |||
| 856bc7e2e6 | |||
| 5cc1e2d71c | |||
| ffc5462326 | |||
| 3cbe4b1c0d | |||
| c213b3b09f | |||
| 7d45bb0253 | |||
| a8bcd0f588 | |||
| bc3e9cbaf0 | |||
| e6ad51ed70 | |||
| cce0ef2de1 | |||
| 71898c39e7 | |||
| 985b6e0851 | |||
| 425f469097 | |||
| 761163d666 | |||
| 67dedaa886 | |||
| 5f1de4ec67 | |||
| d27b9061bb | |||
| 0f271ac2fb | |||
| 20560ed246 | |||
| aacf5a7195 | |||
| 5880d3beb6 | |||
| 0917172d1c | |||
| f74c81dc9b |
@@ -24,7 +24,7 @@ steps:
|
|||||||
- name: publish_docker
|
- name: publish_docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: gitea.va.reichard.io/evan/bookmanager
|
repo: gitea.va.reichard.io/evan/antholume
|
||||||
registry: gitea.va.reichard.io
|
registry: gitea.va.reichard.io
|
||||||
tags:
|
tags:
|
||||||
- dev
|
- dev
|
||||||
|
|||||||
14
Dockerfile
@@ -10,17 +10,17 @@ WORKDIR /src
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create Package Directory
|
# Create Package Directory
|
||||||
RUN mkdir -p /opt/bookmanager
|
RUN mkdir -p /opt/antholume
|
||||||
|
|
||||||
# Compile
|
# Compile
|
||||||
RUN go build -o /opt/bookmanager/server; \
|
RUN go build -o /opt/antholume/server; \
|
||||||
cp -a ./templates /opt/bookmanager/templates; \
|
cp -a ./templates /opt/antholume/templates; \
|
||||||
cp -a ./assets /opt/bookmanager/assets;
|
cp -a ./assets /opt/antholume/assets;
|
||||||
|
|
||||||
# Create Image
|
# Create Image
|
||||||
FROM busybox:1.36
|
FROM busybox:1.36
|
||||||
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
||||||
COPY --from=build /opt/bookmanager /opt/bookmanager
|
COPY --from=build /opt/antholume /opt/antholume
|
||||||
WORKDIR /opt/bookmanager
|
WORKDIR /opt/antholume
|
||||||
EXPOSE 8585
|
EXPOSE 8585
|
||||||
ENTRYPOINT ["/opt/bookmanager/server", "serve"]
|
ENTRYPOINT ["/opt/antholume/server", "serve"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ FROM --platform=$BUILDPLATFORM golang:1.20 AS build
|
|||||||
|
|
||||||
# Create Package Directory
|
# Create Package Directory
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN mkdir -p /opt/bookmanager
|
RUN mkdir -p /opt/antholume
|
||||||
|
|
||||||
# Cache Dependencies & Compile
|
# Cache Dependencies & Compile
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
@@ -15,14 +15,14 @@ ARG TARGETARCH
|
|||||||
RUN --mount=target=. \
|
RUN --mount=target=. \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/go/pkg \
|
--mount=type=cache,target=/go/pkg \
|
||||||
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/bookmanager/server; \
|
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/antholume/server; \
|
||||||
cp -a ./templates /opt/bookmanager/templates; \
|
cp -a ./templates /opt/antholume/templates; \
|
||||||
cp -a ./assets /opt/bookmanager/assets;
|
cp -a ./assets /opt/antholume/assets;
|
||||||
|
|
||||||
# Create Image
|
# Create Image
|
||||||
FROM busybox:1.36
|
FROM busybox:1.36
|
||||||
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
||||||
COPY --from=build /opt/bookmanager /opt/bookmanager
|
COPY --from=build /opt/antholume /opt/antholume
|
||||||
WORKDIR /opt/bookmanager
|
WORKDIR /opt/antholume
|
||||||
EXPOSE 8585
|
EXPOSE 8585
|
||||||
ENTRYPOINT ["/opt/bookmanager/server", "serve"]
|
ENTRYPOINT ["/opt/antholume/server", "serve"]
|
||||||
|
|||||||
10
Makefile
@@ -11,25 +11,25 @@ build_local: build_tailwind
|
|||||||
env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64
|
env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64
|
||||||
|
|
||||||
docker_build_local: build_tailwind
|
docker_build_local: build_tailwind
|
||||||
docker build -t bookmanager:latest .
|
docker build -t antholume:latest .
|
||||||
|
|
||||||
docker_build_release_dev: build_tailwind
|
docker_build_release_dev: build_tailwind
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
-t gitea.va.reichard.io/evan/bookmanager:dev \
|
-t gitea.va.reichard.io/evan/antholume:dev \
|
||||||
-f Dockerfile-BuildKit \
|
-f Dockerfile-BuildKit \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
docker_build_release_latest: build_tailwind
|
docker_build_release_latest: build_tailwind
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
-t gitea.va.reichard.io/evan/bookmanager:latest \
|
-t gitea.va.reichard.io/evan/antholume:latest \
|
||||||
-t gitea.va.reichard.io/evan/bookmanager:`git describe --tags` \
|
-t gitea.va.reichard.io/evan/antholume:`git describe --tags` \
|
||||||
-f Dockerfile-BuildKit \
|
-f Dockerfile-BuildKit \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
build_tailwind:
|
build_tailwind:
|
||||||
tailwind build -o ./assets/style.css
|
tailwind build -o ./assets/style.css --minify
|
||||||
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
122
README.md
@@ -1,100 +1,104 @@
|
|||||||
# Book Manager
|
<p><img align="center" src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/banner.png"></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png" width="19%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="19%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png" width="19%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="19%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png" width="19%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="19%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png" width="19%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="19%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">Screenshots</p>
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/web/README.md">Web App</a> - <a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/pwa/README.md">PWA</a>
|
<strong><a href="https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/screenshots">Screenshots</a></strong> •
|
||||||
|
<strong><a href="https://antholume-demo.cloud.reichard.io/">Demo Server</a></strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p align="center"><strong>user:</strong> demo • <strong>pass:</strong> demo</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://drone.va.reichard.io/evan/BookManager" target="_blank">
|
<a href="https://drone.va.reichard.io/evan/AnthoLume" target="_blank">
|
||||||
<img src="https://drone.va.reichard.io/api/badges/evan/BookManager/status.svg">
|
<img src="https://drone.va.reichard.io/api/badges/evan/AnthoLume/status.svg">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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)
|
- OPDS API Endpoint
|
||||||
- [KOReader](https://github.com/koreader/koreader) Plugin (See `client` subfolder)
|
- Local / Offline Reader (via ServiceWorker)
|
||||||
- [KOReader KOSync](https://github.com/koreader/koreader-sync-server) compatible API
|
- Metadata Scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
|
||||||
- OPDS API endpoint that provides access to the uploaded documents
|
- 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
|
## Server
|
||||||
- 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
|
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`
|
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`
|
||||||
|
|
||||||
## OPDS API
|
### OPDS API
|
||||||
|
|
||||||
The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds`
|
The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds`
|
||||||
|
|
||||||
## Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Make Data Directory
|
# Make Data Directory
|
||||||
mkdir -p bookmanager_data
|
mkdir -p antholume_data
|
||||||
|
|
||||||
# Run Server
|
# Run Server
|
||||||
docker run \
|
docker run \
|
||||||
-p 8585:8585 \
|
-p 8585:8585 \
|
||||||
-e REGISTRATION_ENABLED=true \
|
-e REGISTRATION_ENABLED=true \
|
||||||
-v ./bookmanager_data:/config \
|
-v ./antholume_data:/config \
|
||||||
-v ./bookmanager_data:/data \
|
-v ./antholume_data:/data \
|
||||||
gitea.va.reichard.io/evan/bookmanager:latest
|
gitea.va.reichard.io/evan/antholume:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
|
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
|
||||||
|
|
||||||
## Configuration
|
### Configuration
|
||||||
|
|
||||||
| Environment Variable | Default Value | Description |
|
| Environment Variable | Default Value | Description |
|
||||||
| -------------------- | ------------- | -------------------------------------------------------------------- |
|
| -------------------- | ------------- | ------------------------------------------------------------------- |
|
||||||
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
|
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
|
||||||
| DATABASE_NAME | bbank | The database name, or in SQLite's case, the filename |
|
| DATABASE_NAME | antholume | The database name, or in SQLite's case, the filename |
|
||||||
| DATABASE_PASSWORD | <EMPTY> | Currently not used. Placeholder for potential alternative DB support |
|
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
||||||
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
||||||
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
| LISTEN_PORT | 8585 | Port the server listens at |
|
||||||
| LISTEN_PORT | 8585 | Port the server listens at |
|
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
||||||
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
||||||
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
||||||
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
|
||||||
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
|
- _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)
|
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
|
||||||
|
|
||||||
### Notes
|
### 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:
|
- The native KOSync plugin sends an MD5 hash of the password. Due to that:
|
||||||
- We store an Argon2 hash _and_ per-password salt of the MD5 hashed original password
|
- We store an Argon2 hash _and_ per-password salt of the MD5 hashed original password
|
||||||
|
|
||||||
# Client (KOReader Plugin)
|
## Client (KOReader Plugin)
|
||||||
|
|
||||||
See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/BookManager/src/branch/master/client/)
|
See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/client/)
|
||||||
|
|
||||||
# Development
|
## Development
|
||||||
|
|
||||||
SQLC Generation (v1.21.0):
|
SQLC Generation (v1.21.0):
|
||||||
|
|
||||||
@@ -119,10 +123,10 @@ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
|||||||
Run Development:
|
Run Development:
|
||||||
|
|
||||||
```bash
|
```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:
|
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)
|
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
|
||||||
make docker_build_release_latest
|
make docker_build_release_latest
|
||||||
make docker_build_release_dev
|
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
|
## Notes
|
||||||
|
|||||||
89
api/api.go
@@ -78,40 +78,57 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
"NiceSeconds": niceSeconds,
|
"NiceSeconds": niceSeconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Templates
|
||||||
render.AddFromFiles("error", "templates/error.html")
|
render.AddFromFiles("error", "templates/error.html")
|
||||||
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
|
||||||
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.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("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("search", helperFuncs, "templates/base.html", "templates/search.html")
|
||||||
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.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
|
api.Router.HTMLRender = render
|
||||||
|
|
||||||
|
// Static Assets (Required @ Root)
|
||||||
api.Router.GET("/manifest.json", api.webManifest)
|
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("/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("/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("/login", api.authFormLogin)
|
||||||
api.Router.POST("/register", api.authFormRegister)
|
api.Router.POST("/register", api.authFormRegister)
|
||||||
|
|
||||||
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
// Demo Mode Enabled Configuration
|
||||||
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
|
if api.Config.DemoMode {
|
||||||
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
|
api.Router.POST("/documents", api.authWebAppMiddleware, api.demoModeAppError)
|
||||||
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.demoModeAppError)
|
||||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.demoModeAppError)
|
||||||
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
|
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.demoModeAppError)
|
||||||
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
api.Router.POST("/settings", api.authWebAppMiddleware, api.demoModeAppError)
|
||||||
api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader)
|
} else {
|
||||||
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
|
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
|
||||||
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/edit", api.authWebAppMiddleware, api.editDocument)
|
||||||
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
|
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
|
||||||
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 {
|
if api.Config.SearchEnabled {
|
||||||
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
|
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
|
||||||
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
|
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
|
||||||
@@ -121,28 +138,36 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
||||||
koGroup := apiGroup.Group("/ko")
|
koGroup := apiGroup.Group("/ko")
|
||||||
|
|
||||||
koGroup.POST("/users/create", api.createUser)
|
// KO Sync Routes (WebApp Uses - Progress & Activity)
|
||||||
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
|
|
||||||
|
|
||||||
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
|
|
||||||
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.getProgress)
|
|
||||||
|
|
||||||
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.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("/activity", api.authKOMiddleware, api.addActivities)
|
||||||
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
|
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
|
||||||
opdsGroup := apiGroup.Group("/opds")
|
opdsGroup := apiGroup.Group("/opds")
|
||||||
|
|
||||||
|
// OPDS Routes
|
||||||
|
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments)
|
||||||
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/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) {
|
func generateToken(n int) ([]byte, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -25,6 +27,7 @@ import (
|
|||||||
type queryParams struct {
|
type queryParams struct {
|
||||||
Page *int64 `form:"page"`
|
Page *int64 `form:"page"`
|
||||||
Limit *int64 `form:"limit"`
|
Limit *int64 `form:"limit"`
|
||||||
|
Search *string `form:"search"`
|
||||||
Document *string `form:"document"`
|
Document *string `form:"document"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,18 @@ func (api *API) webManifest(c *gin.Context) {
|
|||||||
c.File("./assets/manifest.json")
|
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) {
|
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
|
||||||
// Merge Optional Template Data
|
// Merge Optional Template Data
|
||||||
var templateVarsBase = gin.H{}
|
var templateVarsBase = gin.H{}
|
||||||
@@ -98,8 +113,15 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
qParams := bindQueryParams(c)
|
qParams := bindQueryParams(c)
|
||||||
|
|
||||||
if routeName == "documents" {
|
if routeName == "documents" {
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Query: query,
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
@@ -109,10 +131,30 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
length, err := api.DB.Queries.GetDocumentsSize(api.DB.Ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[createAppResourcesRoute] GetDocumentsSize DB Error:", err)
|
||||||
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err = api.getDocumentsWordCount(documents); err != nil {
|
if err = api.getDocumentsWordCount(documents); err != nil {
|
||||||
log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err)
|
log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
||||||
|
nextPage := *qParams.Page + 1
|
||||||
|
previousPage := *qParams.Page - 1
|
||||||
|
|
||||||
|
if nextPage <= totalPages {
|
||||||
|
templateVars["NextPage"] = nextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
if previousPage >= 0 {
|
||||||
|
templateVars["PreviousPage"] = previousPage
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["PageLimit"] = *qParams.Limit
|
||||||
templateVars["Data"] = documents
|
templateVars["Data"] = documents
|
||||||
} else if routeName == "document" {
|
} else if routeName == "document" {
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
@@ -133,7 +175,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
}
|
}
|
||||||
|
|
||||||
templateVars["Data"] = document
|
templateVars["Data"] = document
|
||||||
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
} else if routeName == "activity" {
|
} else if routeName == "activity" {
|
||||||
activityFilter := database.GetActivityParams{
|
activityFilter := database.GetActivityParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -164,13 +206,13 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
log.Info("GetDatabaseInfo Performance: ", time.Since(start))
|
log.Info("GetDatabaseInfo Performance: ", time.Since(start))
|
||||||
|
|
||||||
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID)
|
streaks, _ := api.DB.Queries.GetUserStreaks(api.DB.Ctx, userID)
|
||||||
wpn_leaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
|
wpm_leaderboard, _ := api.DB.Queries.GetWPMLeaderboard(api.DB.Ctx)
|
||||||
|
|
||||||
templateVars["Data"] = gin.H{
|
templateVars["Data"] = gin.H{
|
||||||
"Streaks": streaks,
|
"Streaks": streaks,
|
||||||
"GraphData": read_graph_data,
|
"GraphData": read_graph_data,
|
||||||
"DatabaseInfo": database_info,
|
"DatabaseInfo": database_info,
|
||||||
"WPMLeaderboard": wpn_leaderboard,
|
"WPMLeaderboard": wpm_leaderboard,
|
||||||
}
|
}
|
||||||
} else if routeName == "settings" {
|
} else if routeName == "settings" {
|
||||||
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
|
user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
|
||||||
@@ -245,7 +287,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
// Handle Identified Document
|
// Handle Identified Document
|
||||||
if document.Coverfile != nil {
|
if document.Coverfile != nil {
|
||||||
if *document.Coverfile == "UNKNOWN" {
|
if *document.Coverfile == "UNKNOWN" {
|
||||||
c.File("./assets/no-cover.jpg")
|
c.File("./assets/images/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +298,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
_, err = os.Stat(safePath)
|
_, err = os.Stat(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
|
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
|
||||||
c.File("./assets/no-cover.jpg")
|
c.File("./assets/images/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +351,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
|
|
||||||
// Return Unknown Cover
|
// Return Unknown Cover
|
||||||
if coverFile == "UNKNOWN" {
|
if coverFile == "UNKNOWN" {
|
||||||
c.File("./assets/no-cover.jpg")
|
c.File("./assets/images/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,12 +359,12 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
c.File(coverFilePath)
|
c.File(coverFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) documentReader(c *gin.Context) {
|
func (api *API) getDocumentProgress(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
rUser, _ := c.Get("AuthorizedUser")
|
||||||
|
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
log.Error("[documentReader] Invalid URI Bind")
|
log.Error("[getDocumentProgress] Invalid URI Bind")
|
||||||
errorPage(c, http.StatusNotFound, "Invalid document.")
|
errorPage(c, http.StatusNotFound, "Invalid document.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -333,7 +375,7 @@ func (api *API) documentReader(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Error("[documentReader] UpsertDocument DB Error:", err)
|
log.Error("[getDocumentProgress] UpsertDocument DB Error:", err)
|
||||||
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -343,15 +385,18 @@ func (api *API) documentReader(c *gin.Context) {
|
|||||||
DocumentID: rDoc.DocumentID,
|
DocumentID: rDoc.DocumentID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "reader", gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"SearchEnabled": api.Config.SearchEnabled,
|
"id": document.ID,
|
||||||
"Progress": progress.Progress,
|
"title": document.Title,
|
||||||
"Data": document,
|
"author": document.Author,
|
||||||
|
"words": document.Words,
|
||||||
|
"progress": progress.Progress,
|
||||||
|
"percentage": document.Percentage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +443,7 @@ func (api *API) uploadNewDocument(c *gin.Context) {
|
|||||||
errorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
|
errorPage(c, http.StatusInternalServerError, "Unable to create temp file.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
defer tempFile.Close()
|
defer tempFile.Close()
|
||||||
|
|
||||||
// Save Temp
|
// Save Temp
|
||||||
@@ -439,6 +485,14 @@ func (api *API) uploadNewDocument(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Word Count
|
||||||
|
wordCount, err := metadata.GetWordCount(tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[uploadNewDocument] Word Count Failure:", err)
|
||||||
|
errorPage(c, http.StatusInternalServerError, "Unable to calculate word count.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Derive Filename
|
// Derive Filename
|
||||||
var fileName string
|
var fileName string
|
||||||
if *metadataInfo.Author != "" {
|
if *metadataInfo.Author != "" {
|
||||||
@@ -459,12 +513,19 @@ func (api *API) uploadNewDocument(c *gin.Context) {
|
|||||||
// Derive & Sanitize File Name
|
// Derive & Sanitize File Name
|
||||||
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
|
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)
|
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
|
// Copy File
|
||||||
if err := os.Rename(tempFile.Name(), safePath); err != nil {
|
if _, err = io.Copy(destFile, tempFile); err != nil {
|
||||||
log.Error("[uploadNewDocument] Move Temp File Error:", err)
|
log.Error("[uploadNewDocument] Copy Temp File Error:", err)
|
||||||
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -475,6 +536,7 @@ func (api *API) uploadNewDocument(c *gin.Context) {
|
|||||||
Title: metadataInfo.Title,
|
Title: metadataInfo.Title,
|
||||||
Author: metadataInfo.Author,
|
Author: metadataInfo.Author,
|
||||||
Description: metadataInfo.Description,
|
Description: metadataInfo.Description,
|
||||||
|
Words: &wordCount,
|
||||||
Md5: fileHash,
|
Md5: fileHash,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -687,7 +749,7 @@ func (api *API) identifyDocument(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templateVars["Data"] = document
|
templateVars["Data"] = document
|
||||||
templateVars["TotalTimeLeftSeconds"] = (document.Pages - document.Page) * document.SecondsPerPage
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "document", templateVars)
|
c.HTML(http.StatusOK, "document", templateVars)
|
||||||
}
|
}
|
||||||
@@ -755,12 +817,29 @@ func (api *API) saveNewDocument(c *gin.Context) {
|
|||||||
// Derive & Sanitize File Name
|
// Derive & Sanitize File Name
|
||||||
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
|
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, partialMD5, fileExtension))
|
||||||
|
|
||||||
// Generate Storage Path
|
// Open Source File
|
||||||
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
|
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
|
// Generate Storage Path & Open File
|
||||||
if err := os.Rename(tempFilePath, safePath); err != nil {
|
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
|
||||||
log.Warn("[saveNewDocument] Move Temp File Error: ", err)
|
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.")
|
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -773,6 +852,14 @@ func (api *API) saveNewDocument(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Word Count
|
||||||
|
wordCount, err := metadata.GetWordCount(safePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[saveNewDocument] Word Count Failure:", err)
|
||||||
|
errorPage(c, http.StatusInternalServerError, "Unable to calculate word count.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert Document
|
// Upsert Document
|
||||||
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: partialMD5,
|
ID: partialMD5,
|
||||||
@@ -780,6 +867,7 @@ func (api *API) saveNewDocument(c *gin.Context) {
|
|||||||
Author: rDocAdd.Author,
|
Author: rDocAdd.Author,
|
||||||
Md5: fileHash,
|
Md5: fileHash,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
|
Words: &wordCount,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
|
log.Error("[saveNewDocument] UpsertDocument DB Error:", err)
|
||||||
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
||||||
@@ -916,7 +1004,7 @@ func bindQueryParams(c *gin.Context) queryParams {
|
|||||||
c.BindQuery(&qParams)
|
c.BindQuery(&qParams)
|
||||||
|
|
||||||
if qParams.Limit == nil {
|
if qParams.Limit == nil {
|
||||||
var defaultValue int64 = 50
|
var defaultValue int64 = 9
|
||||||
qParams.Limit = &defaultValue
|
qParams.Limit = &defaultValue
|
||||||
} else if *qParams.Limit < 0 {
|
} else if *qParams.Limit < 0 {
|
||||||
var zeroValue int64 = 0
|
var zeroValue int64 = 0
|
||||||
@@ -924,7 +1012,7 @@ func bindQueryParams(c *gin.Context) queryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if qParams.Page == nil || *qParams.Page < 1 {
|
if qParams.Page == nil || *qParams.Page < 1 {
|
||||||
var oneValue int64 = 0
|
var oneValue int64 = 1
|
||||||
qParams.Page = &oneValue
|
qParams.Page = &oneValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ func (api *API) authLogout(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/login")
|
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) {
|
func getSession(session sessions.Session) (user string, ok bool) {
|
||||||
// Check Session
|
// Check Session
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/bbank/database"
|
||||||
|
"reichard.io/bbank/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type activityItem struct {
|
type activityItem struct {
|
||||||
@@ -168,6 +169,13 @@ func (api *API) setProgress(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Statistic
|
||||||
|
log.Info("[setProgress] UpdateDocumentUserStatistic Running...")
|
||||||
|
if err := api.DB.UpdateDocumentUserStatistic(rPosition.DocumentID, rUser.(string)); err != nil {
|
||||||
|
log.Error("[setProgress] UpdateDocumentUserStatistic Error:", err)
|
||||||
|
}
|
||||||
|
log.Info("[setProgress] UpdateDocumentUserStatistic Complete")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"document": progress.DocumentID,
|
"document": progress.DocumentID,
|
||||||
"timestamp": progress.CreatedAt,
|
"timestamp": progress.CreatedAt,
|
||||||
@@ -263,13 +271,13 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
// Add All Activity
|
// Add All Activity
|
||||||
for _, item := range rActivity.Activity {
|
for _, item := range rActivity.Activity {
|
||||||
if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
|
if _, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DocumentID: item.DocumentID,
|
DocumentID: item.DocumentID,
|
||||||
DeviceID: rActivity.DeviceID,
|
DeviceID: rActivity.DeviceID,
|
||||||
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
|
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
|
||||||
Duration: int64(item.Duration),
|
Duration: int64(item.Duration),
|
||||||
Page: int64(item.Page),
|
StartPercentage: float64(item.Page) / float64(item.Pages),
|
||||||
Pages: int64(item.Pages),
|
EndPercentage: float64(item.Page+1) / float64(item.Pages),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[addActivities] AddActivity DB Error:", err)
|
log.Error("[addActivities] AddActivity DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
||||||
@@ -284,13 +292,14 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Temp Tables
|
// Update Statistic
|
||||||
go func() {
|
for _, doc := range allDocuments {
|
||||||
log.Info("[addActivities] Caching Temp Tables")
|
log.Info("[addActivities] UpdateDocumentUserStatistic Running...")
|
||||||
if err := api.DB.CacheTempTables(); err != nil {
|
if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
|
||||||
log.Warn("[addActivities] CacheTempTables Failure: ", err)
|
log.Error("[addActivities] UpdateDocumentUserStatistic Error:", err)
|
||||||
}
|
}
|
||||||
}()
|
log.Info("[addActivities] UpdateDocumentUserStatistic Complete")
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"added": len(rActivity.Activity),
|
"added": len(rActivity.Activity),
|
||||||
@@ -367,7 +376,7 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
for _, doc := range rNewDocs.Documents {
|
for _, doc := range rNewDocs.Documents {
|
||||||
doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
_, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: doc.ID,
|
ID: doc.ID,
|
||||||
Title: api.sanitizeInput(doc.Title),
|
Title: api.sanitizeInput(doc.Title),
|
||||||
Author: api.sanitizeInput(doc.Author),
|
Author: api.sanitizeInput(doc.Author),
|
||||||
@@ -381,16 +390,6 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
|
||||||
ID: doc.ID,
|
|
||||||
Synced: true,
|
|
||||||
}); err != nil {
|
|
||||||
log.Error("[addDocuments] UpdateDocumentSync DB Error:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
@@ -416,7 +415,7 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
_, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rCheckDocs.DeviceID,
|
ID: rCheckDocs.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DeviceName: rCheckDocs.Device,
|
DeviceName: rCheckDocs.Device,
|
||||||
@@ -431,22 +430,20 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
missingDocs := []database.Document{}
|
missingDocs := []database.Document{}
|
||||||
deletedDocIDs := []string{}
|
deletedDocIDs := []string{}
|
||||||
|
|
||||||
if device.Sync == true {
|
// Get Missing Documents
|
||||||
// Get Missing Documents
|
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
|
||||||
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
|
if err != nil {
|
||||||
if err != nil {
|
log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err)
|
||||||
log.Error("[checkDocumentsSync] GetMissingDocuments DB Error", err)
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Get Deleted Documents
|
// Get Deleted Documents
|
||||||
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
|
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
|
log.Error("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
return
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Wanted Documents
|
// Get Wanted Documents
|
||||||
@@ -576,27 +573,26 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Word Count
|
||||||
|
wordCount, err := metadata.GetWordCount(safePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[uploadExistingDocument] Word Count Failure:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert Document
|
// Upsert Document
|
||||||
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
||||||
ID: document.ID,
|
ID: document.ID,
|
||||||
Md5: fileHash,
|
Md5: fileHash,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
|
Words: &wordCount,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
|
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Document Sync Attribute
|
|
||||||
if _, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
|
|
||||||
ID: document.ID,
|
|
||||||
Synced: true,
|
|
||||||
}); err != nil {
|
|
||||||
log.Error("[uploadExistingDocument] UpdateDocumentSync DB Error:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
})
|
})
|
||||||
@@ -636,7 +632,7 @@ func (api *API) downloadDocument(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force Download (Security)
|
// 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)
|
c.File(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,15 +55,30 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
splitFilepath := strings.Split(*doc.Filepath, ".")
|
splitFilepath := strings.Split(*doc.Filepath, ".")
|
||||||
fileType := splitFilepath[len(splitFilepath)-1]
|
fileType := splitFilepath[len(splitFilepath)-1]
|
||||||
|
|
||||||
|
title := "N/A"
|
||||||
|
if doc.Title != nil {
|
||||||
|
title = *doc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
author := "N/A"
|
||||||
|
if doc.Author != nil {
|
||||||
|
author = *doc.Author
|
||||||
|
}
|
||||||
|
|
||||||
|
description := "N/A"
|
||||||
|
if doc.Description != nil {
|
||||||
|
description = *doc.Description
|
||||||
|
}
|
||||||
|
|
||||||
item := opds.Entry{
|
item := opds.Entry{
|
||||||
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage.(float64)), *doc.Title),
|
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage), title),
|
||||||
Author: []opds.Author{
|
Author: []opds.Author{
|
||||||
{
|
{
|
||||||
Name: *doc.Author,
|
Name: author,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Content: &opds.Content{
|
Content: &opds.Content{
|
||||||
Content: *doc.Description,
|
Content: description,
|
||||||
ContentType: "text",
|
ContentType: "text",
|
||||||
},
|
},
|
||||||
Links: []opds.Link{
|
Links: []opds.Link{
|
||||||
@@ -91,7 +106,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
// TODO
|
// TODO
|
||||||
// Links: []opds.Link{
|
// Links: []opds.Link{
|
||||||
// {
|
// {
|
||||||
// Title: "Search Book Manager",
|
// Title: "Search AnthoLume",
|
||||||
// Rel: "search",
|
// Rel: "search",
|
||||||
// TypeLink: "application/opensearchdescription+xml",
|
// TypeLink: "application/opensearchdescription+xml",
|
||||||
// Href: "search.xml",
|
// Href: "search.xml",
|
||||||
@@ -105,8 +120,8 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
|
|
||||||
func (api *API) opdsSearchDescription(c *gin.Context) {
|
func (api *API) opdsSearchDescription(c *gin.Context) {
|
||||||
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<ShortName>Search Book Manager</ShortName>
|
<ShortName>Search AnthoLume</ShortName>
|
||||||
<Description>Search Book Manager</Description>
|
<Description>Search AnthoLume</Description>
|
||||||
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/>
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/>
|
||||||
</OpenSearchDescription>`
|
</OpenSearchDescription>`
|
||||||
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
||||||
|
|||||||
122
assets/common.js
Normal 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
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 511 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 699 KiB After Width: | Height: | Size: 699 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
78
assets/index.js
Normal 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
@@ -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
1
assets/lib/platform.min.js
vendored
Normal file
282
assets/local/index.html
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
{
|
{
|
||||||
"short_name": "Book Manager",
|
"name": "AnthoLume",
|
||||||
"name": "Book Manager",
|
"short_name": "AnthoLume",
|
||||||
|
"lang": "en-US",
|
||||||
"theme_color": "#1F2937",
|
"theme_color": "#1F2937",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"start_url": "/"
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"purpose": "any",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"src": "/assets/icons/icon512.png",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,27 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#D2B48C" />
|
<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="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<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>
|
<style>
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* -------- PWA Styling -------- */
|
||||||
|
/* ----------------------------- */
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
@@ -66,7 +81,7 @@
|
|||||||
>
|
>
|
||||||
<div class="w-full h-32 flex items-center justify-around relative">
|
<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">
|
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||||
<a href="../{{ .Data.ID }}">
|
<a href="#">
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -101,8 +116,11 @@
|
|||||||
|
|
||||||
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
||||||
<div class="h-full my-auto relative">
|
<div class="h-full my-auto relative">
|
||||||
<a href="../{{ .Data.ID }}">
|
<a href="#">
|
||||||
<img class="rounded object-cover h-full" src="./cover" />
|
<img
|
||||||
|
class="rounded object-cover h-full"
|
||||||
|
src="/assets/images/no-cover.jpg"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
||||||
@@ -113,7 +131,7 @@
|
|||||||
<p
|
<p
|
||||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
>
|
>
|
||||||
{{ or .Data.Title "N/A" }}
|
"N/A"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +141,7 @@
|
|||||||
<p
|
<p
|
||||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
>
|
>
|
||||||
{{ or .Data.Author "N/A" }}
|
"N/A"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{block "content" .}}{{end}}
|
<div id="viewer" class="w-full h-full"></div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,9 +1,78 @@
|
|||||||
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
||||||
const THEME_FILE = "/assets/reader/readerThemes.css";
|
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 {
|
class EBookReader {
|
||||||
bookState = {
|
bookState = {
|
||||||
currentWord: 0,
|
|
||||||
pages: 0,
|
pages: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
progress: "",
|
progress: "",
|
||||||
@@ -45,27 +114,11 @@ class EBookReader {
|
|||||||
* Load progress and generate locations
|
* Load progress and generate locations
|
||||||
**/
|
**/
|
||||||
async setupReader() {
|
async setupReader() {
|
||||||
// Get Word Count (If Needed)
|
// Get Word Count
|
||||||
if (this.bookState.words == 0)
|
this.bookState.words = await this.countWords();
|
||||||
this.bookState.words = await this.countWords();
|
|
||||||
|
|
||||||
// Load Progress
|
// Load Progress
|
||||||
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
||||||
this.bookState.currentWord = cfi
|
|
||||||
? this.bookState.percentage * (this.bookState.words / 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
let getStats = function () {
|
|
||||||
// Start Timer
|
|
||||||
this.bookState.pageStart = Date.now();
|
|
||||||
|
|
||||||
// Get Stats
|
|
||||||
let stats = this.getBookStats();
|
|
||||||
this.updateBookStats(stats);
|
|
||||||
}.bind(this);
|
|
||||||
|
|
||||||
// Register Content Hook
|
|
||||||
this.rendition.hooks.content.register(getStats);
|
|
||||||
|
|
||||||
// Update Position
|
// Update Position
|
||||||
await this.setPosition(cfi);
|
await this.setPosition(cfi);
|
||||||
@@ -73,8 +126,14 @@ class EBookReader {
|
|||||||
// Highlight Element - DOM Has Element
|
// Highlight Element - DOM Has Element
|
||||||
let { element } = await this.getCFIFromXPath(this.bookState.progress);
|
let { element } = await this.getCFIFromXPath(this.bookState.progress);
|
||||||
|
|
||||||
|
// Set Progress Element & Highlight
|
||||||
this.bookState.progressElement = element;
|
this.bookState.progressElement = element;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
|
|
||||||
|
// Update Stats & Page Start
|
||||||
|
let stats = await this.getBookStats();
|
||||||
|
this.updateBookStatElements(stats);
|
||||||
|
this.bookState.pageStart = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
initDevice() {
|
initDevice() {
|
||||||
@@ -381,25 +440,41 @@ class EBookReader {
|
|||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
// --------------- Bottom & Top Bar --------------- //
|
// --------------- Bottom & Top Bar --------------- //
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
|
renderDoc.addEventListener(
|
||||||
renderDoc.addEventListener("click", function (event) {
|
"click",
|
||||||
let barPixels = emSize * 5;
|
function (event) {
|
||||||
|
// Get Window Dimensions
|
||||||
|
let windowWidth = window.innerWidth;
|
||||||
|
let windowHeight = window.innerHeight;
|
||||||
|
|
||||||
let top = barPixels;
|
// Calculate X & Y Hot Zones
|
||||||
let bottom = window.innerHeight - top;
|
let barPixels = windowHeight * 0.2;
|
||||||
|
let pagePixels = windowWidth * 0.2;
|
||||||
|
|
||||||
let left = barPixels / 2;
|
// Calculate Top & Bottom Thresholds
|
||||||
let right = window.innerWidth - left;
|
let top = barPixels;
|
||||||
|
let bottom = window.innerHeight - top;
|
||||||
|
|
||||||
if (event.clientY < top) handleSwipeDown();
|
// Calculate Left & Right Thresholds
|
||||||
else if (event.clientY > bottom) handleSwipeUp();
|
let left = pagePixels;
|
||||||
else if (event.screenX < left) prevPage();
|
let right = windowWidth - left;
|
||||||
else if (event.screenX > right) nextPage();
|
|
||||||
else {
|
// Calculate Relative Coords
|
||||||
bottomBar.classList.remove("bottom-0");
|
let leftOffset = this.views().container.scrollLeft;
|
||||||
topBar.classList.remove("top-0");
|
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(
|
renderDoc.addEventListener(
|
||||||
"wheel",
|
"wheel",
|
||||||
@@ -506,7 +581,6 @@ class EBookReader {
|
|||||||
"click",
|
"click",
|
||||||
function (event) {
|
function (event) {
|
||||||
let colorScheme = event.target.innerText;
|
let colorScheme = event.target.innerText;
|
||||||
console.log(colorScheme);
|
|
||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
);
|
);
|
||||||
@@ -565,26 +639,8 @@ class EBookReader {
|
|||||||
* Progresses to the next page & monitors reading activity
|
* Progresses to the next page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async nextPage() {
|
async nextPage() {
|
||||||
// Flush Activity
|
// Create Activity
|
||||||
this.flushActivity();
|
await this.createActivity();
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render Next Page
|
// Render Next Page
|
||||||
await this.rendition.next();
|
await this.rendition.next();
|
||||||
@@ -593,45 +649,29 @@ class EBookReader {
|
|||||||
this.bookState.pageStart = Date.now();
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
// Update Stats
|
// Update Stats
|
||||||
let stats = this.getBookStats();
|
let stats = await this.getBookStats();
|
||||||
this.updateBookStats(stats);
|
this.updateBookStatElements(stats);
|
||||||
|
|
||||||
// Update & Flush Progress
|
// Create Progress
|
||||||
let currentCFI = await this.rendition.currentLocation();
|
this.createProgress();
|
||||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
|
||||||
this.bookState.progress = xpath;
|
|
||||||
this.bookState.progressElement = element;
|
|
||||||
|
|
||||||
this.flushProgress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progresses to the previous page & monitors reading activity
|
* Progresses to the previous page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async prevPage() {
|
async prevPage() {
|
||||||
// Flush Activity
|
|
||||||
this.flushActivity();
|
|
||||||
|
|
||||||
// Render Previous Page
|
// Render Previous Page
|
||||||
await this.rendition.prev();
|
await this.rendition.prev();
|
||||||
|
|
||||||
// Update Current Word
|
|
||||||
let pageWords = await this.getVisibleWordCount();
|
|
||||||
this.bookState.currentWord -= pageWords;
|
|
||||||
|
|
||||||
// Reset Read Timer
|
// Reset Read Timer
|
||||||
this.bookState.pageStart = Date.now();
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
// Update Stats
|
// Update Stats
|
||||||
let stats = this.getBookStats();
|
let stats = await this.getBookStats();
|
||||||
this.updateBookStats(stats);
|
this.updateBookStatElements(stats);
|
||||||
|
|
||||||
// Update & Flush Progress
|
// Create Progress
|
||||||
let currentCFI = await this.rendition.currentLocation();
|
this.createProgress();
|
||||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
|
||||||
this.bookState.progress = xpath;
|
|
||||||
this.bookState.progressElement = element;
|
|
||||||
this.flushProgress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,117 +692,157 @@ class EBookReader {
|
|||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async createActivity() {
|
||||||
* Normalize and flush activity
|
// WPM MAX & MIN
|
||||||
**/
|
|
||||||
async flushActivity() {
|
|
||||||
// Process & Reset Activity
|
|
||||||
let allActivity = this.bookState.readActivity;
|
|
||||||
this.bookState.readActivity = [];
|
|
||||||
|
|
||||||
const WPM_MAX = 2000;
|
const WPM_MAX = 2000;
|
||||||
const WPM_MIN = 100;
|
const WPM_MIN = 100;
|
||||||
|
|
||||||
let normalizedActivity = allActivity
|
// Get Elapsed Time
|
||||||
// Exclude Fast WPM
|
let pageStart = this.bookState.pageStart;
|
||||||
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
|
let elapsedTime = Date.now() - pageStart;
|
||||||
.map((item) => {
|
|
||||||
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
|
|
||||||
|
|
||||||
// Min WPM
|
// Update Current Word
|
||||||
if (pageWPM < WPM_MIN) {
|
let pageWords = await this.getVisibleWordCount();
|
||||||
// TODO - Exclude Event?
|
let currentWord = await this.getBookWordPosition();
|
||||||
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
|
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(
|
// Exclude Ridiculous WPM
|
||||||
(item.startingWord * item.pages) / this.bookState.words
|
if (pageWPM >= WPM_MAX)
|
||||||
);
|
return console.log(
|
||||||
|
"[createActivity] Page WPM Exceeds Max (2000):",
|
||||||
|
pageWPM
|
||||||
|
);
|
||||||
|
|
||||||
// Estimate Accuracy Loss (Debugging)
|
// Ensure WPM Minimum
|
||||||
// let wordLoss = Math.abs(
|
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
|
||||||
// item.pageWords - this.bookState.words / item.pages
|
|
||||||
// );
|
|
||||||
// console.log("Word Loss:", wordLoss);
|
|
||||||
|
|
||||||
return {
|
let totalPages = Math.round(1 / percentRead);
|
||||||
document: this.bookState.id,
|
|
||||||
duration: Math.round(item.elapsedTime / 1000),
|
|
||||||
start_time: Math.round(item.startTime / 1000),
|
|
||||||
page: item.page,
|
|
||||||
pages: item.pages,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
// Create Activity Event
|
||||||
let activityEvent = {
|
let activityEvent = {
|
||||||
device_id: this.readerSettings.deviceID,
|
device_id: this.readerSettings.deviceID,
|
||||||
device: this.readerSettings.deviceName,
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flush Activity
|
// Local Files
|
||||||
fetch("/api/ko/activity", {
|
if (this.bookState.type == "LOCAL") return;
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(activityEvent),
|
// Remote Flush -> Offline Cache IDB
|
||||||
})
|
this.flushActivity(activityEvent).catch(async (e) => {
|
||||||
.then(async (r) =>
|
console.error("[createActivity] Activity Flush Failed:", {
|
||||||
console.log("Flushed Activity:", {
|
error: e,
|
||||||
response: r,
|
data: activityEvent,
|
||||||
json: await r.json(),
|
});
|
||||||
data: activityEvent,
|
|
||||||
})
|
// Get & Update Activity
|
||||||
)
|
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
|
||||||
.catch((e) =>
|
existingActivity.device_id = activityEvent.device_id;
|
||||||
console.error("Activity Flush Failed:", {
|
existingActivity.device = activityEvent.device;
|
||||||
error: e,
|
existingActivity.activity.push(...activityEvent.activity);
|
||||||
data: activityEvent,
|
|
||||||
})
|
// Update IDB
|
||||||
);
|
await IDB.set("ACTIVITY", existingActivity);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush progress to the API. Called when the page changes.
|
* Normalize and flush activity
|
||||||
**/
|
**/
|
||||||
async flushProgress() {
|
flushActivity(activityEvent) {
|
||||||
console.log("Flushing Progress...");
|
console.log("[flushActivity] Flushing Activity...");
|
||||||
|
|
||||||
// Create Progress Event
|
// 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Event
|
||||||
let progressEvent = {
|
let progressEvent = {
|
||||||
document: this.bookState.id,
|
document: this.bookState.id,
|
||||||
device_id: this.readerSettings.deviceID,
|
device_id: this.readerSettings.deviceID,
|
||||||
device: this.readerSettings.deviceName,
|
device: this.readerSettings.deviceName,
|
||||||
percentage:
|
percentage:
|
||||||
Math.round(
|
Math.round((currentWord / this.bookState.words) * 100000) / 100000,
|
||||||
(this.bookState.currentWord / this.bookState.words) * 100000
|
|
||||||
) / 100000,
|
|
||||||
progress: this.bookState.progress,
|
progress: this.bookState.progress,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// Flush Progress
|
||||||
fetch("/api/ko/syncs/progress", {
|
return fetch("/api/ko/syncs/progress", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(progressEvent),
|
body: JSON.stringify(progressEvent),
|
||||||
})
|
}).then(async (r) =>
|
||||||
.then(async (r) =>
|
console.log("[flushProgress] Flushed Progress:", {
|
||||||
console.log("Flushed Progress:", {
|
response: r,
|
||||||
response: r,
|
json: await r.json(),
|
||||||
json: await r.json(),
|
data: progressEvent,
|
||||||
data: progressEvent,
|
})
|
||||||
})
|
);
|
||||||
)
|
|
||||||
.catch((e) =>
|
|
||||||
console.error("Progress Flush Failed:", {
|
|
||||||
error: e,
|
|
||||||
data: progressEvent,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -770,7 +850,8 @@ class EBookReader {
|
|||||||
**/
|
**/
|
||||||
sectionProgress() {
|
sectionProgress() {
|
||||||
let visibleItems = this.rendition.manager.visible();
|
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 visibleSection = visibleItems[0];
|
||||||
let visibleIndex = visibleSection.index;
|
let visibleIndex = visibleSection.index;
|
||||||
let pagesPerBlock = visibleSection.layout.divisor;
|
let pagesPerBlock = visibleSection.layout.divisor;
|
||||||
@@ -787,12 +868,13 @@ class EBookReader {
|
|||||||
/**
|
/**
|
||||||
* Get chapter pages, name and progress percentage
|
* Get chapter pages, name and progress percentage
|
||||||
**/
|
**/
|
||||||
getBookStats() {
|
async getBookStats() {
|
||||||
let currentProgress = this.sectionProgress();
|
let currentProgress = this.sectionProgress();
|
||||||
if (!currentProgress) return;
|
if (!currentProgress) return;
|
||||||
let { sectionPages, sectionCurrentPage } = currentProgress;
|
let { sectionPages, sectionCurrentPage } = currentProgress;
|
||||||
|
|
||||||
let currentLocation = this.rendition.currentLocation();
|
let currentLocation = this.rendition.currentLocation();
|
||||||
|
let currentWord = await this.getBookWordPosition();
|
||||||
|
|
||||||
let currentTOC = this.book.navigation.toc.find(
|
let currentTOC = this.book.navigation.toc.find(
|
||||||
(item) => item.href == currentLocation.start.href
|
(item) => item.href == currentLocation.start.href
|
||||||
@@ -803,16 +885,14 @@ class EBookReader {
|
|||||||
sectionTotalPages: sectionPages,
|
sectionTotalPages: sectionPages,
|
||||||
chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
|
chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
|
||||||
percentage:
|
percentage:
|
||||||
Math.round(
|
Math.round((currentWord / this.bookState.words) * 10000) / 100,
|
||||||
(this.bookState.currentWord / this.bookState.words) * 10000
|
|
||||||
) / 100,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update elements with stats
|
* Update elements with stats
|
||||||
**/
|
**/
|
||||||
updateBookStats(data) {
|
updateBookStatElements(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
let chapterStatus = document.querySelector("#chapter-status");
|
let chapterStatus = document.querySelector("#chapter-status");
|
||||||
@@ -830,7 +910,7 @@ class EBookReader {
|
|||||||
* Get XPath from current location
|
* Get XPath from current location
|
||||||
**/
|
**/
|
||||||
async getXPathFromCFI(cfi) {
|
async getXPathFromCFI(cfi) {
|
||||||
// Get DocFragment (current book spline index)
|
// Get DocFragment (Spine Index)
|
||||||
let startCFI = cfi.replace("epubcfi(", "");
|
let startCFI = cfi.replace("epubcfi(", "");
|
||||||
let docFragmentIndex =
|
let docFragmentIndex =
|
||||||
this.book.spine.spineItems.find((item) =>
|
this.book.spine.spineItems.find((item) =>
|
||||||
@@ -838,58 +918,62 @@ class EBookReader {
|
|||||||
).index + 1;
|
).index + 1;
|
||||||
|
|
||||||
// Base Progress
|
// Base Progress
|
||||||
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
let basePos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
||||||
|
|
||||||
// Get first visible node
|
// Get First Node & Element Reference
|
||||||
let contents = this.rendition.getContents()[0];
|
let contents = this.rendition.getContents()[0];
|
||||||
let node = contents.range(cfi).startContainer;
|
let currentNode = contents.range(cfi).startContainer;
|
||||||
let element = null;
|
let element =
|
||||||
|
currentNode.nodeType == Node.ELEMENT_NODE
|
||||||
|
? currentNode
|
||||||
|
: currentNode.parentElement;
|
||||||
|
|
||||||
// Walk upwards and build progress until body
|
// XPath Reference
|
||||||
let childPos = "";
|
let allPos = "";
|
||||||
while (node.nodeName != "BODY") {
|
|
||||||
let ownValue;
|
|
||||||
|
|
||||||
switch (node.nodeType) {
|
// Walk Upwards
|
||||||
case Node.ELEMENT_NODE:
|
while (currentNode.nodeName != "BODY") {
|
||||||
// Store First Element Node
|
// Get Parent
|
||||||
if (!element) element = node;
|
let parentElement = currentNode.parentElement;
|
||||||
let relativeIndex =
|
|
||||||
Array.from(node.parentNode.children)
|
|
||||||
.filter((item) => item.nodeName == node.nodeName)
|
|
||||||
.indexOf(node) + 1;
|
|
||||||
|
|
||||||
ownValue = node.nodeName.toLowerCase() + "[" + relativeIndex + "]";
|
// Unknown Node -> Update Reference
|
||||||
break;
|
if (currentNode.nodeType != Node.ELEMENT_NODE) {
|
||||||
case Node.ATTRIBUTE_NODE:
|
console.log("[getXPathFromCFI] Unknown Node Type:", currentNode);
|
||||||
ownValue = "@" + node.nodeName;
|
currentNode = parentElement;
|
||||||
break;
|
continue;
|
||||||
case Node.TEXT_NODE:
|
|
||||||
case Node.CDATA_SECTION_NODE:
|
|
||||||
ownValue = "text()";
|
|
||||||
break;
|
|
||||||
case Node.PROCESSING_INSTRUCTION_NODE:
|
|
||||||
ownValue = "processing-instruction()";
|
|
||||||
break;
|
|
||||||
case Node.COMMENT_NODE:
|
|
||||||
ownValue = "comment()";
|
|
||||||
break;
|
|
||||||
case Node.DOCUMENT_NODE:
|
|
||||||
ownValue = "";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
ownValue = "";
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepend childPos & Update node reference
|
/**
|
||||||
childPos = "/" + ownValue + childPos;
|
* Exclude A tags. This could potentially be all inline elements:
|
||||||
node = node.parentNode;
|
* https://github.com/koreader/crengine/blob/master/cr3gui/data/epub.css#L149
|
||||||
|
**/
|
||||||
|
while (parentElement.nodeName == "A") {
|
||||||
|
parentElement = parentElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: This is depth / document order first, which means that this
|
||||||
|
* _could_ return incorrect results when dealing with nested "A" tags
|
||||||
|
* (dependent on how KOReader deals with nested "A" tags)
|
||||||
|
**/
|
||||||
|
let allDescendents = parentElement.querySelectorAll(currentNode.nodeName);
|
||||||
|
let relativeIndex = Array.from(allDescendents).indexOf(currentNode) + 1;
|
||||||
|
|
||||||
|
// Get Node Position
|
||||||
|
let nodePos =
|
||||||
|
currentNode.nodeName.toLowerCase() + "[" + relativeIndex + "]";
|
||||||
|
|
||||||
|
// Update Reference
|
||||||
|
currentNode = parentElement;
|
||||||
|
|
||||||
|
// Update Position
|
||||||
|
allPos = "/" + nodePos + allPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
let xpath = newPos + childPos;
|
// Combine XPath
|
||||||
|
let xpath = basePos + allPos;
|
||||||
|
|
||||||
// Return derived progress
|
// Return Derived Progress
|
||||||
return { xpath, element };
|
return { xpath, element };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,19 +981,13 @@ class EBookReader {
|
|||||||
* Get CFI from current location
|
* Get CFI from current location
|
||||||
**/
|
**/
|
||||||
async getCFIFromXPath(xpath) {
|
async getCFIFromXPath(xpath) {
|
||||||
// XPath Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
|
|
||||||
//
|
|
||||||
// - /body/DocFragment[15] = 15th item in book spline
|
|
||||||
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
|
|
||||||
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
|
|
||||||
|
|
||||||
// No XPath
|
// No XPath
|
||||||
if (!xpath || xpath == "") return {};
|
if (!xpath || xpath == "") return {};
|
||||||
|
|
||||||
// Match Document Fragment Index
|
// Match Document Fragment Index
|
||||||
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||||
if (!fragMatch) {
|
if (!fragMatch) {
|
||||||
console.warn("No XPath Match");
|
console.warn("[getCFIFromXPath] No XPath Match");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +1019,7 @@ class EBookReader {
|
|||||||
.find((item) => item.sectionIndex == spinePosition)?.document ||
|
.find((item) => item.sectionIndex == spinePosition)?.document ||
|
||||||
sectionItem.document;
|
sectionItem.document;
|
||||||
|
|
||||||
// Derive XPath & Namespace
|
// Derive Namespace & XPath
|
||||||
let namespaceURI = docItem.documentElement.namespaceURI;
|
let namespaceURI = docItem.documentElement.namespaceURI;
|
||||||
let remainingXPath = xpath
|
let remainingXPath = xpath
|
||||||
// Replace with new base
|
// Replace with new base
|
||||||
@@ -951,6 +1029,26 @@ class EBookReader {
|
|||||||
// Remove potential trailing `text()`
|
// Remove potential trailing `text()`
|
||||||
.replace(/\/text\(\)(\[\d+\])?$/, "");
|
.replace(/\/text\(\)(\[\d+\])?$/, "");
|
||||||
|
|
||||||
|
// XPath to Element
|
||||||
|
let derivedSelectorElement = remainingXPath
|
||||||
|
.replace(/^\/html\/body/, "body")
|
||||||
|
.split("/")
|
||||||
|
.reduce((el, item) => {
|
||||||
|
// No Match
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
// Non Index
|
||||||
|
let indexMatch = item.match(/(\w+)\[(\d+)\]$/);
|
||||||
|
if (!indexMatch) return el.querySelector(item);
|
||||||
|
|
||||||
|
// Get @ Index
|
||||||
|
let tag = indexMatch[1];
|
||||||
|
let index = parseInt(indexMatch[2]) - 1;
|
||||||
|
return el.querySelectorAll(tag)[index];
|
||||||
|
}, docItem);
|
||||||
|
|
||||||
|
console.log("[getCFIFromXPath] Selector Element:", derivedSelectorElement);
|
||||||
|
|
||||||
// Validate Namespace
|
// Validate Namespace
|
||||||
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
|
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
|
||||||
|
|
||||||
@@ -967,8 +1065,25 @@ class EBookReader {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Element & CFI
|
/**
|
||||||
let element = docSearch.iterateNext();
|
* There are two ways to do this. One via XPath, and the other via derived
|
||||||
|
* CSS selectors. Unfortunately it seems like KOReaders XPath implementation
|
||||||
|
* is a little wonky, requiring the need for CSS Selectors.
|
||||||
|
*
|
||||||
|
* For example the following XPath was generated by KOReader:
|
||||||
|
* "/body/DocFragment[19]/body/h1/img.0"
|
||||||
|
*
|
||||||
|
* In reality, the XPath should have been (note the 'a'):
|
||||||
|
* "/body/DocFragment[19]/body/h1/a/img.0"
|
||||||
|
*
|
||||||
|
* Unfortunately due to the above, `docItem.evaluate` will not find the
|
||||||
|
* element. So as an alternative I thought it would be possible to derive
|
||||||
|
* a CSS selector. I think this should be fully comprehensive; AFAICT
|
||||||
|
* KOReader only creates XPaths referencing HTML tag names and indexes.
|
||||||
|
**/
|
||||||
|
|
||||||
|
// Get Element & CFI (XPath -> CSS Selector Fallback)
|
||||||
|
let element = docSearch.iterateNext() || derivedSelectorElement;
|
||||||
let cfi = sectionItem.cfiFromElement(element);
|
let cfi = sectionItem.cfiFromElement(element);
|
||||||
|
|
||||||
return { cfi, element };
|
return { cfi, element };
|
||||||
@@ -978,6 +1093,43 @@ class EBookReader {
|
|||||||
* Get visible word count - used for reading stats
|
* Get visible word count - used for reading stats
|
||||||
**/
|
**/
|
||||||
async getVisibleWordCount() {
|
async getVisibleWordCount() {
|
||||||
|
let visibleText = await this.getVisibleText();
|
||||||
|
return visibleText.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the word number of the whole book for the first visible word.
|
||||||
|
**/
|
||||||
|
async getBookWordPosition() {
|
||||||
|
// Get Contents & Spine
|
||||||
|
let contents = this.rendition.getContents()[0];
|
||||||
|
let spineItem = this.book.spine.get(contents.sectionIndex);
|
||||||
|
|
||||||
|
// Get CFI Range
|
||||||
|
let firstCFI = spineItem.cfiFromElement(
|
||||||
|
spineItem.document.body.children[0]
|
||||||
|
);
|
||||||
|
let currentLocation = await this.rendition.currentLocation();
|
||||||
|
let cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||||
|
|
||||||
|
// Get Chapter Text (Before Current Position)
|
||||||
|
let textRange = await this.book.getRange(cfiRange);
|
||||||
|
let chapterText = textRange.toString();
|
||||||
|
|
||||||
|
// Get Chapter & Book Positions
|
||||||
|
let chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||||
|
let preChapterWordPosition = this.book.spine.spineItems
|
||||||
|
.slice(0, contents.sectionIndex)
|
||||||
|
.reduce((totalCount, item) => totalCount + item.wordCount, 0);
|
||||||
|
|
||||||
|
// Return Current Word Pointer
|
||||||
|
return chapterWordPosition + preChapterWordPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visible text - used for word counts
|
||||||
|
**/
|
||||||
|
async getVisibleText() {
|
||||||
// Force Expand & Resize (Race Condition Issue)
|
// Force Expand & Resize (Race Condition Issue)
|
||||||
this.rendition.manager.visible().forEach((item) => item.expand());
|
this.rendition.manager.visible().forEach((item) => item.expand());
|
||||||
|
|
||||||
@@ -994,7 +1146,7 @@ class EBookReader {
|
|||||||
let visibleText = textRange.toString();
|
let visibleText = textRange.toString();
|
||||||
|
|
||||||
// Split on Whitespace
|
// Split on Whitespace
|
||||||
return visibleText.trim().split(/\s+/).length;
|
return visibleText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1049,14 +1201,17 @@ class EBookReader {
|
|||||||
* of progress percentage. Implementation returns the same number as the
|
* of progress percentage. Implementation returns the same number as the
|
||||||
* server side implementation.
|
* server side implementation.
|
||||||
**/
|
**/
|
||||||
countWords() {
|
async countWords() {
|
||||||
// Iterate over each item in the spine, render, and count words.
|
let spineWC = await Promise.all(
|
||||||
return this.book.spine.spineItems.reduce(async (totalCount, item) => {
|
this.book.spine.spineItems.map(async (item) => {
|
||||||
let currentCount = await totalCount;
|
let newDoc = await item.load(this.book.load.bind(this.book));
|
||||||
let newDoc = await item.load(this.book.load.bind(this.book));
|
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
||||||
let itemCount = newDoc.innerText.trim().split(/\s+/).length;
|
item.wordCount = spineWords;
|
||||||
return currentCount + itemCount;
|
return spineWords;
|
||||||
}, 0);
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1076,3 +1231,5 @@ class EBookReader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", initReader);
|
||||||
|
|||||||
2105
assets/style.css
233
assets/sw.js
Normal 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
|
After Width: | Height: | Size: 56 KiB |
BIN
banner.xcf
Normal file
@@ -1,6 +1,6 @@
|
|||||||
# Book Manager - SyncNinja KOReader Plugin
|
# AnthoLume - SyncNinja KOReader Plugin
|
||||||
|
|
||||||
This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include:
|
This is AnthoLume's KOReader Plugin called `syncninja.koplugin`. Features include:
|
||||||
|
|
||||||
- Syncing read activity
|
- Syncing read activity
|
||||||
- Uploading documents
|
- Uploading documents
|
||||||
@@ -12,10 +12,10 @@ Copy the `syncninja.koplugin` directory to the `plugins` directory for your KORe
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You must configure the BookManager server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves.
|
You must configure the AnthoLume server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves.
|
||||||
|
|
||||||
## KOSync Compatibility
|
## KOSync Compatibility
|
||||||
|
|
||||||
BookManager implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu.
|
AnthoLume implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu.
|
||||||
|
|
||||||
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above.
|
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above.
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ end
|
|||||||
function SyncNinja:checkDocuments(interactive)
|
function SyncNinja:checkDocuments(interactive)
|
||||||
logger.dbg("SyncNinja: checkDocuments")
|
logger.dbg("SyncNinja: checkDocuments")
|
||||||
|
|
||||||
-- ensure document sync enabled
|
-- Ensure Document Sync Enabled
|
||||||
if self.settings.sync_documents ~= true then return end
|
if self.settings.sync_documents ~= true then return end
|
||||||
|
|
||||||
-- API Request Data
|
-- API Request Data
|
||||||
@@ -723,6 +723,8 @@ function SyncNinja:downloadDocuments(doc_metadata, interactive)
|
|||||||
logger.dbg("SyncNinja: downloadDocuments")
|
logger.dbg("SyncNinja: downloadDocuments")
|
||||||
|
|
||||||
-- TODO
|
-- TODO
|
||||||
|
-- - OPDS Sufficient?
|
||||||
|
-- - Auto Configure OPDS?
|
||||||
end
|
end
|
||||||
|
|
||||||
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)
|
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)
|
||||||
@@ -851,8 +853,8 @@ function SyncNinja:getLocalDocumentMetadata()
|
|||||||
docsettings:saveSetting("partial_md5_checksum", pmd5)
|
docsettings:saveSetting("partial_md5_checksum", pmd5)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Get Document Props
|
-- Get Document Props & Ensure Not Nil
|
||||||
local doc_props = docsettings:readSetting("doc_props")
|
local doc_props = docsettings:readSetting("doc_props") or {}
|
||||||
local fdoc = bookinfo_books[v.file] or {}
|
local fdoc = bookinfo_books[v.file] or {}
|
||||||
|
|
||||||
-- Update or Create
|
-- Update or Create
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
// Miscellaneous Settings
|
// Miscellaneous Settings
|
||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
SearchEnabled bool
|
SearchEnabled bool
|
||||||
|
DemoMode bool
|
||||||
|
|
||||||
// Cookie Settings
|
// Cookie Settings
|
||||||
CookieSessionKey string
|
CookieSessionKey string
|
||||||
@@ -30,13 +31,14 @@ type Config struct {
|
|||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Version: "0.0.2",
|
Version: "0.0.1",
|
||||||
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
||||||
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")),
|
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
||||||
ConfigPath: getEnv("CONFIG_PATH", "/config"),
|
ConfigPath: getEnv("CONFIG_PATH", "/config"),
|
||||||
DataPath: getEnv("DATA_PATH", "/data"),
|
DataPath: getEnv("DATA_PATH", "/data"),
|
||||||
ListenPort: getEnv("LISTEN_PORT", "8585"),
|
ListenPort: getEnv("LISTEN_PORT", "8585"),
|
||||||
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
||||||
|
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
|
||||||
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
||||||
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
|
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
|
||||||
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ var ddl string
|
|||||||
//go:embed update_temp_tables.sql
|
//go:embed update_temp_tables.sql
|
||||||
var tsql string
|
var tsql string
|
||||||
|
|
||||||
|
//go:embed update_document_user_statistics.sql
|
||||||
|
var doc_user_stat_sql string
|
||||||
|
|
||||||
func NewMgr(c *config.Config) *DBManager {
|
func NewMgr(c *config.Config) *DBManager {
|
||||||
// Create Manager
|
// Create Manager
|
||||||
dbm := &DBManager{
|
dbm := &DBManager{
|
||||||
@@ -56,6 +59,25 @@ func NewMgr(c *config.Config) *DBManager {
|
|||||||
return dbm
|
return dbm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dbm *DBManager) Shutdown() error {
|
||||||
|
return dbm.DB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dbm *DBManager) UpdateDocumentUserStatistic(documentID string, userID string) error {
|
||||||
|
// Prepare Statement
|
||||||
|
stmt, err := dbm.DB.PrepareContext(dbm.Ctx, doc_user_stat_sql)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
if _, err := stmt.ExecContext(dbm.Ctx, documentID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (dbm *DBManager) CacheTempTables() error {
|
func (dbm *DBManager) CacheTempTables() error {
|
||||||
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
|
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -122,13 +122,13 @@ func (dt *databaseTest) TestActivity() {
|
|||||||
|
|
||||||
// Add Item
|
// Add Item
|
||||||
activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{
|
activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{
|
||||||
DocumentID: documentID,
|
DocumentID: documentID,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
StartTime: d.UTC().Format(time.RFC3339),
|
StartTime: d.UTC().Format(time.RFC3339),
|
||||||
Duration: 60,
|
Duration: 60,
|
||||||
Page: counter,
|
StartPercentage: float64(counter) / 100.0,
|
||||||
Pages: 100,
|
EndPercentage: float64(counter+1) / 100.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate No Error
|
// Validate No Error
|
||||||
@@ -143,9 +143,7 @@ func (dt *databaseTest) TestActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initiate Cache
|
// Initiate Cache
|
||||||
if err := dt.dbm.CacheTempTables(); err != nil {
|
dt.dbm.CacheTempTables()
|
||||||
t.Fatalf(`Error: %v`, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Exists
|
// Validate Exists
|
||||||
existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
|
existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
UserID string `json:"user_id"`
|
ID int64 `json:"id"`
|
||||||
DocumentID string `json:"document_id"`
|
UserID string `json:"user_id"`
|
||||||
DeviceID string `json:"device_id"`
|
DocumentID string `json:"document_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
DeviceID string `json:"device_id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Page int64 `json:"page"`
|
StartPercentage float64 `json:"start_percentage"`
|
||||||
Pages int64 `json:"pages"`
|
EndPercentage float64 `json:"end_percentage"`
|
||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
@@ -63,10 +64,8 @@ type DocumentUserStatistic struct {
|
|||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
LastRead string `json:"last_read"`
|
LastRead string `json:"last_read"`
|
||||||
Page int64 `json:"page"`
|
|
||||||
Pages int64 `json:"pages"`
|
|
||||||
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
||||||
ReadPages int64 `json:"read_pages"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
WordsRead int64 `json:"words_read"`
|
WordsRead int64 `json:"words_read"`
|
||||||
Wpm float64 `json:"wpm"`
|
Wpm float64 `json:"wpm"`
|
||||||
@@ -85,18 +84,6 @@ type Metadatum struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawActivity struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
Page int64 `json:"page"`
|
|
||||||
Pages int64 `json:"pages"`
|
|
||||||
Duration int64 `json:"duration"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Pass *string `json:"-"`
|
Pass *string `json:"-"`
|
||||||
@@ -119,27 +106,14 @@ type UserStreak struct {
|
|||||||
type ViewDocumentUserStatistic struct {
|
type ViewDocumentUserStatistic struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
LastRead string `json:"last_read"`
|
LastRead interface{} `json:"last_read"`
|
||||||
Page int64 `json:"page"`
|
|
||||||
Pages int64 `json:"pages"`
|
|
||||||
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
|
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
|
||||||
ReadPages int64 `json:"read_pages"`
|
ReadPercentage sql.NullFloat64 `json:"read_percentage"`
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
WordsRead interface{} `json:"words_read"`
|
WordsRead interface{} `json:"words_read"`
|
||||||
Wpm int64 `json:"wpm"`
|
Wpm int64 `json:"wpm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewRescaledActivity struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
Page int64 `json:"page"`
|
|
||||||
Pages int64 `json:"pages"`
|
|
||||||
Duration int64 `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewUserStreak struct {
|
type ViewUserStreak struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
-- name: AddActivity :one
|
-- name: AddActivity :one
|
||||||
INSERT INTO raw_activity (
|
INSERT INTO activity (
|
||||||
user_id,
|
user_id,
|
||||||
document_id,
|
document_id,
|
||||||
device_id,
|
device_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
page,
|
start_percentage,
|
||||||
pages
|
end_percentage
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -43,8 +43,7 @@ WITH filtered_activity AS (
|
|||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
page,
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
pages
|
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
activity.user_id = $user_id
|
activity.user_id = $user_id
|
||||||
@@ -65,8 +64,7 @@ SELECT
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
page,
|
read_percentage
|
||||||
pages
|
|
||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN documents ON documents.id = activity.document_id
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
LEFT JOIN users ON users.id = activity.user_id;
|
LEFT JOIN users ON users.id = activity.user_id;
|
||||||
@@ -82,9 +80,9 @@ WITH RECURSIVE last_30_days AS (
|
|||||||
),
|
),
|
||||||
filtered_activity AS (
|
filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration
|
duration
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE start_time > DATE('now', '-31 days')
|
WHERE start_time > DATE('now', '-31 days')
|
||||||
AND activity.user_id = $user_id
|
AND activity.user_id = $user_id
|
||||||
@@ -142,41 +140,6 @@ ORDER BY devices.last_synced DESC;
|
|||||||
SELECT * FROM documents
|
SELECT * FROM documents
|
||||||
WHERE id = $document_id LIMIT 1;
|
WHERE id = $document_id LIMIT 1;
|
||||||
|
|
||||||
-- name: GetDocumentDaysRead :one
|
|
||||||
WITH document_days AS (
|
|
||||||
SELECT DATE(start_time, time_offset) AS dates
|
|
||||||
FROM activity
|
|
||||||
JOIN users ON users.id = activity.user_id
|
|
||||||
WHERE document_id = $document_id
|
|
||||||
AND user_id = $user_id
|
|
||||||
GROUP BY dates
|
|
||||||
)
|
|
||||||
SELECT CAST(COUNT(*) AS INTEGER) AS days_read
|
|
||||||
FROM document_days;
|
|
||||||
|
|
||||||
-- name: GetDocumentReadStats :one
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT page) AS pages_read,
|
|
||||||
SUM(duration) AS total_time
|
|
||||||
FROM activity
|
|
||||||
WHERE document_id = $document_id
|
|
||||||
AND user_id = $user_id
|
|
||||||
AND start_time >= $start_time;
|
|
||||||
|
|
||||||
-- name: GetDocumentReadStatsCapped :one
|
|
||||||
WITH capped_stats AS (
|
|
||||||
SELECT MIN(SUM(duration), CAST($page_duration_cap AS INTEGER)) AS durations
|
|
||||||
FROM activity
|
|
||||||
WHERE document_id = $document_id
|
|
||||||
AND user_id = $user_id
|
|
||||||
AND start_time >= $start_time
|
|
||||||
GROUP BY page
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
CAST(COUNT(*) AS INTEGER) AS pages_read,
|
|
||||||
CAST(SUM(durations) AS INTEGER) AS total_time
|
|
||||||
FROM capped_stats;
|
|
||||||
|
|
||||||
-- name: GetDocumentWithStats :one
|
-- name: GetDocumentWithStats :one
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -189,23 +152,21 @@ SELECT
|
|||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.page, 0) AS page,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.pages, 0) AS pages,
|
|
||||||
COALESCE(dus.read_pages, 0) AS read_pages,
|
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
||||||
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
||||||
AS last_read,
|
AS last_read,
|
||||||
CASE
|
ROUND(CAST(CASE
|
||||||
WHEN dus.percentage > 97.0 THEN 100.0
|
|
||||||
WHEN dus.percentage IS NULL THEN 0.0
|
WHEN dus.percentage IS NULL THEN 0.0
|
||||||
ELSE dus.percentage
|
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
|
||||||
END AS percentage,
|
ELSE dus.percentage * 100.0
|
||||||
|
END AS REAL), 2) AS percentage,
|
||||||
CAST(CASE
|
CAST(CASE
|
||||||
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
||||||
ELSE
|
ELSE
|
||||||
CAST(dus.total_time_seconds AS REAL)
|
CAST(dus.total_time_seconds AS REAL)
|
||||||
/ CAST(dus.read_pages AS REAL)
|
/ (dus.read_percentage * 100.0)
|
||||||
END AS INTEGER) AS seconds_per_page
|
END AS INTEGER) AS seconds_per_percent
|
||||||
FROM documents AS docs
|
FROM documents AS docs
|
||||||
LEFT JOIN users ON users.id = $user_id
|
LEFT JOIN users ON users.id = $user_id
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@@ -221,6 +182,16 @@ ORDER BY created_at DESC
|
|||||||
LIMIT $limit
|
LIMIT $limit
|
||||||
OFFSET $offset;
|
OFFSET $offset;
|
||||||
|
|
||||||
|
-- name: GetDocumentsSize :one
|
||||||
|
SELECT
|
||||||
|
COUNT(rowid) AS length
|
||||||
|
FROM documents AS docs
|
||||||
|
WHERE $query IS NULL OR (
|
||||||
|
docs.title LIKE $query OR
|
||||||
|
docs.author LIKE $query
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetDocumentsWithStats :many
|
-- name: GetDocumentsWithStats :many
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -233,31 +204,36 @@ SELECT
|
|||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.page, 0) AS page,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.pages, 0) AS pages,
|
|
||||||
COALESCE(dus.read_pages, 0) AS read_pages,
|
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
||||||
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
||||||
AS last_read,
|
AS last_read,
|
||||||
CASE
|
ROUND(CAST(CASE
|
||||||
WHEN dus.percentage > 97.0 THEN 100.0
|
|
||||||
WHEN dus.percentage IS NULL THEN 0.0
|
WHEN dus.percentage IS NULL THEN 0.0
|
||||||
ELSE dus.percentage
|
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
|
||||||
END AS percentage,
|
ELSE dus.percentage * 100.0
|
||||||
|
END AS REAL), 2) AS percentage,
|
||||||
|
|
||||||
CASE
|
CASE
|
||||||
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
||||||
ELSE
|
ELSE
|
||||||
ROUND(
|
ROUND(
|
||||||
CAST(dus.total_time_seconds AS REAL)
|
CAST(dus.total_time_seconds AS REAL)
|
||||||
/ CAST(dus.read_pages AS REAL)
|
/ (dus.read_percentage * 100.0)
|
||||||
)
|
)
|
||||||
END AS seconds_per_page
|
END AS seconds_per_percent
|
||||||
FROM documents AS docs
|
FROM documents AS docs
|
||||||
LEFT JOIN users ON users.id = $user_id
|
LEFT JOIN users ON users.id = $user_id
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
document_user_statistics AS dus
|
document_user_statistics AS dus
|
||||||
ON dus.document_id = docs.id AND dus.user_id = $user_id
|
ON dus.document_id = docs.id AND dus.user_id = $user_id
|
||||||
WHERE docs.deleted = false
|
WHERE
|
||||||
|
docs.deleted = false AND (
|
||||||
|
$query IS NULL OR (
|
||||||
|
docs.title LIKE $query OR
|
||||||
|
docs.author LIKE $query
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY dus.last_read DESC, docs.created_at DESC
|
ORDER BY dus.last_read DESC, docs.created_at DESC
|
||||||
LIMIT $limit
|
LIMIT $limit
|
||||||
OFFSET $offset;
|
OFFSET $offset;
|
||||||
@@ -298,20 +274,6 @@ WHERE id = $user_id LIMIT 1;
|
|||||||
SELECT * FROM user_streaks
|
SELECT * FROM user_streaks
|
||||||
WHERE user_id = $user_id;
|
WHERE user_id = $user_id;
|
||||||
|
|
||||||
-- name: GetUsers :many
|
|
||||||
SELECT * FROM users
|
|
||||||
WHERE
|
|
||||||
users.id = $user
|
|
||||||
OR ?1 IN (
|
|
||||||
SELECT id
|
|
||||||
FROM users
|
|
||||||
WHERE id = $user
|
|
||||||
AND admin = 1
|
|
||||||
)
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $limit
|
|
||||||
OFFSET $offset;
|
|
||||||
|
|
||||||
-- name: GetWPMLeaderboard :many
|
-- name: GetWPMLeaderboard :many
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -328,35 +290,18 @@ ORDER BY wpm DESC;
|
|||||||
SELECT
|
SELECT
|
||||||
CAST(value AS TEXT) AS id,
|
CAST(value AS TEXT) AS id,
|
||||||
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
||||||
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata
|
CAST((documents.id IS NULL) AS BOOLEAN) AS want_metadata
|
||||||
FROM json_each(?1)
|
FROM json_each(?1)
|
||||||
LEFT JOIN documents
|
LEFT JOIN documents
|
||||||
ON value = documents.id
|
ON value = documents.id
|
||||||
WHERE (
|
WHERE (
|
||||||
documents.id IS NOT NULL
|
documents.id IS NOT NULL
|
||||||
AND documents.deleted = false
|
AND documents.deleted = false
|
||||||
AND (
|
AND documents.filepath IS NULL
|
||||||
documents.synced = false
|
|
||||||
OR documents.filepath IS NULL
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
OR (documents.id IS NULL)
|
OR (documents.id IS NULL)
|
||||||
OR CAST($document_ids AS TEXT) != CAST($document_ids AS TEXT);
|
OR CAST($document_ids AS TEXT) != CAST($document_ids AS TEXT);
|
||||||
|
|
||||||
-- name: UpdateDocumentDeleted :one
|
|
||||||
UPDATE documents
|
|
||||||
SET
|
|
||||||
deleted = $deleted
|
|
||||||
WHERE id = $id
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateDocumentSync :one
|
|
||||||
UPDATE documents
|
|
||||||
SET
|
|
||||||
synced = $synced
|
|
||||||
WHERE id = $id
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateProgress :one
|
-- name: UpdateProgress :one
|
||||||
INSERT OR REPLACE INTO document_progress (
|
INSERT OR REPLACE INTO document_progress (
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -7,53 +7,52 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const addActivity = `-- name: AddActivity :one
|
const addActivity = `-- name: AddActivity :one
|
||||||
INSERT INTO raw_activity (
|
INSERT INTO activity (
|
||||||
user_id,
|
user_id,
|
||||||
document_id,
|
document_id,
|
||||||
device_id,
|
device_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
page,
|
start_percentage,
|
||||||
pages
|
end_percentage
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id, user_id, document_id, device_id, start_time, page, pages, duration, created_at
|
RETURNING id, user_id, document_id, device_id, start_time, start_percentage, end_percentage, duration, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type AddActivityParams struct {
|
type AddActivityParams struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
Page int64 `json:"page"`
|
StartPercentage float64 `json:"start_percentage"`
|
||||||
Pages int64 `json:"pages"`
|
EndPercentage float64 `json:"end_percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (RawActivity, error) {
|
func (q *Queries) AddActivity(ctx context.Context, arg AddActivityParams) (Activity, error) {
|
||||||
row := q.db.QueryRowContext(ctx, addActivity,
|
row := q.db.QueryRowContext(ctx, addActivity,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
arg.DocumentID,
|
arg.DocumentID,
|
||||||
arg.DeviceID,
|
arg.DeviceID,
|
||||||
arg.StartTime,
|
arg.StartTime,
|
||||||
arg.Duration,
|
arg.Duration,
|
||||||
arg.Page,
|
arg.StartPercentage,
|
||||||
arg.Pages,
|
arg.EndPercentage,
|
||||||
)
|
)
|
||||||
var i RawActivity
|
var i Activity
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.DocumentID,
|
&i.DocumentID,
|
||||||
&i.DeviceID,
|
&i.DeviceID,
|
||||||
&i.StartTime,
|
&i.StartTime,
|
||||||
&i.Page,
|
&i.StartPercentage,
|
||||||
&i.Pages,
|
&i.EndPercentage,
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -154,8 +153,7 @@ WITH filtered_activity AS (
|
|||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
page,
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
pages
|
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
activity.user_id = ?1
|
activity.user_id = ?1
|
||||||
@@ -176,8 +174,7 @@ SELECT
|
|||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
page,
|
read_percentage
|
||||||
pages
|
|
||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN documents ON documents.id = activity.document_id
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
LEFT JOIN users ON users.id = activity.user_id
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
@@ -192,13 +189,12 @@ type GetActivityParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetActivityRow struct {
|
type GetActivityRow struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Duration int64 `json:"duration"`
|
Duration int64 `json:"duration"`
|
||||||
Page int64 `json:"page"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
Pages int64 `json:"pages"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||||
@@ -222,8 +218,7 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
|
|||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Author,
|
&i.Author,
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
&i.Page,
|
&i.ReadPercentage,
|
||||||
&i.Pages,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -249,9 +244,9 @@ WITH RECURSIVE last_30_days AS (
|
|||||||
),
|
),
|
||||||
filtered_activity AS (
|
filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration
|
duration
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE start_time > DATE('now', '-31 days')
|
WHERE start_time > DATE('now', '-31 days')
|
||||||
AND activity.user_id = ?1
|
AND activity.user_id = ?1
|
||||||
@@ -465,98 +460,6 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDocumentDaysRead = `-- name: GetDocumentDaysRead :one
|
|
||||||
WITH document_days AS (
|
|
||||||
SELECT DATE(start_time, time_offset) AS dates
|
|
||||||
FROM activity
|
|
||||||
JOIN users ON users.id = activity.user_id
|
|
||||||
WHERE document_id = ?1
|
|
||||||
AND user_id = ?2
|
|
||||||
GROUP BY dates
|
|
||||||
)
|
|
||||||
SELECT CAST(COUNT(*) AS INTEGER) AS days_read
|
|
||||||
FROM document_days
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetDocumentDaysReadParams struct {
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetDocumentDaysRead(ctx context.Context, arg GetDocumentDaysReadParams) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getDocumentDaysRead, arg.DocumentID, arg.UserID)
|
|
||||||
var days_read int64
|
|
||||||
err := row.Scan(&days_read)
|
|
||||||
return days_read, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDocumentReadStats = `-- name: GetDocumentReadStats :one
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT page) AS pages_read,
|
|
||||||
SUM(duration) AS total_time
|
|
||||||
FROM activity
|
|
||||||
WHERE document_id = ?1
|
|
||||||
AND user_id = ?2
|
|
||||||
AND start_time >= ?3
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetDocumentReadStatsParams struct {
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetDocumentReadStatsRow struct {
|
|
||||||
PagesRead int64 `json:"pages_read"`
|
|
||||||
TotalTime sql.NullFloat64 `json:"total_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetDocumentReadStats(ctx context.Context, arg GetDocumentReadStatsParams) (GetDocumentReadStatsRow, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getDocumentReadStats, arg.DocumentID, arg.UserID, arg.StartTime)
|
|
||||||
var i GetDocumentReadStatsRow
|
|
||||||
err := row.Scan(&i.PagesRead, &i.TotalTime)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDocumentReadStatsCapped = `-- name: GetDocumentReadStatsCapped :one
|
|
||||||
WITH capped_stats AS (
|
|
||||||
SELECT MIN(SUM(duration), CAST(?1 AS INTEGER)) AS durations
|
|
||||||
FROM activity
|
|
||||||
WHERE document_id = ?2
|
|
||||||
AND user_id = ?3
|
|
||||||
AND start_time >= ?4
|
|
||||||
GROUP BY page
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
CAST(COUNT(*) AS INTEGER) AS pages_read,
|
|
||||||
CAST(SUM(durations) AS INTEGER) AS total_time
|
|
||||||
FROM capped_stats
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetDocumentReadStatsCappedParams struct {
|
|
||||||
PageDurationCap int64 `json:"page_duration_cap"`
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetDocumentReadStatsCappedRow struct {
|
|
||||||
PagesRead int64 `json:"pages_read"`
|
|
||||||
TotalTime int64 `json:"total_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetDocumentReadStatsCapped(ctx context.Context, arg GetDocumentReadStatsCappedParams) (GetDocumentReadStatsCappedRow, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getDocumentReadStatsCapped,
|
|
||||||
arg.PageDurationCap,
|
|
||||||
arg.DocumentID,
|
|
||||||
arg.UserID,
|
|
||||||
arg.StartTime,
|
|
||||||
)
|
|
||||||
var i GetDocumentReadStatsCappedRow
|
|
||||||
err := row.Scan(&i.PagesRead, &i.TotalTime)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
|
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -569,23 +472,21 @@ SELECT
|
|||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.page, 0) AS page,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.pages, 0) AS pages,
|
|
||||||
COALESCE(dus.read_pages, 0) AS read_pages,
|
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
||||||
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
||||||
AS last_read,
|
AS last_read,
|
||||||
CASE
|
ROUND(CAST(CASE
|
||||||
WHEN dus.percentage > 97.0 THEN 100.0
|
|
||||||
WHEN dus.percentage IS NULL THEN 0.0
|
WHEN dus.percentage IS NULL THEN 0.0
|
||||||
ELSE dus.percentage
|
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
|
||||||
END AS percentage,
|
ELSE dus.percentage * 100.0
|
||||||
|
END AS REAL), 2) AS percentage,
|
||||||
CAST(CASE
|
CAST(CASE
|
||||||
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
||||||
ELSE
|
ELSE
|
||||||
CAST(dus.total_time_seconds AS REAL)
|
CAST(dus.total_time_seconds AS REAL)
|
||||||
/ CAST(dus.read_pages AS REAL)
|
/ (dus.read_percentage * 100.0)
|
||||||
END AS INTEGER) AS seconds_per_page
|
END AS INTEGER) AS seconds_per_percent
|
||||||
FROM documents AS docs
|
FROM documents AS docs
|
||||||
LEFT JOIN users ON users.id = ?1
|
LEFT JOIN users ON users.id = ?1
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@@ -602,22 +503,20 @@ type GetDocumentWithStatsParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetDocumentWithStatsRow struct {
|
type GetDocumentWithStatsRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Isbn10 *string `json:"isbn10"`
|
Isbn10 *string `json:"isbn10"`
|
||||||
Isbn13 *string `json:"isbn13"`
|
Isbn13 *string `json:"isbn13"`
|
||||||
Filepath *string `json:"filepath"`
|
Filepath *string `json:"filepath"`
|
||||||
Words *int64 `json:"words"`
|
Words *int64 `json:"words"`
|
||||||
Wpm int64 `json:"wpm"`
|
Wpm int64 `json:"wpm"`
|
||||||
Page int64 `json:"page"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
Pages int64 `json:"pages"`
|
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
||||||
ReadPages int64 `json:"read_pages"`
|
LastRead interface{} `json:"last_read"`
|
||||||
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
Percentage float64 `json:"percentage"`
|
||||||
LastRead interface{} `json:"last_read"`
|
SecondsPerPercent int64 `json:"seconds_per_percent"`
|
||||||
Percentage interface{} `json:"percentage"`
|
|
||||||
SecondsPerPage int64 `json:"seconds_per_page"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) {
|
func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) {
|
||||||
@@ -633,13 +532,11 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
|
|||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Words,
|
&i.Words,
|
||||||
&i.Wpm,
|
&i.Wpm,
|
||||||
&i.Page,
|
&i.ReadPercentage,
|
||||||
&i.Pages,
|
|
||||||
&i.ReadPages,
|
|
||||||
&i.TotalTimeSeconds,
|
&i.TotalTimeSeconds,
|
||||||
&i.LastRead,
|
&i.LastRead,
|
||||||
&i.Percentage,
|
&i.Percentage,
|
||||||
&i.SecondsPerPage,
|
&i.SecondsPerPercent,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -699,6 +596,24 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDocumentsSize = `-- name: GetDocumentsSize :one
|
||||||
|
SELECT
|
||||||
|
COUNT(rowid) AS length
|
||||||
|
FROM documents AS docs
|
||||||
|
WHERE ?1 IS NULL OR (
|
||||||
|
docs.title LIKE ?1 OR
|
||||||
|
docs.author LIKE ?1
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDocumentsSize(ctx context.Context, query interface{}) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getDocumentsSize, query)
|
||||||
|
var length int64
|
||||||
|
err := row.Scan(&length)
|
||||||
|
return length, err
|
||||||
|
}
|
||||||
|
|
||||||
const getDocumentsWithStats = `-- name: GetDocumentsWithStats :many
|
const getDocumentsWithStats = `-- name: GetDocumentsWithStats :many
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -711,63 +626,72 @@ SELECT
|
|||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.page, 0) AS page,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
COALESCE(dus.pages, 0) AS pages,
|
|
||||||
COALESCE(dus.read_pages, 0) AS read_pages,
|
|
||||||
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
|
||||||
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
|
||||||
AS last_read,
|
AS last_read,
|
||||||
CASE
|
ROUND(CAST(CASE
|
||||||
WHEN dus.percentage > 97.0 THEN 100.0
|
|
||||||
WHEN dus.percentage IS NULL THEN 0.0
|
WHEN dus.percentage IS NULL THEN 0.0
|
||||||
ELSE dus.percentage
|
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
|
||||||
END AS percentage,
|
ELSE dus.percentage * 100.0
|
||||||
|
END AS REAL), 2) AS percentage,
|
||||||
|
|
||||||
CASE
|
CASE
|
||||||
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
WHEN dus.total_time_seconds IS NULL THEN 0.0
|
||||||
ELSE
|
ELSE
|
||||||
ROUND(
|
ROUND(
|
||||||
CAST(dus.total_time_seconds AS REAL)
|
CAST(dus.total_time_seconds AS REAL)
|
||||||
/ CAST(dus.read_pages AS REAL)
|
/ (dus.read_percentage * 100.0)
|
||||||
)
|
)
|
||||||
END AS seconds_per_page
|
END AS seconds_per_percent
|
||||||
FROM documents AS docs
|
FROM documents AS docs
|
||||||
LEFT JOIN users ON users.id = ?1
|
LEFT JOIN users ON users.id = ?1
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
document_user_statistics AS dus
|
document_user_statistics AS dus
|
||||||
ON dus.document_id = docs.id AND dus.user_id = ?1
|
ON dus.document_id = docs.id AND dus.user_id = ?1
|
||||||
WHERE docs.deleted = false
|
WHERE
|
||||||
|
docs.deleted = false AND (
|
||||||
|
?2 IS NULL OR (
|
||||||
|
docs.title LIKE ?2 OR
|
||||||
|
docs.author LIKE ?2
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY dus.last_read DESC, docs.created_at DESC
|
ORDER BY dus.last_read DESC, docs.created_at DESC
|
||||||
LIMIT ?3
|
LIMIT ?4
|
||||||
OFFSET ?2
|
OFFSET ?3
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDocumentsWithStatsParams struct {
|
type GetDocumentsWithStatsParams struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Offset int64 `json:"offset"`
|
Query interface{} `json:"query"`
|
||||||
Limit int64 `json:"limit"`
|
Offset int64 `json:"offset"`
|
||||||
|
Limit int64 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetDocumentsWithStatsRow struct {
|
type GetDocumentsWithStatsRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Isbn10 *string `json:"isbn10"`
|
Isbn10 *string `json:"isbn10"`
|
||||||
Isbn13 *string `json:"isbn13"`
|
Isbn13 *string `json:"isbn13"`
|
||||||
Filepath *string `json:"filepath"`
|
Filepath *string `json:"filepath"`
|
||||||
Words *int64 `json:"words"`
|
Words *int64 `json:"words"`
|
||||||
Wpm int64 `json:"wpm"`
|
Wpm int64 `json:"wpm"`
|
||||||
Page int64 `json:"page"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
Pages int64 `json:"pages"`
|
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
||||||
ReadPages int64 `json:"read_pages"`
|
LastRead interface{} `json:"last_read"`
|
||||||
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
Percentage float64 `json:"percentage"`
|
||||||
LastRead interface{} `json:"last_read"`
|
SecondsPerPercent interface{} `json:"seconds_per_percent"`
|
||||||
Percentage interface{} `json:"percentage"`
|
|
||||||
SecondsPerPage interface{} `json:"seconds_per_page"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
|
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats, arg.UserID, arg.Offset, arg.Limit)
|
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Query,
|
||||||
|
arg.Offset,
|
||||||
|
arg.Limit,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -785,13 +709,11 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
|
|||||||
&i.Filepath,
|
&i.Filepath,
|
||||||
&i.Words,
|
&i.Words,
|
||||||
&i.Wpm,
|
&i.Wpm,
|
||||||
&i.Page,
|
&i.ReadPercentage,
|
||||||
&i.Pages,
|
|
||||||
&i.ReadPages,
|
|
||||||
&i.TotalTimeSeconds,
|
&i.TotalTimeSeconds,
|
||||||
&i.LastRead,
|
&i.LastRead,
|
||||||
&i.Percentage,
|
&i.Percentage,
|
||||||
&i.SecondsPerPage,
|
&i.SecondsPerPercent,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -987,56 +909,6 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsers = `-- name: GetUsers :many
|
|
||||||
SELECT id, pass, admin, time_offset, created_at FROM users
|
|
||||||
WHERE
|
|
||||||
users.id = ?1
|
|
||||||
OR ?1 IN (
|
|
||||||
SELECT id
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?1
|
|
||||||
AND admin = 1
|
|
||||||
)
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?3
|
|
||||||
OFFSET ?2
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetUsersParams struct {
|
|
||||||
User string `json:"user"`
|
|
||||||
Offset int64 `json:"offset"`
|
|
||||||
Limit int64 `json:"limit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getUsers, arg.User, arg.Offset, arg.Limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []User
|
|
||||||
for rows.Next() {
|
|
||||||
var i User
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Pass,
|
|
||||||
&i.Admin,
|
|
||||||
&i.TimeOffset,
|
|
||||||
&i.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many
|
const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -1089,17 +961,14 @@ const getWantedDocuments = `-- name: GetWantedDocuments :many
|
|||||||
SELECT
|
SELECT
|
||||||
CAST(value AS TEXT) AS id,
|
CAST(value AS TEXT) AS id,
|
||||||
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
|
||||||
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata
|
CAST((documents.id IS NULL) AS BOOLEAN) AS want_metadata
|
||||||
FROM json_each(?1)
|
FROM json_each(?1)
|
||||||
LEFT JOIN documents
|
LEFT JOIN documents
|
||||||
ON value = documents.id
|
ON value = documents.id
|
||||||
WHERE (
|
WHERE (
|
||||||
documents.id IS NOT NULL
|
documents.id IS NOT NULL
|
||||||
AND documents.deleted = false
|
AND documents.deleted = false
|
||||||
AND (
|
AND documents.filepath IS NULL
|
||||||
documents.synced = false
|
|
||||||
OR documents.filepath IS NULL
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
OR (documents.id IS NULL)
|
OR (documents.id IS NULL)
|
||||||
OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT)
|
OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT)
|
||||||
@@ -1134,86 +1003,6 @@ func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDocumentDeleted = `-- name: UpdateDocumentDeleted :one
|
|
||||||
UPDATE documents
|
|
||||||
SET
|
|
||||||
deleted = ?1
|
|
||||||
WHERE id = ?2
|
|
||||||
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateDocumentDeletedParams struct {
|
|
||||||
Deleted bool `json:"-"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateDocumentDeleted(ctx context.Context, arg UpdateDocumentDeletedParams) (Document, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, updateDocumentDeleted, arg.Deleted, arg.ID)
|
|
||||||
var i Document
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Md5,
|
|
||||||
&i.Filepath,
|
|
||||||
&i.Coverfile,
|
|
||||||
&i.Title,
|
|
||||||
&i.Author,
|
|
||||||
&i.Series,
|
|
||||||
&i.SeriesIndex,
|
|
||||||
&i.Lang,
|
|
||||||
&i.Description,
|
|
||||||
&i.Words,
|
|
||||||
&i.Gbid,
|
|
||||||
&i.Olid,
|
|
||||||
&i.Isbn10,
|
|
||||||
&i.Isbn13,
|
|
||||||
&i.Synced,
|
|
||||||
&i.Deleted,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.CreatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateDocumentSync = `-- name: UpdateDocumentSync :one
|
|
||||||
UPDATE documents
|
|
||||||
SET
|
|
||||||
synced = ?1
|
|
||||||
WHERE id = ?2
|
|
||||||
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateDocumentSyncParams struct {
|
|
||||||
Synced bool `json:"-"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateDocumentSync(ctx context.Context, arg UpdateDocumentSyncParams) (Document, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, updateDocumentSync, arg.Synced, arg.ID)
|
|
||||||
var i Document
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Md5,
|
|
||||||
&i.Filepath,
|
|
||||||
&i.Coverfile,
|
|
||||||
&i.Title,
|
|
||||||
&i.Author,
|
|
||||||
&i.Series,
|
|
||||||
&i.SeriesIndex,
|
|
||||||
&i.Lang,
|
|
||||||
&i.Description,
|
|
||||||
&i.Words,
|
|
||||||
&i.Gbid,
|
|
||||||
&i.Olid,
|
|
||||||
&i.Isbn10,
|
|
||||||
&i.Isbn13,
|
|
||||||
&i.Synced,
|
|
||||||
&i.Deleted,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.CreatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProgress = `-- name: UpdateProgress :one
|
const updateProgress = `-- name: UpdateProgress :one
|
||||||
INSERT OR REPLACE INTO document_progress (
|
INSERT OR REPLACE INTO document_progress (
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -91,16 +91,17 @@ CREATE TABLE IF NOT EXISTS document_progress (
|
|||||||
PRIMARY KEY (user_id, document_id, device_id)
|
PRIMARY KEY (user_id, document_id, device_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Raw Read Activity
|
-- Read Activity
|
||||||
CREATE TABLE IF NOT EXISTS raw_activity (
|
CREATE TABLE IF NOT EXISTS activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
document_id TEXT NOT NULL,
|
document_id TEXT NOT NULL,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
|
||||||
start_time DATETIME NOT NULL,
|
start_time DATETIME NOT NULL,
|
||||||
page INTEGER NOT NULL,
|
start_percentage REAL NOT NULL,
|
||||||
pages INTEGER NOT NULL,
|
end_percentage REAL NOT NULL,
|
||||||
|
|
||||||
duration INTEGER NOT NULL,
|
duration INTEGER NOT NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
|
||||||
@@ -113,19 +114,6 @@ CREATE TABLE IF NOT EXISTS raw_activity (
|
|||||||
----------------------- Temporary Tables ----------------------
|
----------------------- Temporary Tables ----------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
|
|
||||||
-- Temporary Activity Table (Cached from View)
|
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS activity (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
document_id TEXT NOT NULL,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
start_time DATETIME NOT NULL,
|
|
||||||
page INTEGER NOT NULL,
|
|
||||||
pages INTEGER NOT NULL,
|
|
||||||
duration INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Temporary User Streaks Table (Cached from View)
|
-- Temporary User Streaks Table (Cached from View)
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
|
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -144,13 +132,13 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
|
|||||||
document_id TEXT NOT NULL,
|
document_id TEXT NOT NULL,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
last_read TEXT NOT NULL,
|
last_read TEXT NOT NULL,
|
||||||
page INTEGER NOT NULL,
|
|
||||||
pages INTEGER NOT NULL,
|
|
||||||
total_time_seconds INTEGER NOT NULL,
|
total_time_seconds INTEGER NOT NULL,
|
||||||
read_pages INTEGER NOT NULL,
|
read_percentage REAL NOT NULL,
|
||||||
percentage REAL NOT NULL,
|
percentage REAL NOT NULL,
|
||||||
words_read INTEGER NOT NULL,
|
words_read INTEGER NOT NULL,
|
||||||
wpm REAL NOT NULL
|
wpm REAL NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -158,9 +146,9 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
|
|||||||
--------------------------- Indexes ---------------------------
|
--------------------------- Indexes ---------------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS temp.activity_start_time ON activity (start_time);
|
CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time);
|
||||||
CREATE INDEX IF NOT EXISTS temp.activity_user_id ON activity (user_id);
|
CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
|
CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
|
||||||
user_id,
|
user_id,
|
||||||
document_id
|
document_id
|
||||||
);
|
);
|
||||||
@@ -169,100 +157,6 @@ CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
|
|||||||
---------------------------- Views ----------------------------
|
---------------------------- Views ----------------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
|
|
||||||
--------------------------------
|
|
||||||
------- Rescaled Activity ------
|
|
||||||
--------------------------------
|
|
||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS view_rescaled_activity AS
|
|
||||||
|
|
||||||
WITH RECURSIVE nums (idx) AS (
|
|
||||||
SELECT 1 AS idx
|
|
||||||
UNION ALL
|
|
||||||
SELECT idx + 1
|
|
||||||
FROM nums
|
|
||||||
LIMIT 1000
|
|
||||||
),
|
|
||||||
|
|
||||||
current_pages AS (
|
|
||||||
SELECT
|
|
||||||
document_id,
|
|
||||||
user_id,
|
|
||||||
pages
|
|
||||||
FROM raw_activity
|
|
||||||
GROUP BY document_id, user_id
|
|
||||||
HAVING MAX(start_time)
|
|
||||||
ORDER BY start_time DESC
|
|
||||||
),
|
|
||||||
|
|
||||||
intermediate AS (
|
|
||||||
SELECT
|
|
||||||
raw_activity.document_id,
|
|
||||||
raw_activity.device_id,
|
|
||||||
raw_activity.user_id,
|
|
||||||
raw_activity.created_at,
|
|
||||||
raw_activity.start_time,
|
|
||||||
raw_activity.duration,
|
|
||||||
raw_activity.page,
|
|
||||||
current_pages.pages,
|
|
||||||
|
|
||||||
-- Derive first page
|
|
||||||
((raw_activity.page - 1) * current_pages.pages) / raw_activity.pages
|
|
||||||
+ 1 AS first_page,
|
|
||||||
|
|
||||||
-- Derive last page
|
|
||||||
MAX(
|
|
||||||
((raw_activity.page - 1) * current_pages.pages)
|
|
||||||
/ raw_activity.pages
|
|
||||||
+ 1,
|
|
||||||
(raw_activity.page * current_pages.pages) / raw_activity.pages
|
|
||||||
) AS last_page
|
|
||||||
|
|
||||||
FROM raw_activity
|
|
||||||
INNER JOIN current_pages ON
|
|
||||||
current_pages.document_id = raw_activity.document_id
|
|
||||||
AND current_pages.user_id = raw_activity.user_id
|
|
||||||
),
|
|
||||||
|
|
||||||
num_limit AS (
|
|
||||||
SELECT * FROM nums
|
|
||||||
LIMIT (SELECT MAX(last_page - first_page + 1) FROM intermediate)
|
|
||||||
),
|
|
||||||
|
|
||||||
rescaled_raw AS (
|
|
||||||
SELECT
|
|
||||||
intermediate.document_id,
|
|
||||||
intermediate.device_id,
|
|
||||||
intermediate.user_id,
|
|
||||||
intermediate.created_at,
|
|
||||||
intermediate.start_time,
|
|
||||||
intermediate.last_page,
|
|
||||||
intermediate.pages,
|
|
||||||
intermediate.first_page + num_limit.idx - 1 AS page,
|
|
||||||
intermediate.duration / (
|
|
||||||
intermediate.last_page - intermediate.first_page + 1.0
|
|
||||||
) AS duration
|
|
||||||
FROM intermediate
|
|
||||||
LEFT JOIN num_limit ON
|
|
||||||
num_limit.idx <= (intermediate.last_page - intermediate.first_page + 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
user_id,
|
|
||||||
document_id,
|
|
||||||
device_id,
|
|
||||||
created_at,
|
|
||||||
start_time,
|
|
||||||
page,
|
|
||||||
pages,
|
|
||||||
|
|
||||||
-- Round up if last page (maintains total duration)
|
|
||||||
CAST(CASE
|
|
||||||
WHEN page = last_page AND duration != CAST(duration AS INTEGER)
|
|
||||||
THEN duration + 1
|
|
||||||
ELSE duration
|
|
||||||
END AS INTEGER) AS duration
|
|
||||||
FROM rescaled_raw;
|
|
||||||
|
|
||||||
--------------------------------
|
--------------------------------
|
||||||
--------- User Streaks ---------
|
--------- User Streaks ---------
|
||||||
--------------------------------
|
--------------------------------
|
||||||
@@ -279,7 +173,7 @@ WITH document_windows AS (
|
|||||||
'weekday 0', '-7 day'
|
'weekday 0', '-7 day'
|
||||||
) AS weekly_read,
|
) AS weekly_read,
|
||||||
DATE(activity.start_time, users.time_offset) AS daily_read
|
DATE(activity.start_time, users.time_offset) AS daily_read
|
||||||
FROM raw_activity AS activity
|
FROM activity
|
||||||
LEFT JOIN users ON users.id = activity.user_id
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
GROUP BY activity.user_id, weekly_read, daily_read
|
GROUP BY activity.user_id, weekly_read, daily_read
|
||||||
),
|
),
|
||||||
@@ -387,38 +281,86 @@ LEFT JOIN current_streak ON
|
|||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
|
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
|
||||||
|
|
||||||
WITH true_progress AS (
|
WITH intermediate_ga AS (
|
||||||
|
SELECT
|
||||||
|
ga1.id AS row_id,
|
||||||
|
ga1.user_id,
|
||||||
|
ga1.document_id,
|
||||||
|
ga1.duration,
|
||||||
|
ga1.start_time,
|
||||||
|
ga1.start_percentage,
|
||||||
|
ga1.end_percentage,
|
||||||
|
|
||||||
|
-- Find Overlapping Events (Assign Unique ID)
|
||||||
|
(
|
||||||
|
SELECT MIN(id)
|
||||||
|
FROM activity AS ga2
|
||||||
|
WHERE
|
||||||
|
ga1.document_id = ga2.document_id
|
||||||
|
AND ga1.user_id = ga2.user_id
|
||||||
|
AND ga1.start_percentage <= ga2.end_percentage
|
||||||
|
AND ga1.end_percentage >= ga2.start_percentage
|
||||||
|
) AS group_leader
|
||||||
|
FROM activity AS ga1
|
||||||
|
),
|
||||||
|
|
||||||
|
grouped_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
|
||||||
user_id,
|
user_id,
|
||||||
start_time AS last_read,
|
document_id,
|
||||||
page,
|
MAX(start_time) AS start_time,
|
||||||
pages,
|
MIN(start_percentage) AS start_percentage,
|
||||||
SUM(duration) AS total_time_seconds,
|
MAX(end_percentage) AS end_percentage,
|
||||||
|
MAX(end_percentage) - MIN(start_percentage) AS read_percentage,
|
||||||
|
SUM(duration) AS duration
|
||||||
|
FROM intermediate_ga
|
||||||
|
GROUP BY group_leader
|
||||||
|
),
|
||||||
|
|
||||||
-- Determine Read Pages
|
current_progress AS (
|
||||||
COUNT(DISTINCT page) AS read_pages,
|
SELECT
|
||||||
|
user_id,
|
||||||
-- Derive Percentage of Book
|
document_id,
|
||||||
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
|
COALESCE((
|
||||||
FROM view_rescaled_activity
|
SELECT percentage
|
||||||
GROUP BY document_id, user_id
|
FROM document_progress AS dp
|
||||||
|
WHERE
|
||||||
|
dp.user_id = iga.user_id
|
||||||
|
AND dp.document_id = iga.document_id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
), end_percentage) AS percentage
|
||||||
|
FROM intermediate_ga AS iga
|
||||||
|
GROUP BY user_id, document_id
|
||||||
HAVING MAX(start_time)
|
HAVING MAX(start_time)
|
||||||
)
|
)
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
true_progress.*,
|
ga.document_id,
|
||||||
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
|
ga.user_id,
|
||||||
|
MAX(start_time) AS last_read,
|
||||||
|
SUM(duration) AS total_time_seconds,
|
||||||
|
SUM(read_percentage) AS read_percentage,
|
||||||
|
cp.percentage,
|
||||||
|
|
||||||
|
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
||||||
AS words_read,
|
AS words_read,
|
||||||
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
|
|
||||||
/ (total_time_seconds / 60.0) AS wpm
|
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
||||||
FROM true_progress
|
/ (SUM(duration) / 60.0) AS wpm
|
||||||
INNER JOIN documents ON documents.id = true_progress.document_id
|
FROM grouped_activity AS ga
|
||||||
|
INNER JOIN
|
||||||
|
current_progress AS cp
|
||||||
|
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
|
||||||
|
INNER JOIN
|
||||||
|
documents AS d
|
||||||
|
ON d.id = ga.document_id
|
||||||
|
GROUP BY ga.document_id, ga.user_id
|
||||||
ORDER BY wpm DESC;
|
ORDER BY wpm DESC;
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
------------------ Populate Temporary Tables ------------------
|
------------------ Populate Temporary Tables ------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
INSERT INTO activity SELECT * FROM view_rescaled_activity;
|
|
||||||
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
||||||
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
|
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
|
||||||
|
|
||||||
|
|||||||
77
database/update_document_user_statistics.sql
Normal 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;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
DELETE FROM activity;
|
|
||||||
INSERT INTO activity SELECT * FROM view_rescaled_activity;
|
|
||||||
DELETE FROM user_streaks;
|
DELETE FROM user_streaks;
|
||||||
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
||||||
DELETE FROM document_user_statistics;
|
DELETE FROM document_user_statistics;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
bookmanager:
|
antholume:
|
||||||
environment:
|
environment:
|
||||||
- CONFIG_PATH=/data
|
- CONFIG_PATH=/data
|
||||||
- DATA_PATH=/data
|
- DATA_PATH=/data
|
||||||
|
|||||||
@@ -101,9 +101,6 @@ func getSVGBezierOpposedLine(pointA SVGGraphPoint, pointB SVGGraphPoint) SVGBezi
|
|||||||
Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))),
|
Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))),
|
||||||
Angle: int(math.Atan2(lengthY, lengthX)),
|
Angle: int(math.Atan2(lengthY, lengthX)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// length = Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),
|
|
||||||
// angle = Math.atan2(lengthY, lengthX)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPoint, nextPoint *SVGGraphPoint, isReverse bool) SVGGraphPoint {
|
func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPoint, nextPoint *SVGGraphPoint, isReverse bool) SVGGraphPoint {
|
||||||
|
|||||||
28
main.go
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
@@ -22,13 +24,13 @@ func main() {
|
|||||||
log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}})
|
log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}})
|
||||||
|
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "Book Bank",
|
Name: "AnthoLume",
|
||||||
Usage: "A self hosted e-book progress tracker.",
|
Usage: "A self hosted e-book progress tracker.",
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "serve",
|
Name: "serve",
|
||||||
Aliases: []string{"s"},
|
Aliases: []string{"s"},
|
||||||
Usage: "Start Book Bank web server.",
|
Usage: "Start AnthoLume web server.",
|
||||||
Action: cmdServer,
|
Action: cmdServer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -40,17 +42,23 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cmdServer(ctx *cli.Context) error {
|
func cmdServer(ctx *cli.Context) error {
|
||||||
log.Info("Starting Book Bank Server")
|
log.Info("Starting AnthoLume Server")
|
||||||
|
|
||||||
|
// Create Channel
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
done := make(chan struct{})
|
||||||
|
interrupt := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Start Server
|
||||||
server := server.NewServer()
|
server := server.NewServer()
|
||||||
server.StartServer()
|
server.StartServer(&wg, done)
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
// Wait & Close
|
||||||
signal.Notify(c, os.Interrupt)
|
<-interrupt
|
||||||
<-c
|
server.StopServer(&wg, done)
|
||||||
|
|
||||||
log.Info("Stopping Server")
|
// Stop Server
|
||||||
server.StopServer()
|
|
||||||
log.Info("Server Stopped")
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
16
notes/README.md
Normal file
@@ -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
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
# PWA Screenshots
|
# PWA Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/activity.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/activity.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/activity.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png" width="32%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="32%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
# Web Screenshots
|
# Web Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/login.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/login.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/login.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/login.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/home.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/home.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/home.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/home.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/activity.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/activity.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/activity.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/activity.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/documents.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/documents.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/documents.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/documents.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/document.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/document.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/document.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/document.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/metadata.png">
|
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/metadata.png">
|
||||||
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/web/metadata.png" width="49%">
|
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/web/metadata.png" width="49%">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -29,35 +30,72 @@ func NewServer() *Server {
|
|||||||
// Create Paths
|
// Create Paths
|
||||||
docDir := filepath.Join(c.DataPath, "documents")
|
docDir := filepath.Join(c.DataPath, "documents")
|
||||||
coversDir := filepath.Join(c.DataPath, "covers")
|
coversDir := filepath.Join(c.DataPath, "covers")
|
||||||
_ = os.Mkdir(docDir, os.ModePerm)
|
os.Mkdir(docDir, os.ModePerm)
|
||||||
_ = os.Mkdir(coversDir, os.ModePerm)
|
os.Mkdir(coversDir, os.ModePerm)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
API: api,
|
API: api,
|
||||||
Config: c,
|
Config: c,
|
||||||
Database: db,
|
Database: db,
|
||||||
|
httpServer: &http.Server{
|
||||||
|
Handler: api.Router,
|
||||||
|
Addr: (":" + c.ListenPort),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StartServer() {
|
func (s *Server) StartServer(wg *sync.WaitGroup, done <-chan struct{}) {
|
||||||
listenAddr := (":" + s.Config.ListenPort)
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
|
|
||||||
s.httpServer = &http.Server{
|
wg.Add(2)
|
||||||
Handler: s.API.Router,
|
|
||||||
Addr: listenAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := s.httpServer.ListenAndServe()
|
||||||
if err != nil {
|
if err != nil && err != http.ErrServerClosed {
|
||||||
log.Error("Error starting server ", err)
|
log.Error("Error Starting Server:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.RunScheduledTasks()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.RunScheduledTasks()
|
||||||
|
case <-done:
|
||||||
|
log.Info("Stopping Task Runner...")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StopServer() {
|
func (s *Server) RunScheduledTasks() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
log.Info("[RunScheduledTasks] Refreshing Temp Table Cache")
|
||||||
defer cancel()
|
if err := s.API.DB.CacheTempTables(); err != nil {
|
||||||
s.httpServer.Shutdown(ctx)
|
log.Warn("[RunScheduledTasks] Refreshing Temp Table Cache Failure:", err)
|
||||||
s.API.DB.DB.Close()
|
}
|
||||||
|
log.Info("[RunScheduledTasks] Refreshing Temp Table Success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) StopServer(wg *sync.WaitGroup, done chan<- struct{}) {
|
||||||
|
log.Info("Stopping HTTP Server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||||
|
log.Info("Shutting Error")
|
||||||
|
}
|
||||||
|
s.API.DB.Shutdown()
|
||||||
|
|
||||||
|
close(done)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
log.Info("Server Stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ pkgs.mkShell {
|
|||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
go
|
go
|
||||||
nodePackages.tailwindcss
|
nodePackages.tailwindcss
|
||||||
|
python311Packages.grip
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./templates/**/*.html", "./assets/reader/index.js"],
|
content: [
|
||||||
|
"./templates/**/*.html",
|
||||||
|
"./assets/local/*.{html,js}",
|
||||||
|
"./assets/reader/*.{html,js}",
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,32 +3,20 @@
|
|||||||
{{end}} {{define "content"}}
|
{{end}} {{define "content"}}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm">
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
<thead class="text-gray-800 dark:text-gray-400">
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Document
|
Document
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Time
|
Time
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Duration
|
Duration
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
scope="col"
|
Percent
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Page
|
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -38,7 +26,6 @@
|
|||||||
<td class="text-center p-3" colspan="4">No Results</td>
|
<td class="text-center p-3" colspan="4">No Results</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{range $activity := .Data }}
|
{{range $activity := .Data }}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="p-3 border-b border-gray-200">
|
<td class="p-3 border-b border-gray-200">
|
||||||
@@ -51,7 +38,7 @@
|
|||||||
<p>{{ $activity.Duration }}</p>
|
<p>{{ $activity.Duration }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 border-b border-gray-200">
|
<td class="p-3 border-b border-gray-200">
|
||||||
<p>{{ $activity.Page }} / {{ $activity.Pages }}</p>
|
<p>{{ $activity.ReadPercentage }}%</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,11 +9,16 @@
|
|||||||
<meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)">
|
||||||
|
|
||||||
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
<title>AnthoLume - {{block "title" .}}{{end}}</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
|
||||||
|
<!-- Service Worker / Offline Cache Flush -->
|
||||||
|
<script src="/assets/lib/idb-keyval.min.js"></script>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/index.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ----------------------------- */
|
/* ----------------------------- */
|
||||||
/* -------- PWA Styling -------- */
|
/* -------- PWA Styling -------- */
|
||||||
@@ -122,16 +127,16 @@
|
|||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
<div class="flex items-center justify-between w-full h-16">
|
<div class="flex items-center justify-between w-full h-16">
|
||||||
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
||||||
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0 w-12 h-12" />
|
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0" />
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
|
|
||||||
<div id="menu" class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
|
<div id="menu" class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
|
||||||
<div class="h-16 flex justify-end lg:justify-around">
|
<div class="h-16 flex justify-end lg:justify-around">
|
||||||
<p class="text-xl font-bold dark:text-white text-right my-auto pr-4 lg:pr-0">Book Manager</p>
|
<p class="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">AnthoLume</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6">
|
<div>
|
||||||
<a
|
<a
|
||||||
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
||||||
href="/"
|
href="/"
|
||||||
@@ -167,6 +172,24 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="mx-4 text-sm font-normal">Documents</span>
|
<span class="mx-4 text-sm font-normal">Documents</span>
|
||||||
</a>
|
</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
|
<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}}"
|
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"
|
href="/activity"
|
||||||
@@ -209,12 +232,47 @@
|
|||||||
</a>
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
<a class="flex justify-center items-center p-6 w-full absolute bottom-0" target="_blank" href="https://gitea.va.reichard.io/evan/AnthoLume">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="text-black dark:text-white"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 219 92"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a"><path d="M159 .79h25V69h-25Zm0 0" /></clipPath>
|
||||||
|
<clipPath id="b"><path d="M183 9h35.371v60H183Zm0 0" /></clipPath>
|
||||||
|
<clipPath id="c"><path d="M0 .79h92V92H0Zm0 0" /></clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
||||||
|
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
|
||||||
|
/>
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path
|
||||||
|
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
||||||
|
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#b)">
|
||||||
|
<path
|
||||||
|
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
||||||
|
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#c)">
|
||||||
|
<path
|
||||||
|
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
||||||
|
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
|
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
|
||||||
<div
|
<div class="relative flex items-center justify-end w-full p-4 space-x-4">
|
||||||
class="relative flex items-center justify-end w-full p-4 space-x-4"
|
|
||||||
>
|
|
||||||
<a href="#" class="relative block">
|
<a href="#" class="relative block">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -245,7 +303,7 @@
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<span class="flex flex-col">
|
<span class="flex flex-col">
|
||||||
@@ -254,7 +312,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/logout"
|
href="/logout"
|
||||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<span class="flex flex-col">
|
<span class="flex flex-col">
|
||||||
@@ -286,9 +344,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main
|
<main class="relative overflow-hidden">
|
||||||
class="relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
|
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
{{ if .Data.Filepath }}
|
{{ if .Data.Filepath }}
|
||||||
<a
|
<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"
|
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>
|
>Read</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -67,7 +67,6 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
>Remove Cover</button>
|
>Remove Cover</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label for="delete-button">
|
<label for="delete-button">
|
||||||
@@ -323,12 +322,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
<input type="checkbox" id="progress-info-button" class="hidden css-button"/>
|
<input type="checkbox" id="progress-info-button" class="hidden css-button"/>
|
||||||
|
|
||||||
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
||||||
<div class="text-xs flex">
|
<div class="text-xs flex">
|
||||||
<p class="text-gray-400 w-32">Seconds / Page</p>
|
<p class="text-gray-400 w-32">Seconds / Percent</p>
|
||||||
<p class="font-medium dark:text-white">
|
<p class="font-medium dark:text-white">
|
||||||
{{ .Data.SecondsPerPage }}
|
{{ .Data.SecondsPerPercent }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs flex">
|
<div class="text-xs flex">
|
||||||
@@ -352,23 +350,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Progress</p>
|
<p class="text-gray-500">Progress</p>
|
||||||
<p class="font-medium text-lg">
|
<p class="font-medium text-lg">
|
||||||
{{ .Data.Page }} / {{ .Data.Pages }} ({{ .Data.Percentage }}%)
|
{{ .Data.Percentage }}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500">ISBN 10</p>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ or .Data.Isbn10 "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500">ISBN 13</p>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ or .Data.Isbn13 "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -7,6 +7,49 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row" action="./documents" method="GET">
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex relative">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect width="24" height="24" fill="none" />
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Search Author / Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
||||||
|
>
|
||||||
|
<span class="w-full">Search</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{range $doc := .Data }}
|
{{range $doc := .Data }}
|
||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
@@ -37,7 +80,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Progress</p>
|
<p class="text-gray-400">Progress</p>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
{{ $doc.Page }} / {{ $doc.Pages }} ({{ $doc.Percentage }}%)
|
{{ $doc.Percentage }}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +147,16 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||||
|
{{ if .PreviousPage }}
|
||||||
|
<a href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">◄</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .NextPage }}
|
||||||
|
<a href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">►</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center">
|
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center">
|
||||||
<input type="checkbox" id="upload-file-button" class="hidden css-button"/>
|
<input type="checkbox" id="upload-file-button" class="hidden css-button"/>
|
||||||
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2">
|
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<title>Book Manager - Error</title>
|
<title>AnthoLume - Error</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
@@ -30,29 +30,27 @@
|
|||||||
<body
|
<body
|
||||||
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"
|
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"
|
||||||
>
|
>
|
||||||
<section>
|
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
||||||
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
<div class="mx-auto max-w-screen-sm text-center">
|
||||||
<div class="mx-auto max-w-screen-sm text-center">
|
<h1
|
||||||
<h1
|
class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
|
||||||
class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
|
>
|
||||||
>
|
{{ .Status }}
|
||||||
{{ .Status }}
|
</h1>
|
||||||
</h1>
|
<p
|
||||||
<p
|
class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white"
|
||||||
class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white"
|
>
|
||||||
>
|
{{ .Error }}
|
||||||
{{ .Error }}
|
</p>
|
||||||
</p>
|
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">
|
||||||
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">
|
{{ .Message }}
|
||||||
{{ .Message }}
|
</p>
|
||||||
</p>
|
<a
|
||||||
<a
|
href="/"
|
||||||
href="/"
|
class="rounded text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
class="rounded text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
>Back to Homepage</a
|
||||||
>Back to Homepage</a
|
>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,10 +22,43 @@
|
|||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title>
|
<title>AnthoLume - {{if .Register}}Register{{else}}Login{{end}}</title>
|
||||||
|
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<link rel="stylesheet" href="./assets/style.css" />
|
<link rel="stylesheet" href="./assets/style.css" />
|
||||||
|
|
||||||
|
<!-- Service Worker / Offline Cache Flush -->
|
||||||
|
<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>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||||
<div class="flex flex-wrap w-full">
|
<div class="flex flex-wrap w-full">
|
||||||
@@ -108,9 +141,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{ if .RegistrationEnabled }}
|
|
||||||
<div class="pt-12 pb-12 text-center">
|
<div class="pt-12 pb-12 text-center">
|
||||||
{{ if .Register }}
|
{{ if .RegistrationEnabled }} {{ if .Register }}
|
||||||
<p>
|
<p>
|
||||||
Trying to login?
|
Trying to login?
|
||||||
<a href="./login" class="font-semibold underline">
|
<a href="./login" class="font-semibold underline">
|
||||||
@@ -124,9 +156,13 @@
|
|||||||
Register here.
|
Register here.
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}} {{ end }}
|
||||||
|
<p class="mt-4">
|
||||||
|
<a href="./local" class="font-semibold underline">
|
||||||
|
Offline / Local Mode
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -134,19 +170,19 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/book1.jpg"
|
src="/assets/images/book1.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/book2.jpg"
|
src="/assets/images/book2.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/book3.jpg"
|
src="/assets/images/book3.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -2,7 +2,7 @@ package utils
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestCalculatePartialPD5(t *testing.T) {
|
func TestCalculatePartialMD5(t *testing.T) {
|
||||||
partialMD5, err := CalculatePartialMD5("../_test_files/alice.epub")
|
partialMD5, err := CalculatePartialMD5("../_test_files/alice.epub")
|
||||||
|
|
||||||
want := "386d1cb51fe4a72e5c9fdad5e059bad9"
|
want := "386d1cb51fe4a72e5c9fdad5e059bad9"
|
||||||
|
|||||||