Compare commits
42 Commits
0.0.1
...
6c6a6dd329
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c6a6dd329 | |||
| c4602c8c3b | |||
| fe81b57a34 | |||
| a69b7452ce | |||
| 75ed394f8d | |||
| 803c187a00 | |||
| da1baeb4cd | |||
| 5865fe3c13 | |||
| 4a5464853b | |||
| 622dcd5702 | |||
| a86e2520ef | |||
| b1cfd16627 | |||
| 015ca30ac5 | |||
| 9792a6ff19 | |||
| 8c4c1022c3 | |||
| fd8b6bcdc1 | |||
| 0bbd5986cb | |||
| 45cef2f4af | |||
| e33a64db96 | |||
| 35ca021649 | |||
| 760b9ca0a0 | |||
| c9edcd8f5a | |||
| 2d63a7d109 | |||
| 9bd6bf7727 | |||
| f0a2d2cf69 | |||
| a65750ae21 | |||
| 14b930781e | |||
| 8a8f12c07a | |||
| c5b181dda4 | |||
| d3d89b36f6 | |||
| a69f20d5a9 | |||
| c66a6c8499 | |||
| 3057b86002 | |||
| 2c240f2f5c | |||
| 39fd7ab1f1 | |||
| e9f2e3a5a0 | |||
| a34906c266 | |||
| 756db7a493 | |||
| bb837dd30e | |||
| e823a794cf | |||
| 3c6f3ae237 | |||
| ca1cce1ff1 |
14
.djlintrc
Normal file
14
.djlintrc
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"profile": "golang",
|
||||||
|
"indent": 2,
|
||||||
|
"close_void_tags": true,
|
||||||
|
"format_attribute_template_tags": true,
|
||||||
|
"format_js": true,
|
||||||
|
"js": {
|
||||||
|
"indent_size": 2
|
||||||
|
},
|
||||||
|
"format_css": true,
|
||||||
|
"css": {
|
||||||
|
"indent_size": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
21
.drone.yml
21
.drone.yml
@@ -4,24 +4,19 @@ name: default
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Unit Tests
|
# Unit Tests
|
||||||
- name: unit test
|
- name: tests
|
||||||
image: golang
|
image: golang
|
||||||
commands:
|
commands:
|
||||||
- make tests_unit
|
- make tests
|
||||||
|
|
||||||
# Integration Tests (Every Month)
|
# Fetch tags
|
||||||
- name: integration test
|
- name: fetch tags
|
||||||
image: golang
|
image: alpine/git
|
||||||
commands:
|
commands:
|
||||||
- make tests_integration
|
- git fetch --tags
|
||||||
when:
|
|
||||||
event:
|
|
||||||
- cron
|
|
||||||
cron:
|
|
||||||
- integration-test
|
|
||||||
|
|
||||||
# Publish Dev Docker Image
|
# Publish docker image
|
||||||
- name: publish_docker
|
- name: publish docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: gitea.va.reichard.io/evan/antholume
|
repo: gitea.va.reichard.io/evan/antholume
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
|||||||
|
TODO.md
|
||||||
.DS_Store
|
.DS_Store
|
||||||
data/
|
data/
|
||||||
build/
|
build/
|
||||||
.direnv/
|
.direnv/
|
||||||
|
cover.html
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -3,19 +3,19 @@ FROM alpine AS certs
|
|||||||
RUN apk update && apk add ca-certificates
|
RUN apk update && apk add ca-certificates
|
||||||
|
|
||||||
# Build Image
|
# Build Image
|
||||||
FROM golang:1.20 AS build
|
FROM golang:1.21 AS build
|
||||||
|
|
||||||
|
# Create Package Directory
|
||||||
|
RUN mkdir -p /opt/antholume
|
||||||
|
|
||||||
# Copy Source
|
# Copy Source
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create Package Directory
|
|
||||||
RUN mkdir -p /opt/antholume
|
|
||||||
|
|
||||||
# Compile
|
# Compile
|
||||||
RUN go build -o /opt/antholume/server; \
|
RUN go build \
|
||||||
cp -a ./templates /opt/antholume/templates; \
|
-ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" \
|
||||||
cp -a ./assets /opt/antholume/assets;
|
-o /opt/antholume/server
|
||||||
|
|
||||||
# Create Image
|
# Create Image
|
||||||
FROM busybox:1.36
|
FROM busybox:1.36
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ FROM alpine AS certs
|
|||||||
RUN apk update && apk add ca-certificates
|
RUN apk update && apk add ca-certificates
|
||||||
|
|
||||||
# Build Image
|
# Build Image
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.20 AS build
|
FROM --platform=$BUILDPLATFORM golang:1.21 AS build
|
||||||
|
|
||||||
# Create Package Directory
|
# Create Package Directory
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -15,9 +15,9 @@ 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/antholume/server; \
|
GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
||||||
cp -a ./templates /opt/antholume/templates; \
|
-ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" \
|
||||||
cp -a ./assets /opt/antholume/assets;
|
-o /opt/antholume/server
|
||||||
|
|
||||||
# Create Image
|
# Create Image
|
||||||
FROM busybox:1.36
|
FROM busybox:1.36
|
||||||
|
|||||||
30
Makefile
30
Makefile
@@ -1,14 +1,12 @@
|
|||||||
build_local: build_tailwind
|
build_local: build_tailwind
|
||||||
go mod download
|
go mod download
|
||||||
rm -r ./build
|
rm -r ./build || true
|
||||||
mkdir -p ./build
|
mkdir -p ./build
|
||||||
cp -a ./templates ./build/templates
|
|
||||||
cp -a ./assets ./build/assets
|
|
||||||
|
|
||||||
env GOOS=linux GOARCH=amd64 go build -o ./build/server_linux_amd64
|
env GOOS=linux GOARCH=amd64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_linux_amd64
|
||||||
env GOOS=linux GOARCH=arm64 go build -o ./build/server_linux_arm64
|
env GOOS=linux GOARCH=arm64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_linux_arm64
|
||||||
env GOOS=darwin GOARCH=arm64 go build -o ./build/server_darwin_arm64
|
env GOOS=darwin GOARCH=arm64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_arm64
|
||||||
env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64
|
env GOOS=darwin GOARCH=amd64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_amd64
|
||||||
|
|
||||||
docker_build_local: build_tailwind
|
docker_build_local: build_tailwind
|
||||||
docker build -t antholume:latest .
|
docker build -t antholume:latest .
|
||||||
@@ -31,12 +29,20 @@ docker_build_release_latest: build_tailwind
|
|||||||
build_tailwind:
|
build_tailwind:
|
||||||
tailwind build -o ./assets/style.css --minify
|
tailwind build -o ./assets/style.css --minify
|
||||||
|
|
||||||
|
dev: build_tailwind
|
||||||
|
GIN_MODE=release \
|
||||||
|
CONFIG_PATH=./data \
|
||||||
|
DATA_PATH=./data \
|
||||||
|
SEARCH_ENABLED=true \
|
||||||
|
REGISTRATION_ENABLED=true \
|
||||||
|
COOKIE_SECURE=false \
|
||||||
|
COOKIE_AUTH_KEY=1234 \
|
||||||
|
LOG_LEVEL=debug go run main.go serve
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf ./build
|
rm -rf ./build
|
||||||
|
|
||||||
tests_integration:
|
tests:
|
||||||
go test -v -tags=integration -coverpkg=./... ./metadata
|
SET_TEST=set_val go test -coverpkg=./... ./... -coverprofile=./cover.out
|
||||||
|
go tool cover -html=./cover.out -o ./cover.html
|
||||||
tests_unit:
|
rm ./cover.out
|
||||||
SET_TEST=set_val go test -v -coverpkg=./... ./...
|
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -64,6 +64,8 @@ The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds`
|
|||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
|
**NOTE**: If you're accessing your instance over HTTP (not HTTPS), you must set `COOKIE_SECURE=false`, otherwise you will not be able to login.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Make Data Directory
|
# Make Data Directory
|
||||||
mkdir -p antholume_data
|
mkdir -p antholume_data
|
||||||
@@ -71,6 +73,7 @@ mkdir -p antholume_data
|
|||||||
# Run Server
|
# Run Server
|
||||||
docker run \
|
docker run \
|
||||||
-p 8585:8585 \
|
-p 8585:8585 \
|
||||||
|
-e COOKIE_SECURE=false \
|
||||||
-e REGISTRATION_ENABLED=true \
|
-e REGISTRATION_ENABLED=true \
|
||||||
-v ./antholume_data:/config \
|
-v ./antholume_data:/config \
|
||||||
-v ./antholume_data:/data \
|
-v ./antholume_data:/data \
|
||||||
@@ -81,17 +84,19 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
|
|||||||
|
|
||||||
### 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 | antholume | The database name, or in SQLite's case, the filename |
|
| DATABASE_NAME | antholume | The database name, or in SQLite's case, the filename |
|
||||||
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
||||||
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
||||||
| LISTEN_PORT | 8585 | Port the server listens at |
|
| LISTEN_PORT | 8585 | Port the server listens at |
|
||||||
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
| LOG_LEVEL | info | Set server log level |
|
||||||
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
||||||
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
| COOKIE_AUTH_KEY | <EMPTY> | Optional secret cookie authentication key (auto generated if not provided) |
|
||||||
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
|
| COOKIE_ENC_KEY | <EMPTY> | Optional secret cookie encryption key (16 or 32 bytes) |
|
||||||
|
| 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) |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -120,6 +125,12 @@ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
|||||||
~/go/bin/sqlc generate
|
~/go/bin/sqlc generate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Goose Migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||||
|
```
|
||||||
|
|
||||||
Run Development:
|
Run Development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
363
api/api.go
363
api/api.go
@@ -1,9 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"context"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/multitemplate"
|
"github.com/gin-contrib/multitemplate"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -11,170 +16,312 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/bbank/config"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type API struct {
|
type API struct {
|
||||||
Router *gin.Engine
|
db *database.DBManager
|
||||||
Config *config.Config
|
cfg *config.Config
|
||||||
DB *database.DBManager
|
assets fs.FS
|
||||||
HTMLPolicy *bluemonday.Policy
|
httpServer *http.Server
|
||||||
|
templates map[string]*template.Template
|
||||||
|
userAuthCache map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApi(db *database.DBManager, c *config.Config) *API {
|
var htmlPolicy = bluemonday.StrictPolicy()
|
||||||
|
|
||||||
|
func NewApi(db *database.DBManager, c *config.Config, assets fs.FS) *API {
|
||||||
api := &API{
|
api := &API{
|
||||||
HTMLPolicy: bluemonday.StrictPolicy(),
|
db: db,
|
||||||
Router: gin.Default(),
|
cfg: c,
|
||||||
Config: c,
|
assets: assets,
|
||||||
DB: db,
|
userAuthCache: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assets & Web App Templates
|
// Create router
|
||||||
api.Router.Static("/assets", "./assets")
|
router := gin.New()
|
||||||
|
|
||||||
// Generate Secure Token
|
// Add server
|
||||||
|
api.httpServer = &http.Server{
|
||||||
|
Handler: router,
|
||||||
|
Addr: (":" + c.ListenPort),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add global logging middleware
|
||||||
|
router.Use(loggingMiddleware)
|
||||||
|
|
||||||
|
// Add global template loader middleware (develop)
|
||||||
|
if c.Version == "develop" {
|
||||||
|
log.Info("utilizing debug template loader")
|
||||||
|
router.Use(api.templateMiddleware(router))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets & web app templates
|
||||||
|
assetsDir, _ := fs.Sub(assets, "assets")
|
||||||
|
router.StaticFS("/assets", http.FS(assetsDir))
|
||||||
|
|
||||||
|
// Generate auth token
|
||||||
var newToken []byte
|
var newToken []byte
|
||||||
var err error
|
var err error
|
||||||
|
if c.CookieAuthKey != "" {
|
||||||
if c.CookieSessionKey != "" {
|
log.Info("utilizing environment cookie auth key")
|
||||||
log.Info("[NewApi] Utilizing Environment Cookie Session Key")
|
newToken = []byte(c.CookieAuthKey)
|
||||||
newToken = []byte(c.CookieSessionKey)
|
|
||||||
} else {
|
} else {
|
||||||
log.Info("[NewApi] Generating Cookie Session Key")
|
log.Info("generating cookie auth key")
|
||||||
newToken, err = generateToken(64)
|
newToken, err = utils.GenerateToken(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Unable to generate secure token")
|
log.Panic("unable to generate cookie auth key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Cookie Session Store
|
// Set enc token
|
||||||
store := cookie.NewStore(newToken)
|
store := cookie.NewStore(newToken)
|
||||||
|
if c.CookieEncKey != "" {
|
||||||
|
if len(c.CookieEncKey) == 16 || len(c.CookieEncKey) == 32 {
|
||||||
|
log.Info("utilizing environment cookie encryption key")
|
||||||
|
store = cookie.NewStore(newToken, []byte(c.CookieEncKey))
|
||||||
|
} else {
|
||||||
|
log.Panic("invalid cookie encryption key (must be 16 or 32 bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure cookie session store
|
||||||
store.Options(sessions.Options{
|
store.Options(sessions.Options{
|
||||||
MaxAge: 60 * 60 * 24 * 7,
|
MaxAge: 60 * 60 * 24 * 7,
|
||||||
Secure: c.CookieSecure,
|
Secure: c.CookieSecure,
|
||||||
HttpOnly: c.CookieHTTPOnly,
|
HttpOnly: c.CookieHTTPOnly,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
api.Router.Use(sessions.Sessions("token", store))
|
router.Use(sessions.Sessions("token", store))
|
||||||
|
|
||||||
// Register Web App Route
|
// Register web app route
|
||||||
api.registerWebAppRoutes()
|
api.registerWebAppRoutes(router)
|
||||||
|
|
||||||
// Register API Routes
|
// Register API routes
|
||||||
apiGroup := api.Router.Group("/api")
|
apiGroup := router.Group("/api")
|
||||||
api.registerKOAPIRoutes(apiGroup)
|
api.registerKOAPIRoutes(apiGroup)
|
||||||
api.registerOPDSRoutes(apiGroup)
|
api.registerOPDSRoutes(apiGroup)
|
||||||
|
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) registerWebAppRoutes() {
|
func (api *API) Start() error {
|
||||||
// Define Templates & Helper Functions
|
return api.httpServer.ListenAndServe()
|
||||||
render := multitemplate.NewRenderer()
|
}
|
||||||
helperFuncs := template.FuncMap{
|
|
||||||
"GetSVGGraphData": getSVGGraphData,
|
func (api *API) Stop() error {
|
||||||
"GetUTCOffsets": getUTCOffsets,
|
// Stop server
|
||||||
"NiceSeconds": niceSeconds,
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := api.httpServer.Shutdown(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Templates
|
// Close DB
|
||||||
render.AddFromFiles("error", "templates/error.html")
|
return api.db.DB.Close()
|
||||||
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
|
}
|
||||||
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
|
|
||||||
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
|
|
||||||
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
|
||||||
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
|
||||||
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
|
|
||||||
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
|
||||||
|
|
||||||
api.Router.HTMLRender = render
|
func (api *API) registerWebAppRoutes(router *gin.Engine) {
|
||||||
|
// Generate templates
|
||||||
|
router.HTMLRender = *api.generateTemplates()
|
||||||
|
|
||||||
// Static Assets (Required @ Root)
|
// Static assets (required @ root)
|
||||||
api.Router.GET("/manifest.json", api.webManifest)
|
router.GET("/manifest.json", api.appWebManifest)
|
||||||
api.Router.GET("/sw.js", api.serviceWorker)
|
router.GET("/favicon.ico", api.appFaviconIcon)
|
||||||
|
router.GET("/sw.js", api.appServiceWorker)
|
||||||
|
|
||||||
// Local / Offline Static Pages (No Template, No Auth)
|
// Local / offline static pages (no template, no auth)
|
||||||
api.Router.GET("/local", api.localDocuments)
|
router.GET("/local", api.appLocalDocuments)
|
||||||
api.Router.GET("/reader", api.documentReader)
|
|
||||||
|
|
||||||
// Web App
|
// Reader (reader page, document progress, devices)
|
||||||
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
router.GET("/reader", api.appDocumentReader)
|
||||||
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
|
||||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
|
||||||
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("/logout", api.authWebAppMiddleware, api.authLogout)
|
|
||||||
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
|
|
||||||
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
|
|
||||||
api.Router.POST("/login", api.authFormLogin)
|
|
||||||
api.Router.POST("/register", api.authFormRegister)
|
|
||||||
|
|
||||||
// Demo Mode Enabled Configuration
|
// Web app
|
||||||
if api.Config.DemoMode {
|
router.GET("/", api.authWebAppMiddleware, api.appGetHome)
|
||||||
api.Router.POST("/documents", api.authWebAppMiddleware, api.demoModeAppError)
|
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
|
||||||
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.demoModeAppError)
|
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
|
||||||
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.demoModeAppError)
|
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
|
||||||
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.demoModeAppError)
|
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
|
||||||
api.Router.POST("/settings", api.authWebAppMiddleware, api.demoModeAppError)
|
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
|
||||||
|
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage))
|
||||||
|
router.GET("/login", api.appGetLogin)
|
||||||
|
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
|
||||||
|
router.GET("/register", api.appGetRegister)
|
||||||
|
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
|
||||||
|
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
|
||||||
|
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
|
||||||
|
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
|
||||||
|
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
|
||||||
|
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
|
||||||
|
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
|
||||||
|
router.POST("/login", api.appAuthLogin)
|
||||||
|
router.POST("/register", api.appAuthRegister)
|
||||||
|
|
||||||
|
// Demo mode enabled configuration
|
||||||
|
if api.cfg.DemoMode {
|
||||||
|
router.POST("/documents", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
|
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
|
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
|
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
|
||||||
} else {
|
} else {
|
||||||
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
|
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
|
||||||
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
|
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
|
||||||
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
|
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
|
||||||
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
|
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
|
||||||
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
|
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Enabled Configuration
|
// Search enabled configuration
|
||||||
if api.Config.SearchEnabled {
|
if api.cfg.SearchEnabled {
|
||||||
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
|
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
|
||||||
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
|
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
|
||||||
koGroup := apiGroup.Group("/ko")
|
koGroup := apiGroup.Group("/ko")
|
||||||
|
|
||||||
// KO Sync Routes (WebApp Uses - Progress & Activity)
|
// KO sync routes (webapp uses - progress & activity)
|
||||||
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
|
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.createDownloadDocumentHandler(apiErrorPage))
|
||||||
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.getProgress)
|
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.koGetProgress)
|
||||||
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
|
koGroup.GET("/users/auth", api.authKOMiddleware, api.koAuthorizeUser)
|
||||||
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
|
koGroup.POST("/activity", api.authKOMiddleware, api.koAddActivities)
|
||||||
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
|
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.koCheckActivitySync)
|
||||||
koGroup.POST("/users/create", api.createUser)
|
koGroup.POST("/users/create", api.koAuthRegister)
|
||||||
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
|
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.koSetProgress)
|
||||||
|
|
||||||
// Demo Mode Enabled Configuration
|
// Demo mode enabled configuration
|
||||||
if api.Config.DemoMode {
|
if api.cfg.DemoMode {
|
||||||
koGroup.POST("/documents", api.authKOMiddleware, api.demoModeJSONError)
|
koGroup.POST("/documents", api.authKOMiddleware, api.koDemoModeJSONError)
|
||||||
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.demoModeJSONError)
|
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koDemoModeJSONError)
|
||||||
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.demoModeJSONError)
|
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koDemoModeJSONError)
|
||||||
} else {
|
} else {
|
||||||
koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments)
|
koGroup.POST("/documents", api.authKOMiddleware, api.koAddDocuments)
|
||||||
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
|
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koCheckDocumentsSync)
|
||||||
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
|
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koUploadExistingDocument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
|
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
|
||||||
opdsGroup := apiGroup.Group("/opds")
|
opdsGroup := apiGroup.Group("/opds")
|
||||||
|
|
||||||
// OPDS Routes
|
// OPDS routes
|
||||||
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments)
|
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsEntry)
|
||||||
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
|
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsEntry)
|
||||||
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)
|
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
|
||||||
|
opdsGroup.GET("/documents", api.authOPDSMiddleware, api.opdsDocuments)
|
||||||
|
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.createGetCoverHandler(apiErrorPage))
|
||||||
|
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.createDownloadDocumentHandler(apiErrorPage))
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateToken(n int) ([]byte, error) {
|
func (api *API) generateTemplates() *multitemplate.Renderer {
|
||||||
b := make([]byte, n)
|
// Define templates & helper functions
|
||||||
_, err := rand.Read(b)
|
templates := make(map[string]*template.Template)
|
||||||
if err != nil {
|
render := multitemplate.NewRenderer()
|
||||||
return nil, err
|
helperFuncs := template.FuncMap{
|
||||||
|
"dict": dict,
|
||||||
|
"fields": fields,
|
||||||
|
"getSVGGraphData": getSVGGraphData,
|
||||||
|
"getUTCOffsets": getUTCOffsets,
|
||||||
|
"hasPrefix": strings.HasPrefix,
|
||||||
|
"niceNumbers": niceNumbers,
|
||||||
|
"niceSeconds": niceSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load base
|
||||||
|
b, _ := fs.ReadFile(api.assets, "templates/base.tmpl")
|
||||||
|
baseTemplate := template.Must(template.New("base").Funcs(helperFuncs).Parse(string(b)))
|
||||||
|
|
||||||
|
// Load SVGs
|
||||||
|
svgs, _ := fs.ReadDir(api.assets, "templates/svgs")
|
||||||
|
for _, item := range svgs {
|
||||||
|
basename := item.Name()
|
||||||
|
path := fmt.Sprintf("templates/svgs/%s", basename)
|
||||||
|
name := strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||||
|
|
||||||
|
b, _ := fs.ReadFile(api.assets, path)
|
||||||
|
baseTemplate = template.Must(baseTemplate.New("svg/" + name).Parse(string(b)))
|
||||||
|
templates["svg/"+name] = baseTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load components
|
||||||
|
components, _ := fs.ReadDir(api.assets, "templates/components")
|
||||||
|
for _, item := range components {
|
||||||
|
basename := item.Name()
|
||||||
|
path := fmt.Sprintf("templates/components/%s", basename)
|
||||||
|
name := strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||||
|
|
||||||
|
// Clone Base Template
|
||||||
|
b, _ := fs.ReadFile(api.assets, path)
|
||||||
|
baseTemplate = template.Must(baseTemplate.New("component/" + name).Parse(string(b)))
|
||||||
|
render.Add("component/"+name, baseTemplate)
|
||||||
|
templates["component/"+name] = baseTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load pages
|
||||||
|
pages, _ := fs.ReadDir(api.assets, "templates/pages")
|
||||||
|
for _, item := range pages {
|
||||||
|
basename := item.Name()
|
||||||
|
path := fmt.Sprintf("templates/pages/%s", basename)
|
||||||
|
name := strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||||
|
|
||||||
|
// Clone Base Template
|
||||||
|
b, _ := fs.ReadFile(api.assets, path)
|
||||||
|
pageTemplate, _ := template.Must(baseTemplate.Clone()).New("page/" + name).Parse(string(b))
|
||||||
|
render.Add("page/"+name, pageTemplate)
|
||||||
|
templates["page/"+name] = pageTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
api.templates = templates
|
||||||
|
|
||||||
|
return &render
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggingMiddleware(c *gin.Context) {
|
||||||
|
// Start timer
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Process request
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// End timer
|
||||||
|
endTime := time.Now()
|
||||||
|
latency := endTime.Sub(startTime).Round(time.Microsecond)
|
||||||
|
|
||||||
|
// Log data
|
||||||
|
logData := log.Fields{
|
||||||
|
"type": "access",
|
||||||
|
"ip": c.ClientIP(),
|
||||||
|
"latency": fmt.Sprintf("%s", latency),
|
||||||
|
"status": c.Writer.Status(),
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get username
|
||||||
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log user
|
||||||
|
if auth.UserName != "" {
|
||||||
|
logData["user"] = auth.UserName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log result
|
||||||
|
log.WithFields(logData).Info(fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) templateMiddleware(router *gin.Engine) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
router.HTMLRender = *api.generateTemplates()
|
||||||
|
c.Next()
|
||||||
}
|
}
|
||||||
return b, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
590
api/app-admin-routes.go
Normal file
590
api/app-admin-routes.go
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/itchyny/gojq"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminBackup adminAction = "BACKUP"
|
||||||
|
adminRestore adminAction = "RESTORE"
|
||||||
|
adminMetadataMatch adminAction = "METADATA_MATCH"
|
||||||
|
adminCacheTables adminAction = "CACHE_TABLES"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestAdminAction struct {
|
||||||
|
Action adminAction `form:"action"`
|
||||||
|
|
||||||
|
// Backup Action
|
||||||
|
BackupTypes []backupType `form:"backup_types"`
|
||||||
|
|
||||||
|
// Restore Action
|
||||||
|
RestoreFile *multipart.FileHeader `form:"restore_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
importDirect importType = "DIRECT"
|
||||||
|
importCopy importType = "COPY"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestAdminImport struct {
|
||||||
|
Directory string `form:"directory"`
|
||||||
|
Select string `form:"select"`
|
||||||
|
Type importType `form:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestAdminLogs struct {
|
||||||
|
Filter string `form:"filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appPerformAdminAction(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
|
||||||
|
var rAdminAction requestAdminAction
|
||||||
|
if err := c.ShouldBind(&rAdminAction); err != nil {
|
||||||
|
log.Error("Invalid Form Bind: ", err)
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO - Messages
|
||||||
|
switch rAdminAction.Action {
|
||||||
|
case adminMetadataMatch:
|
||||||
|
// TODO
|
||||||
|
// 1. Documents xref most recent metadata table?
|
||||||
|
// 2. Select all / deselect?
|
||||||
|
case adminCacheTables:
|
||||||
|
go api.db.CacheTempTables()
|
||||||
|
// TODO - Message
|
||||||
|
case adminRestore:
|
||||||
|
api.processRestoreFile(rAdminAction, c)
|
||||||
|
return
|
||||||
|
case adminBackup:
|
||||||
|
// Vacuum
|
||||||
|
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Headers
|
||||||
|
c.Header("Content-type", "application/octet-stream")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
|
||||||
|
|
||||||
|
// Stream Backup ZIP Archive
|
||||||
|
c.Stream(func(w io.Writer) bool {
|
||||||
|
var directories []string
|
||||||
|
for _, item := range rAdminAction.BackupTypes {
|
||||||
|
if item == backupCovers {
|
||||||
|
directories = append(directories, "covers")
|
||||||
|
} else if item == backupDocuments {
|
||||||
|
directories = append(directories, "documents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := api.createBackup(w, directories)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Backup Error: ", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdmin(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin", c)
|
||||||
|
c.HTML(http.StatusOK, "page/admin", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdminLogs(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-logs", c)
|
||||||
|
|
||||||
|
var rAdminLogs requestAdminLogs
|
||||||
|
if err := c.ShouldBindQuery(&rAdminLogs); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid URI parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rAdminLogs.Filter = strings.TrimSpace(rAdminLogs.Filter)
|
||||||
|
|
||||||
|
var jqFilter *gojq.Code
|
||||||
|
if rAdminLogs.Filter != "" {
|
||||||
|
parsed, err := gojq.Parse(rAdminLogs.Filter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to parse JQ filter")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to parse JQ filter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jqFilter, err = gojq.Compile(parsed)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to compile JQ filter")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to compile JQ filter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Log File
|
||||||
|
logPath := filepath.Join(api.cfg.ConfigPath, "logs/antholume.log")
|
||||||
|
logFile, err := os.Open(logPath)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Missing AnthoLume log file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
|
||||||
|
// Log Lines
|
||||||
|
var logLines []string
|
||||||
|
scanner := bufio.NewScanner(logFile)
|
||||||
|
for scanner.Scan() {
|
||||||
|
rawLog := scanner.Text()
|
||||||
|
|
||||||
|
// Attempt JSON Pretty
|
||||||
|
var jsonMap map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(rawLog), &jsonMap)
|
||||||
|
if err != nil {
|
||||||
|
logLines = append(logLines, scanner.Text())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
rawData, err := json.MarshalIndent(jsonMap, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
logLines = append(logLines, scanner.Text())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Filter
|
||||||
|
if jqFilter == nil {
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error or nil
|
||||||
|
result, _ := jqFilter.Run(jsonMap).Next()
|
||||||
|
if _, ok := result.(error); ok {
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
continue
|
||||||
|
} else if result == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt filtered json
|
||||||
|
filteredData, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err == nil {
|
||||||
|
rawData = filteredData
|
||||||
|
}
|
||||||
|
|
||||||
|
logLines = append(logLines, string(rawData))
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = logLines
|
||||||
|
templateVars["Filter"] = rAdminLogs.Filter
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-logs", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdminUsers(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
|
||||||
|
|
||||||
|
users, err := api.db.Queries.GetUsers(api.db.Ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUsers DB Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["Data"] = users
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-users", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appGetAdminImport(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
|
||||||
|
|
||||||
|
var rImportFolder requestAdminImport
|
||||||
|
if err := c.ShouldBindQuery(&rImportFolder); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rImportFolder.Select != "" {
|
||||||
|
templateVars["SelectedDirectory"] = rImportFolder.Select
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Path
|
||||||
|
if rImportFolder.Directory == "" {
|
||||||
|
dPath, err := filepath.Abs(api.cfg.DataPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Absolute filepath error: ", rImportFolder.Directory)
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Unable to get data directory absolute path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rImportFolder.Directory = dPath
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(rImportFolder.Directory)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Invalid directory: ", rImportFolder.Directory)
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allDirectories := []string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allDirectories = append(allDirectories, e.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
templateVars["CurrentPath"] = filepath.Clean(rImportFolder.Directory)
|
||||||
|
templateVars["Data"] = allDirectories
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appPerformAdminImport(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
|
||||||
|
|
||||||
|
var rAdminImport requestAdminImport
|
||||||
|
if err := c.ShouldBind(&rAdminImport); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
appErrorPage(c, http.StatusNotFound, "Invalid directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO - Store results for approval?
|
||||||
|
|
||||||
|
// Walk import directory & copy or import files
|
||||||
|
importDirectory := filepath.Clean(rAdminImport.Directory)
|
||||||
|
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
fileMeta, err := metadata.GetMetadata(currentPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("metadata error: %v\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only needed if copying
|
||||||
|
newName := deriveBaseFileName(fileMeta)
|
||||||
|
|
||||||
|
// Open File on Disk
|
||||||
|
// file, err := os.Open(currentPath)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// defer file.Close()
|
||||||
|
|
||||||
|
// TODO - BasePath in DB
|
||||||
|
// TODO - Copy / Import
|
||||||
|
|
||||||
|
fmt.Printf("New File Metadata: %s\n", newName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "page/admin-import", templateVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
|
||||||
|
// Validate Type & Derive Extension on MIME
|
||||||
|
uploadedFile, err := rAdminAction.RestoreFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to open file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileMime, err := mimetype.DetectReader(uploadedFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("MIME Error")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to detect filetype")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileExtension := fileMime.Extension()
|
||||||
|
|
||||||
|
// Validate Extension
|
||||||
|
if !slices.Contains([]string{".zip"}, fileExtension) {
|
||||||
|
log.Error("Invalid FileType: ", fileExtension)
|
||||||
|
appErrorPage(c, http.StatusBadRequest, "Invalid filetype")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Temp File
|
||||||
|
tempFile, err := os.CreateTemp("", "restore")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Temp File Create Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to create temp file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Save Temp
|
||||||
|
err = c.SaveUploadedFile(rAdminAction.RestoreFile, tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to save file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP Info
|
||||||
|
fileInfo, err := tempFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ZIP Reader
|
||||||
|
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ZIP Error: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to read zip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ZIP Contents
|
||||||
|
hasDBFile := false
|
||||||
|
hasUnknownFile := false
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
fileName := strings.TrimPrefix(file.Name, "/")
|
||||||
|
if fileName == "antholume.db" {
|
||||||
|
hasDBFile = true
|
||||||
|
break
|
||||||
|
} else if !strings.HasPrefix(fileName, "covers/") && !strings.HasPrefix(fileName, "documents/") {
|
||||||
|
hasUnknownFile = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid ZIP
|
||||||
|
if !hasDBFile {
|
||||||
|
log.Error("Invalid ZIP File - Missing DB")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Missing DB")
|
||||||
|
return
|
||||||
|
} else if hasUnknownFile {
|
||||||
|
log.Error("Invalid ZIP File - Invalid File(s)")
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Invalid File(s)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Backup File
|
||||||
|
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
|
||||||
|
backupFile, err := os.Create(backupFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to create backup file: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to create backup file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer backupFile.Close()
|
||||||
|
|
||||||
|
// Vacuum DB
|
||||||
|
_, err = api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to vacuum DB: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Backup File
|
||||||
|
w := bufio.NewWriter(backupFile)
|
||||||
|
err = api.createBackup(w, []string{"covers", "documents"})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to save backup file: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Data
|
||||||
|
err = api.removeData()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to delete data: ", err)
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to delete data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore Data
|
||||||
|
err = api.restoreData(zipReader)
|
||||||
|
if err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to restore data")
|
||||||
|
log.Panic("Unable to restore data: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinit DB
|
||||||
|
if err := api.db.Reload(); err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to reload DB")
|
||||||
|
log.Panicf("Unable to reload DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate Auth Hashes
|
||||||
|
if err := api.rotateAllAuthHashes(); err != nil {
|
||||||
|
appErrorPage(c, http.StatusInternalServerError, "Unable to rotate hashes")
|
||||||
|
log.Panicf("Unable to rotate auth hashes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore all data
|
||||||
|
func (api *API) restoreData(zipReader *zip.Reader) error {
|
||||||
|
// Ensure Directories
|
||||||
|
api.cfg.EnsureDirectories()
|
||||||
|
|
||||||
|
// Restore Data
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
destPath := filepath.Join(api.cfg.DataPath, file.Name)
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating destination file:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy the contents from the zip file to the destination file.
|
||||||
|
if _, err := io.Copy(destFile, rc); err != nil {
|
||||||
|
fmt.Println("Error copying file contents:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all data
|
||||||
|
func (api *API) removeData() error {
|
||||||
|
allPaths := []string{
|
||||||
|
"covers",
|
||||||
|
"documents",
|
||||||
|
"antholume.db",
|
||||||
|
"antholume.db-wal",
|
||||||
|
"antholume.db-shm",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range allPaths {
|
||||||
|
fullPath := filepath.Join(api.cfg.DataPath, name)
|
||||||
|
err := os.RemoveAll(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to delete %s: %v", name, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup all data
|
||||||
|
func (api *API) createBackup(w io.Writer, directories []string) error {
|
||||||
|
ar := zip.NewWriter(w)
|
||||||
|
|
||||||
|
exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open File on Disk
|
||||||
|
file, err := os.Open(currentPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Derive Export Structure
|
||||||
|
fileName := filepath.Base(currentPath)
|
||||||
|
folderName := filepath.Base(filepath.Dir(currentPath))
|
||||||
|
|
||||||
|
// Create File in Export
|
||||||
|
newF, err := ar.Create(filepath.Join(folderName, fileName))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy File in Export
|
||||||
|
_, err = io.Copy(newF, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get DB Path
|
||||||
|
fileName := fmt.Sprintf("%s.db", api.cfg.DBName)
|
||||||
|
dbLocation := filepath.Join(api.cfg.ConfigPath, fileName)
|
||||||
|
|
||||||
|
// Copy Database File
|
||||||
|
dbFile, err := os.Open(dbLocation)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
newDbFile, err := ar.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Copy(newDbFile, dbFile)
|
||||||
|
|
||||||
|
// Backup Covers & Documents
|
||||||
|
for _, dir := range directories {
|
||||||
|
err = filepath.WalkDir(filepath.Join(api.cfg.DataPath, dir), exportWalker)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ar.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1108
api/app-routes.go
1108
api/app-routes.go
File diff suppressed because it is too large
Load Diff
389
api/auth.go
389
api/auth.go
@@ -11,9 +11,17 @@ import (
|
|||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Authorization Data
|
||||||
|
type authData struct {
|
||||||
|
UserName string
|
||||||
|
IsAdmin bool
|
||||||
|
AuthHash string
|
||||||
|
}
|
||||||
|
|
||||||
// KOSync API Auth Headers
|
// KOSync API Auth Headers
|
||||||
type authKOHeader struct {
|
type authKOHeader struct {
|
||||||
AuthUser string `header:"x-auth-user"`
|
AuthUser string `header:"x-auth-user"`
|
||||||
@@ -25,25 +33,32 @@ type authOPDSHeader struct {
|
|||||||
Authorization string `header:"authorization"`
|
Authorization string `header:"authorization"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeCredentials(username string, password string) (authorized bool) {
|
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
|
||||||
user, err := api.DB.Queries.GetUser(api.DB.Ctx, username)
|
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
|
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
// Update Auth Cache
|
||||||
|
api.userAuthCache[user.ID] = *user.AuthHash
|
||||||
|
|
||||||
|
return &authData{
|
||||||
|
UserName: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
AuthHash: *user.AuthHash,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authKOMiddleware(c *gin.Context) {
|
func (api *API) authKOMiddleware(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session First
|
// Check Session First
|
||||||
if user, ok := getSession(session); ok == true {
|
if auth, ok := api.getSession(session); ok == true {
|
||||||
c.Set("AuthorizedUser", user)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -61,17 +76,18 @@ func (api *API) authKOMiddleware(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorized := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey); authorized != true {
|
authData := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey)
|
||||||
|
if authData == nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := setSession(session, rHeader.AuthUser); err != nil {
|
if err := api.setSession(session, *authData); err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("AuthorizedUser", rHeader.AuthUser)
|
c.Set("Authorization", *authData)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -89,12 +105,13 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate Auth
|
// Validate Auth
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
if authorized := api.authorizeCredentials(user, password); authorized != true {
|
authData := api.authorizeCredentials(user, password)
|
||||||
|
if authData == nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("AuthorizedUser", user)
|
c.Set("Authorization", *authData)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -103,8 +120,8 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
|
||||||
// Check Session
|
// Check Session
|
||||||
if user, ok := getSession(session); ok == true {
|
if auth, ok := api.getSession(session); ok == true {
|
||||||
c.Set("AuthorizedUser", user)
|
c.Set("Authorization", auth)
|
||||||
c.Header("Cache-Control", "private")
|
c.Header("Cache-Control", "private")
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -115,35 +132,46 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authFormLogin(c *gin.Context) {
|
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth := data.(authData)
|
||||||
|
if auth.IsAdmin == true {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) appAuthLogin(c *gin.Context) {
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
|
|
||||||
username := strings.TrimSpace(c.PostForm("username"))
|
username := strings.TrimSpace(c.PostForm("username"))
|
||||||
rawPassword := strings.TrimSpace(c.PostForm("password"))
|
rawPassword := strings.TrimSpace(c.PostForm("password"))
|
||||||
|
|
||||||
if username == "" || rawPassword == "" {
|
if username == "" || rawPassword == "" {
|
||||||
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
"RegistrationEnabled": api.Config.RegistrationEnabled,
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
"Error": "Invalid Credentials",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// MD5 - KOSync Compatiblity
|
// MD5 - KOSync Compatiblity
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
if authorized := api.authorizeCredentials(username, password); authorized != true {
|
authData := api.authorizeCredentials(username, password)
|
||||||
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
if authData == nil {
|
||||||
"RegistrationEnabled": api.Config.RegistrationEnabled,
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
"Error": "Invalid Credentials",
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Session
|
// Set Session
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
if err := setSession(session, username); err != nil {
|
if err := api.setSession(session, *authData); err != nil {
|
||||||
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
templateVars["Error"] = "Invalid Credentials"
|
||||||
"RegistrationEnabled": api.Config.RegistrationEnabled,
|
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
|
||||||
"Error": "Unknown Error",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,60 +179,93 @@ func (api *API) authFormLogin(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authFormRegister(c *gin.Context) {
|
func (api *API) appAuthRegister(c *gin.Context) {
|
||||||
if !api.Config.RegistrationEnabled {
|
if !api.cfg.RegistrationEnabled {
|
||||||
errorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
|
appErrorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templateVars, _ := api.getBaseTemplateVars("login", c)
|
||||||
|
templateVars["Register"] = true
|
||||||
|
|
||||||
username := strings.TrimSpace(c.PostForm("username"))
|
username := strings.TrimSpace(c.PostForm("username"))
|
||||||
rawPassword := strings.TrimSpace(c.PostForm("password"))
|
rawPassword := strings.TrimSpace(c.PostForm("password"))
|
||||||
|
|
||||||
if username == "" || rawPassword == "" {
|
if username == "" || rawPassword == "" {
|
||||||
c.HTML(http.StatusBadRequest, "login", gin.H{
|
templateVars["Error"] = "Invalid User or Password"
|
||||||
"Register": true,
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
"Error": "Registration Disabled or User Already Exists",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||||
|
|
||||||
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(http.StatusBadRequest, "login", gin.H{
|
templateVars["Error"] = "Registration Disabled or User Already Exists"
|
||||||
"Register": true,
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
"Error": "Registration Disabled or User Already Exists",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
|
// Generate auth hash
|
||||||
ID: username,
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
Pass: &hashedPassword,
|
|
||||||
})
|
|
||||||
|
|
||||||
// SQL Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(http.StatusBadRequest, "login", gin.H{
|
log.Error("Failed to generate user token: ", err)
|
||||||
"Register": true,
|
templateVars["Error"] = "Failed to Create User"
|
||||||
"Error": "Registration Disabled or User Already Exists",
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// User Already Exists
|
// Get current users
|
||||||
if rows == 0 {
|
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
|
||||||
c.HTML(http.StatusBadRequest, "login", gin.H{
|
if err != nil {
|
||||||
"Register": true,
|
log.Error("Failed to check all users: ", err)
|
||||||
"Error": "Registration Disabled or User Already Exists",
|
templateVars["Error"] = "Failed to Create User"
|
||||||
})
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Session
|
// Determine if we should be admin
|
||||||
|
isAdmin := false
|
||||||
|
if len(currentUsers) == 0 {
|
||||||
|
isAdmin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in DB
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
|
||||||
|
ID: username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
Admin: isAdmin,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("CreateUser DB Error:", err)
|
||||||
|
templateVars["Error"] = "Registration Disabled or User Already Exists"
|
||||||
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
|
return
|
||||||
|
} else if rows == 0 {
|
||||||
|
log.Warn("User Already Exists:", username)
|
||||||
|
templateVars["Error"] = "Registration Disabled or User Already Exists"
|
||||||
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUser DB Error:", err)
|
||||||
|
templateVars["Error"] = "Registration Disabled or User Already Exists"
|
||||||
|
c.HTML(http.StatusBadRequest, "page/login", templateVars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session
|
||||||
|
auth := authData{
|
||||||
|
UserName: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
AuthHash: *user.AuthHash,
|
||||||
|
}
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
if err := setSession(session, username); err != nil {
|
if err := api.setSession(session, auth); err != nil {
|
||||||
errorPage(c, http.StatusUnauthorized, "Unauthorized.")
|
appErrorPage(c, http.StatusUnauthorized, "Unauthorized.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,41 +273,213 @@ func (api *API) authFormRegister(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authLogout(c *gin.Context) {
|
func (api *API) appAuthLogout(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
session.Clear()
|
session.Clear()
|
||||||
session.Save()
|
session.Save()
|
||||||
c.Redirect(http.StatusFound, "/login")
|
c.Redirect(http.StatusFound, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) demoModeAppError(c *gin.Context) {
|
func (api *API) koAuthRegister(c *gin.Context) {
|
||||||
errorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
if !api.cfg.RegistrationEnabled {
|
||||||
|
c.AbortWithStatus(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rUser requestUser
|
||||||
|
if err := c.ShouldBindJSON(&rUser); err != nil {
|
||||||
|
log.Error("Invalid JSON Bind")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rUser.Username == "" || rUser.Password == "" {
|
||||||
|
log.Error("Invalid User - Empty Username or Password")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate password hash
|
||||||
|
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Argon2 Hash Failure:", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate auth hash
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to generate user token: ", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current users
|
||||||
|
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to check all users: ", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Failed to Create User")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we should be admin
|
||||||
|
isAdmin := false
|
||||||
|
if len(currentUsers) == 0 {
|
||||||
|
isAdmin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
|
||||||
|
ID: rUser.Username,
|
||||||
|
Pass: &hashedPassword,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
Admin: isAdmin,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("CreateUser DB Error:", err)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
|
||||||
|
return
|
||||||
|
} else if rows == 0 {
|
||||||
|
log.Error("User Already Exists:", rUser.Username)
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "User Already Exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"username": rUser.Username,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) demoModeJSONError(c *gin.Context) {
|
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not Allowed in Demo Mode"})
|
// Get Session
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(session sessions.Session) (user string, ok bool) {
|
|
||||||
// Check Session
|
|
||||||
authorizedUser := session.Get("authorizedUser")
|
authorizedUser := session.Get("authorizedUser")
|
||||||
if authorizedUser == nil {
|
isAdmin := session.Get("isAdmin")
|
||||||
return "", false
|
expiresAt := session.Get("expiresAt")
|
||||||
|
authHash := session.Get("authHash")
|
||||||
|
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Auth Object
|
||||||
|
auth = authData{
|
||||||
|
UserName: authorizedUser.(string),
|
||||||
|
IsAdmin: isAdmin.(bool),
|
||||||
|
AuthHash: authHash.(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Auth Hash
|
||||||
|
correctAuthHash, err := api.getUserAuthHash(auth.UserName)
|
||||||
|
if err != nil || correctAuthHash != auth.AuthHash {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
expiresAt := session.Get("expiresAt")
|
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
||||||
if expiresAt != nil && expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
|
log.Info("Refreshing Session")
|
||||||
log.Info("[getSession] Refreshing Session")
|
api.setSession(session, auth)
|
||||||
setSession(session, authorizedUser.(string))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return authorizedUser.(string), true
|
// Authorized
|
||||||
|
return auth, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSession(session sessions.Session, user string) error {
|
func (api *API) setSession(session sessions.Session, auth authData) error {
|
||||||
// Set Session Cookie
|
// Set Session Cookie
|
||||||
session.Set("authorizedUser", user)
|
session.Set("authorizedUser", auth.UserName)
|
||||||
|
session.Set("isAdmin", auth.IsAdmin)
|
||||||
session.Set("expiresAt", time.Now().Unix()+(60*60*24*7))
|
session.Set("expiresAt", time.Now().Unix()+(60*60*24*7))
|
||||||
|
session.Set("authHash", auth.AuthHash)
|
||||||
|
|
||||||
return session.Save()
|
return session.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) getUserAuthHash(username string) (string, error) {
|
||||||
|
// Return Cache
|
||||||
|
if api.userAuthCache[username] != "" {
|
||||||
|
return api.userAuthCache[username], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get DB
|
||||||
|
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUser DB Error:", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Cache
|
||||||
|
api.userAuthCache[username] = *user.AuthHash
|
||||||
|
|
||||||
|
return api.userAuthCache[username], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) rotateUserAuthHash(username string) error {
|
||||||
|
// Generate Auth Hash
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to generate user token: ", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update User
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
if _, err = api.db.Queries.UpdateUser(api.db.Ctx, database.UpdateUserParams{
|
||||||
|
UserID: username,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("UpdateUser DB Error: ", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Cache
|
||||||
|
api.userAuthCache[username] = fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) rotateAllAuthHashes() error {
|
||||||
|
// Do Transaction
|
||||||
|
tx, err := api.db.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Transaction Begin DB Error: ", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer & Start Transaction
|
||||||
|
defer tx.Rollback()
|
||||||
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
|
users, err := qtx.GetUsers(api.db.Ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update users
|
||||||
|
for _, user := range users {
|
||||||
|
// Generate Auth Hash
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update User
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
if _, err = qtx.UpdateUser(api.db.Ctx, database.UpdateUserParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
AuthHash: &authHash,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Cache
|
||||||
|
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit Transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Error("Transaction Commit DB Error: ", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
144
api/common.go
Normal file
144
api/common.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int, string)) func(*gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var rDoc requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
errorFunc(c, http.StatusBadRequest, "Invalid Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Document
|
||||||
|
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error:", err)
|
||||||
|
errorFunc(c, http.StatusBadRequest, "Unknown Document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.Filepath == nil {
|
||||||
|
log.Error("Document Doesn't Have File:", rDoc.DocumentID)
|
||||||
|
errorFunc(c, http.StatusBadRequest, "Document Doesn't Exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive Storage Location
|
||||||
|
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath)
|
||||||
|
|
||||||
|
// Validate File Exists
|
||||||
|
_, err = os.Stat(filePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Error("File should but doesn't exist: ", err)
|
||||||
|
errorFunc(c, http.StatusBadRequest, "Document Doesn't Exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Download
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
|
||||||
|
c.File(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string)) func(*gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var rDoc requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("Invalid URI Bind")
|
||||||
|
errorFunc(c, http.StatusNotFound, "Invalid cover.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Document Exists in DB
|
||||||
|
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDocument DB Error:", err)
|
||||||
|
errorFunc(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Identified Document
|
||||||
|
if document.Coverfile != nil {
|
||||||
|
if *document.Coverfile == "UNKNOWN" {
|
||||||
|
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive Path
|
||||||
|
safePath := filepath.Join(api.cfg.DataPath, "covers", *document.Coverfile)
|
||||||
|
|
||||||
|
// Validate File Exists
|
||||||
|
_, err = os.Stat(safePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File should but doesn't exist: ", err)
|
||||||
|
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.File(safePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt Metadata
|
||||||
|
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
|
||||||
|
var coverFile string = "UNKNOWN"
|
||||||
|
|
||||||
|
// Identify Documents & Save Covers
|
||||||
|
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
|
||||||
|
Title: document.Title,
|
||||||
|
Author: document.Author,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
|
||||||
|
firstResult := metadataResults[0]
|
||||||
|
|
||||||
|
// Save Cover
|
||||||
|
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
|
||||||
|
if err == nil {
|
||||||
|
coverFile = *fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store First Metadata Result
|
||||||
|
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
Title: firstResult.Title,
|
||||||
|
Author: firstResult.Author,
|
||||||
|
Description: firstResult.Description,
|
||||||
|
Gbid: firstResult.ID,
|
||||||
|
Olid: nil,
|
||||||
|
Isbn10: firstResult.ISBN10,
|
||||||
|
Isbn13: firstResult.ISBN13,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("AddMetadata DB Error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert Document
|
||||||
|
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
|
||||||
|
ID: document.ID,
|
||||||
|
Coverfile: &coverFile,
|
||||||
|
}); err != nil {
|
||||||
|
log.Warn("UpsertDocument DB Error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return Unknown Cover
|
||||||
|
if coverFile == "UNKNOWN" {
|
||||||
|
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverFilePath := filepath.Join(coverDir, coverFile)
|
||||||
|
c.File(coverFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
404
api/ko-routes.go
404
api/ko-routes.go
@@ -10,16 +10,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
argon2 "github.com/alexedwards/argon2id"
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/exp/slices"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/metadata"
|
||||||
"reichard.io/bbank/metadata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type activityItem struct {
|
type activityItem struct {
|
||||||
@@ -75,126 +71,78 @@ type requestDocumentID struct {
|
|||||||
DocumentID string `uri:"document" binding:"required"`
|
DocumentID string `uri:"document" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) authorizeUser(c *gin.Context) {
|
func (api *API) koAuthorizeUser(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"authorized": "OK",
|
"authorized": "OK",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) createUser(c *gin.Context) {
|
func (api *API) koSetProgress(c *gin.Context) {
|
||||||
if !api.Config.RegistrationEnabled {
|
var auth authData
|
||||||
c.AbortWithStatus(http.StatusConflict)
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
return
|
auth = data.(authData)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rUser requestUser
|
|
||||||
if err := c.ShouldBindJSON(&rUser); err != nil {
|
|
||||||
log.Error("[createUser] Invalid JSON Bind")
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rUser.Username == "" || rUser.Password == "" {
|
|
||||||
log.Error("[createUser] Invalid User - Empty Username or Password")
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[createUser] Argon2 Hash Failure:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
|
|
||||||
ID: rUser.Username,
|
|
||||||
Pass: &hashedPassword,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[createUser] CreateUser DB Error:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// User Exists
|
|
||||||
if rows == 0 {
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"username": rUser.Username,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) setProgress(c *gin.Context) {
|
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
|
||||||
|
|
||||||
var rPosition requestPosition
|
var rPosition requestPosition
|
||||||
if err := c.ShouldBindJSON(&rPosition); err != nil {
|
if err := c.ShouldBindJSON(&rPosition); err != nil {
|
||||||
log.Error("[setProgress] Invalid JSON Bind")
|
log.Error("Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Progress Data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rPosition.DeviceID,
|
ID: rPosition.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
DeviceName: rPosition.Device,
|
DeviceName: rPosition.Device,
|
||||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[setProgress] UpsertDevice DB Error:", err)
|
log.Error("UpsertDevice DB Error:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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: rPosition.DocumentID,
|
ID: rPosition.DocumentID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[setProgress] UpsertDocument DB Error:", err)
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or Replace Progress
|
// Create or Replace Progress
|
||||||
progress, err := api.DB.Queries.UpdateProgress(api.DB.Ctx, database.UpdateProgressParams{
|
progress, err := api.db.Queries.UpdateProgress(api.db.Ctx, database.UpdateProgressParams{
|
||||||
Percentage: rPosition.Percentage,
|
Percentage: rPosition.Percentage,
|
||||||
DocumentID: rPosition.DocumentID,
|
DocumentID: rPosition.DocumentID,
|
||||||
DeviceID: rPosition.DeviceID,
|
DeviceID: rPosition.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
Progress: rPosition.Progress,
|
Progress: rPosition.Progress,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[setProgress] UpdateProgress DB Error:", err)
|
log.Error("UpdateProgress DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getProgress(c *gin.Context) {
|
func (api *API) koGetProgress(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
log.Error("[getProgress] Invalid URI Bind")
|
log.Error("Invalid URI Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
progress, err := api.DB.Queries.GetProgress(api.DB.Ctx, database.GetProgressParams{
|
progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{
|
||||||
DocumentID: rDocID.DocumentID,
|
DocumentID: rDocID.DocumentID,
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -202,8 +150,8 @@ func (api *API) getProgress(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{})
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Error("[getProgress] GetProgress DB Error:", err)
|
log.Error("GetDocumentProgress DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,21 +164,24 @@ func (api *API) getProgress(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) addActivities(c *gin.Context) {
|
func (api *API) koAddActivities(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
var rActivity requestActivity
|
var rActivity requestActivity
|
||||||
if err := c.ShouldBindJSON(&rActivity); err != nil {
|
if err := c.ShouldBindJSON(&rActivity); err != nil {
|
||||||
log.Error("[addActivity] Invalid JSON Bind")
|
log.Error("Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Activity")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do Transaction
|
// Do Transaction
|
||||||
tx, err := api.DB.DB.Begin()
|
tx, err := api.db.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[addActivities] Transaction Begin DB Error:", err)
|
log.Error("Transaction Begin DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,35 +194,35 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
|
|
||||||
// Defer & Start Transaction
|
// Defer & Start Transaction
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
qtx := api.DB.Queries.WithTx(tx)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
for _, doc := range allDocuments {
|
for _, doc := range allDocuments {
|
||||||
if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
|
if _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
|
||||||
ID: doc,
|
ID: doc,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[addActivities] UpsertDocument DB Error:", err)
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
if _, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
if _, err = qtx.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rActivity.DeviceID,
|
ID: rActivity.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
DeviceName: rActivity.Device,
|
DeviceName: rActivity.Device,
|
||||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[addActivities] UpsertDevice DB Error:", err)
|
log.Error("UpsertDevice DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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: auth.UserName,
|
||||||
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),
|
||||||
@@ -279,73 +230,67 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
StartPercentage: float64(item.Page) / float64(item.Pages),
|
StartPercentage: float64(item.Page) / float64(item.Pages),
|
||||||
EndPercentage: float64(item.Page+1) / float64(item.Pages),
|
EndPercentage: float64(item.Page+1) / float64(item.Pages),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[addActivities] AddActivity DB Error:", err)
|
log.Error("AddActivity DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Activity")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Error("[addActivities] Transaction Commit DB Error:", err)
|
log.Error("Transaction Commit DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Statistic
|
|
||||||
for _, doc := range allDocuments {
|
|
||||||
log.Info("[addActivities] UpdateDocumentUserStatistic Running...")
|
|
||||||
if err := api.DB.UpdateDocumentUserStatistic(doc, rUser.(string)); err != nil {
|
|
||||||
log.Error("[addActivities] UpdateDocumentUserStatistic Error:", err)
|
|
||||||
}
|
|
||||||
log.Info("[addActivities] UpdateDocumentUserStatistic Complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"added": len(rActivity.Activity),
|
"added": len(rActivity.Activity),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) checkActivitySync(c *gin.Context) {
|
func (api *API) koCheckActivitySync(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
var rCheckActivity requestCheckActivitySync
|
var rCheckActivity requestCheckActivitySync
|
||||||
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
|
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
|
||||||
log.Error("[checkActivitySync] Invalid JSON Bind")
|
log.Error("Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert Device
|
||||||
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
|
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
|
||||||
ID: rCheckActivity.DeviceID,
|
ID: rCheckActivity.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
DeviceName: rCheckActivity.Device,
|
DeviceName: rCheckActivity.Device,
|
||||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[checkActivitySync] UpsertDevice DB Error", err)
|
log.Error("UpsertDevice DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Last Device Activity
|
// Get Last Device Activity
|
||||||
lastActivity, err := api.DB.Queries.GetLastActivity(api.DB.Ctx, database.GetLastActivityParams{
|
lastActivity, err := api.db.Queries.GetLastActivity(api.db.Ctx, database.GetLastActivityParams{
|
||||||
UserID: rUser.(string),
|
UserID: auth.UserName,
|
||||||
DeviceID: rCheckActivity.DeviceID,
|
DeviceID: rCheckActivity.DeviceID,
|
||||||
})
|
})
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
lastActivity = time.UnixMilli(0).Format(time.RFC3339)
|
lastActivity = time.UnixMilli(0).Format(time.RFC3339)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Error("[checkActivitySync] GetLastActivity DB Error:", err)
|
log.Error("GetLastActivity DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Time
|
// Parse Time
|
||||||
parsedTime, err := time.Parse(time.RFC3339, lastActivity)
|
parsedTime, err := time.Parse(time.RFC3339, lastActivity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[checkActivitySync] Time Parse Error:", err)
|
log.Error("Time Parse Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,29 +299,29 @@ func (api *API) checkActivitySync(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) addDocuments(c *gin.Context) {
|
func (api *API) koAddDocuments(c *gin.Context) {
|
||||||
var rNewDocs requestDocument
|
var rNewDocs requestDocument
|
||||||
if err := c.ShouldBindJSON(&rNewDocs); err != nil {
|
if err := c.ShouldBindJSON(&rNewDocs); err != nil {
|
||||||
log.Error("[addDocuments] Invalid JSON Bind")
|
log.Error("Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document(s)"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Document(s)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do Transaction
|
// Do Transaction
|
||||||
tx, err := api.DB.DB.Begin()
|
tx, err := api.db.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[addDocuments] Transaction Begin DB Error:", err)
|
log.Error("Transaction Begin DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer & Start Transaction
|
// Defer & Start Transaction
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
qtx := api.DB.Queries.WithTx(tx)
|
qtx := api.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
// Upsert Documents
|
// Upsert Documents
|
||||||
for _, doc := range rNewDocs.Documents {
|
for _, doc := range rNewDocs.Documents {
|
||||||
_, 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),
|
||||||
@@ -386,16 +331,16 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
Description: api.sanitizeInput(doc.Description),
|
Description: api.sanitizeInput(doc.Description),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[addDocuments] UpsertDocument DB Error:", err)
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit Transaction
|
// Commit Transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Error("[addDocuments] Transaction Commit DB Error:", err)
|
log.Error("Transaction Commit DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,26 +349,29 @@ func (api *API) addDocuments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) checkDocumentsSync(c *gin.Context) {
|
func (api *API) koCheckDocumentsSync(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
var rCheckDocs requestCheckDocumentSync
|
var rCheckDocs requestCheckDocumentSync
|
||||||
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
|
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
|
||||||
log.Error("[checkDocumentsSync] Invalid JSON Bind")
|
log.Error("Invalid JSON Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert Device
|
// Upsert 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: auth.UserName,
|
||||||
DeviceName: rCheckDocs.Device,
|
DeviceName: rCheckDocs.Device,
|
||||||
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[checkDocumentsSync] UpsertDevice DB Error", err)
|
log.Error("UpsertDevice DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,33 +379,33 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
deletedDocIDs := []string{}
|
deletedDocIDs := []string{}
|
||||||
|
|
||||||
// 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("GetMissingDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "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("GetDeletedDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Wanted Documents
|
// Get Wanted Documents
|
||||||
jsonHaves, err := json.Marshal(rCheckDocs.Have)
|
jsonHaves, err := json.Marshal(rCheckDocs.Have)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[checkDocumentsSync] JSON Marshal Error", err)
|
log.Error("JSON Marshal Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
|
wantedDocs, err := api.db.Queries.GetWantedDocuments(api.db.Ctx, string(jsonHaves))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[checkDocumentsSync] GetWantedDocuments DB Error", err)
|
log.Error("GetWantedDocuments DB Error", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,99 +445,85 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, rCheckDocSync)
|
c.JSON(http.StatusOK, rCheckDocSync)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) uploadExistingDocument(c *gin.Context) {
|
func (api *API) koUploadExistingDocument(c *gin.Context) {
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
log.Error("[uploadExistingDocument] Invalid URI Bind")
|
log.Error("Invalid URI Bind")
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open Form File
|
||||||
fileData, err := c.FormFile("file")
|
fileData, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[uploadExistingDocument] File Error:", err)
|
log.Error("File Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
apiErrorPage(c, http.StatusBadRequest, "File error")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Type & Derive Extension on MIME
|
|
||||||
uploadedFile, err := fileData.Open()
|
|
||||||
fileMime, err := mimetype.DetectReader(uploadedFile)
|
|
||||||
fileExtension := fileMime.Extension()
|
|
||||||
|
|
||||||
if !slices.Contains([]string{".epub", ".html"}, fileExtension) {
|
|
||||||
log.Error("[uploadExistingDocument] Invalid FileType:", fileExtension)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Document Exists in DB
|
// Validate Document Exists in DB
|
||||||
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[uploadExistingDocument] GetDocument DB Error:", err)
|
log.Error("GetDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
apiErrorPage(c, http.StatusBadRequest, "Unknown Document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open File
|
||||||
|
uploadedFile, err := fileData.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to open file")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unable to open file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Support
|
||||||
|
docType, err := metadata.GetDocumentTypeReader(uploadedFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unsupported file")
|
||||||
|
apiErrorPage(c, http.StatusBadRequest, "Unsupported file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive Filename
|
// Derive Filename
|
||||||
var fileName string
|
fileName := deriveBaseFileName(&metadata.MetadataInfo{
|
||||||
if document.Author != nil {
|
Type: *docType,
|
||||||
fileName = fileName + *document.Author
|
PartialMD5: &document.ID,
|
||||||
} else {
|
Title: document.Title,
|
||||||
fileName = fileName + "Unknown"
|
Author: document.Author,
|
||||||
}
|
})
|
||||||
|
|
||||||
if document.Title != nil {
|
|
||||||
fileName = fileName + " - " + *document.Title
|
|
||||||
} else {
|
|
||||||
fileName = fileName + " - Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove Slashes
|
|
||||||
fileName = strings.ReplaceAll(fileName, "/", "")
|
|
||||||
|
|
||||||
// Derive & Sanitize File Name
|
|
||||||
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, document.ID, fileExtension))
|
|
||||||
|
|
||||||
// Generate Storage Path
|
// Generate Storage Path
|
||||||
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
|
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName)
|
||||||
|
|
||||||
// Save & Prevent Overwrites
|
// Save & Prevent Overwrites
|
||||||
_, err = os.Stat(safePath)
|
_, err = os.Stat(safePath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
err = c.SaveUploadedFile(fileData, safePath)
|
err = c.SaveUploadedFile(fileData, safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[uploadExistingDocument] Save Failure:", err)
|
log.Error("Save Failure:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
apiErrorPage(c, http.StatusBadRequest, "File Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get MD5 Hash
|
// Acquire Metadata
|
||||||
fileHash, err := getFileMD5(safePath)
|
metadataInfo, err := metadata.GetMetadata(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[uploadExistingDocument] Hash Failure:", err)
|
log.Errorf("Unable to acquire metadata: %v", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Unable to acquire metadata")
|
||||||
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
|
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: metadataInfo.MD5,
|
||||||
|
Words: metadataInfo.WordCount,
|
||||||
Filepath: &fileName,
|
Filepath: &fileName,
|
||||||
Words: &wordCount,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
|
log.Error("UpsertDocument DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
|
apiErrorPage(c, http.StatusBadRequest, "Document Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,54 +532,24 @@ func (api *API) uploadExistingDocument(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) downloadDocument(c *gin.Context) {
|
func (api *API) koDemoModeJSONError(c *gin.Context) {
|
||||||
var rDoc requestDocumentID
|
apiErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
}
|
||||||
log.Error("[downloadDocument] Invalid URI Bind")
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Document
|
func apiErrorPage(c *gin.Context, errorCode int, errorMessage string) {
|
||||||
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
|
c.AbortWithStatusJSON(errorCode, gin.H{"error": errorMessage})
|
||||||
if err != nil {
|
|
||||||
log.Error("[downloadDocument] GetDocument DB Error:", err)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if document.Filepath == nil {
|
|
||||||
log.Error("[downloadDocument] Document Doesn't Have File:", rDoc.DocumentID)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive Storage Location
|
|
||||||
filePath := filepath.Join(api.Config.DataPath, "documents", *document.Filepath)
|
|
||||||
|
|
||||||
// Validate File Exists
|
|
||||||
_, err = os.Stat(filePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
log.Error("[downloadDocument] File Doesn't Exist:", rDoc.DocumentID)
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force Download (Security)
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
|
|
||||||
c.File(filePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) sanitizeInput(val any) *string {
|
func (api *API) sanitizeInput(val any) *string {
|
||||||
switch v := val.(type) {
|
switch v := val.(type) {
|
||||||
case *string:
|
case *string:
|
||||||
if v != nil {
|
if v != nil {
|
||||||
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(*v)))
|
newString := html.UnescapeString(htmlPolicy.Sanitize(string(*v)))
|
||||||
return &newString
|
return &newString
|
||||||
}
|
}
|
||||||
case string:
|
case string:
|
||||||
if v != "" {
|
if v != "" {
|
||||||
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(v)))
|
newString := html.UnescapeString(htmlPolicy.Sanitize(string(v)))
|
||||||
return &newString
|
return &newString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/bbank/opds"
|
"reichard.io/antholume/opds"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mimeMapping map[string]string = map[string]string{
|
var mimeMapping map[string]string = map[string]string{
|
||||||
@@ -26,23 +26,65 @@ var mimeMapping map[string]string = map[string]string{
|
|||||||
"lit": "application/x-ms-reader",
|
"lit": "application/x-ms-reader",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) opdsDocuments(c *gin.Context) {
|
func (api *API) opdsEntry(c *gin.Context) {
|
||||||
var userID string
|
// Build & Return XML
|
||||||
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
|
mainFeed := &opds.Feed{
|
||||||
userID = rUser.(string)
|
Title: "AnthoLume OPDS Server",
|
||||||
|
Updated: time.Now().UTC(),
|
||||||
|
Links: []opds.Link{
|
||||||
|
{
|
||||||
|
Title: "Search AnthoLume",
|
||||||
|
Rel: "search",
|
||||||
|
TypeLink: "application/opensearchdescription+xml",
|
||||||
|
Href: "/api/opds/search.xml",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Entries: []opds.Entry{
|
||||||
|
{
|
||||||
|
Title: "AnthoLume - All Documents",
|
||||||
|
Content: &opds.Content{
|
||||||
|
Content: "AnthoLume - All Documents",
|
||||||
|
ContentType: "text",
|
||||||
|
},
|
||||||
|
Links: []opds.Link{
|
||||||
|
{
|
||||||
|
Href: "/api/opds/documents",
|
||||||
|
TypeLink: "application/atom+xml;type=feed;profile=opds-catalog",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Potential URL Parameters
|
c.XML(http.StatusOK, mainFeed)
|
||||||
qParams := bindQueryParams(c)
|
}
|
||||||
|
|
||||||
|
func (api *API) opdsDocuments(c *gin.Context) {
|
||||||
|
var auth authData
|
||||||
|
if data, _ := c.Get("Authorization"); data != nil {
|
||||||
|
auth = data.(authData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potential URL Parameters (Default Pagination - 100)
|
||||||
|
qParams := bindQueryParams(c, 100)
|
||||||
|
|
||||||
|
// Possible Query
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
// Get Documents
|
// Get Documents
|
||||||
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: auth.UserName,
|
||||||
|
Query: query,
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[opdsDocuments] GetDocumentsWithStats DB Error:", err)
|
log.Error("GetDocumentsWithStats DB Error:", err)
|
||||||
c.AbortWithStatus(http.StatusBadRequest)
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,7 +113,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item := opds.Entry{
|
item := opds.Entry{
|
||||||
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage), title),
|
Title: title,
|
||||||
Author: []opds.Author{
|
Author: []opds.Author{
|
||||||
{
|
{
|
||||||
Name: author,
|
Name: author,
|
||||||
@@ -84,12 +126,12 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
Links: []opds.Link{
|
Links: []opds.Link{
|
||||||
{
|
{
|
||||||
Rel: "http://opds-spec.org/acquisition",
|
Rel: "http://opds-spec.org/acquisition",
|
||||||
Href: fmt.Sprintf("./documents/%s/file", doc.ID),
|
Href: fmt.Sprintf("/api/opds/documents/%s/file", doc.ID),
|
||||||
TypeLink: mimeMapping[fileType],
|
TypeLink: mimeMapping[fileType],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Rel: "http://opds-spec.org/image",
|
Rel: "http://opds-spec.org/image",
|
||||||
Href: fmt.Sprintf("./documents/%s/cover", doc.ID),
|
Href: fmt.Sprintf("/api/opds/documents/%s/cover", doc.ID),
|
||||||
TypeLink: "image/jpeg",
|
TypeLink: "image/jpeg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,19 +141,15 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
feedTitle := "All Documents"
|
||||||
|
if query != nil {
|
||||||
|
feedTitle = "Search Results"
|
||||||
|
}
|
||||||
|
|
||||||
// Build & Return XML
|
// Build & Return XML
|
||||||
searchFeed := &opds.Feed{
|
searchFeed := &opds.Feed{
|
||||||
Title: "All Documents",
|
Title: feedTitle,
|
||||||
Updated: time.Now().UTC(),
|
Updated: time.Now().UTC(),
|
||||||
// TODO
|
|
||||||
// Links: []opds.Link{
|
|
||||||
// {
|
|
||||||
// Title: "Search AnthoLume",
|
|
||||||
// Rel: "search",
|
|
||||||
// TypeLink: "application/opensearchdescription+xml",
|
|
||||||
// Href: "search.xml",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
Entries: allEntries,
|
Entries: allEntries,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +160,7 @@ 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 AnthoLume</ShortName>
|
<ShortName>Search AnthoLume</ShortName>
|
||||||
<Description>Search AnthoLume</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="/api/opds/documents?search={searchTerms}"/>
|
||||||
</OpenSearchDescription>`
|
</OpenSearchDescription>`
|
||||||
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
||||||
}
|
}
|
||||||
|
|||||||
76
api/streamer.go
Normal file
76
api/streamer.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type streamer struct {
|
||||||
|
templates map[string]*template.Template
|
||||||
|
writer gin.ResponseWriter
|
||||||
|
mutex sync.Mutex
|
||||||
|
completeCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) newStreamer(c *gin.Context, data string) *streamer {
|
||||||
|
stream := &streamer{
|
||||||
|
writer: c.Writer,
|
||||||
|
templates: api.templates,
|
||||||
|
completeCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Headers
|
||||||
|
header := stream.writer.Header()
|
||||||
|
header.Set("Transfer-Encoding", "chunked")
|
||||||
|
header.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
header.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
stream.writer.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Send Open Element Tags
|
||||||
|
stream.write(data)
|
||||||
|
|
||||||
|
// Keep Alive
|
||||||
|
go func() {
|
||||||
|
closeCh := stream.writer.CloseNotify()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stream.completeCh:
|
||||||
|
return
|
||||||
|
case <-closeCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
stream.write("<!-- ping -->")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *streamer) write(str string) {
|
||||||
|
stream.mutex.Lock()
|
||||||
|
stream.writer.WriteString(str)
|
||||||
|
stream.writer.(http.Flusher).Flush()
|
||||||
|
stream.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *streamer) send(templateName string, templateVars gin.H) {
|
||||||
|
t := stream.templates[templateName]
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_ = t.ExecuteTemplate(buf, templateName, templateVars)
|
||||||
|
stream.write(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *streamer) close(data string) {
|
||||||
|
// Send Close Element Tags
|
||||||
|
stream.write(data)
|
||||||
|
|
||||||
|
// Close
|
||||||
|
close(stream.completeCh)
|
||||||
|
}
|
||||||
75
api/utils.go
75
api/utils.go
@@ -1,11 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/database"
|
||||||
"reichard.io/bbank/graph"
|
"reichard.io/antholume/graph"
|
||||||
|
"reichard.io/antholume/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UTCOffset struct {
|
type UTCOffset struct {
|
||||||
@@ -86,6 +91,24 @@ func niceSeconds(input int64) (result string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func niceNumbers(input int64) string {
|
||||||
|
if input == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
abbreviations := []string{"", "k", "M", "B", "T"}
|
||||||
|
abbrevIndex := int(math.Log10(float64(input)) / 3)
|
||||||
|
scaledNumber := float64(input) / math.Pow(10, float64(abbrevIndex*3))
|
||||||
|
|
||||||
|
if scaledNumber >= 100 {
|
||||||
|
return fmt.Sprintf("%.0f%s", scaledNumber, abbreviations[abbrevIndex])
|
||||||
|
} else if scaledNumber >= 10 {
|
||||||
|
return fmt.Sprintf("%.1f%s", scaledNumber, abbreviations[abbrevIndex])
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%.2f%s", scaledNumber, abbreviations[abbrevIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert Database Array -> Int64 Array
|
// Convert Database Array -> Int64 Array
|
||||||
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||||
var intData []int64
|
var intData []int64
|
||||||
@@ -95,3 +118,51 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv
|
|||||||
|
|
||||||
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dict(values ...interface{}) (map[string]interface{}, error) {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return nil, errors.New("invalid dict call")
|
||||||
|
}
|
||||||
|
dict := make(map[string]interface{}, len(values)/2)
|
||||||
|
for i := 0; i < len(values); i += 2 {
|
||||||
|
key, ok := values[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("dict keys must be strings")
|
||||||
|
}
|
||||||
|
dict[key] = values[i+1]
|
||||||
|
}
|
||||||
|
return dict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fields(value interface{}) (map[string]interface{}, error) {
|
||||||
|
v := reflect.Indirect(reflect.ValueOf(value))
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return nil, fmt.Errorf("%T is not a struct", value)
|
||||||
|
}
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
t := v.Type()
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
sv := t.Field(i)
|
||||||
|
m[sv.Name] = v.Field(i).Interface()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||||
|
// Derive New FileName
|
||||||
|
var newFileName string
|
||||||
|
if *metadataInfo.Author != "" {
|
||||||
|
newFileName = newFileName + *metadataInfo.Author
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + "Unknown"
|
||||||
|
}
|
||||||
|
if *metadataInfo.Title != "" {
|
||||||
|
newFileName = newFileName + " - " + *metadataInfo.Title
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + " - Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Slashes
|
||||||
|
fileName := strings.ReplaceAll(newFileName, "/", "")
|
||||||
|
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNiceSeconds(t *testing.T) {
|
func TestNiceSeconds(t *testing.T) {
|
||||||
want := "22d 7h 39m 31s"
|
wantOne := "22d 7h 39m 31s"
|
||||||
nice := niceSeconds(1928371)
|
wantNA := "N/A"
|
||||||
|
|
||||||
if nice != want {
|
niceOne := niceSeconds(1928371)
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, want, nice)
|
niceNA := niceSeconds(0)
|
||||||
}
|
|
||||||
|
assert.Equal(t, wantOne, niceOne, "should be nice seconds")
|
||||||
|
assert.Equal(t, wantNA, niceNA, "should be nice NA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiceNumbers(t *testing.T) {
|
||||||
|
wantMillions := "198M"
|
||||||
|
wantThousands := "19.8k"
|
||||||
|
wantThousandsTwo := "1.98k"
|
||||||
|
wantZero := "0"
|
||||||
|
|
||||||
|
niceMillions := niceNumbers(198236461)
|
||||||
|
niceThousands := niceNumbers(19823)
|
||||||
|
niceThousandsTwo := niceNumbers(1984)
|
||||||
|
niceZero := niceNumbers(0)
|
||||||
|
|
||||||
|
assert.Equal(t, wantMillions, niceMillions, "should be nice millions")
|
||||||
|
assert.Equal(t, wantThousands, niceThousands, "should be nice thousands")
|
||||||
|
assert.Equal(t, wantThousandsTwo, niceThousandsTwo, "should be nice thousands")
|
||||||
|
assert.Equal(t, wantZero, niceZero, "should be nice zero")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/icons/favicon.ico
Normal file
BIN
assets/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
assets/lib/epub.min.js
vendored
2
assets/lib/epub.min.js
vendored
File diff suppressed because one or more lines are too long
1
assets/lib/platform.min.js
vendored
1
assets/lib/platform.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -31,7 +31,6 @@
|
|||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
<script src="/assets/lib/epub.min.js"></script>
|
<script src="/assets/lib/epub.min.js"></script>
|
||||||
<script src="/assets/lib/idb-keyval.min.js"></script>
|
<script src="/assets/lib/idb-keyval.min.js"></script>
|
||||||
<script src="/assets/lib/sw-helper.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Local -->
|
<!-- Local -->
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
119
assets/reader/fonts.css
Normal file
119
assets/reader/fonts.css
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Lato
|
||||||
|
* - Charsets: [latin,latin-ext]
|
||||||
|
* - Styles: [100,700,100italic,regular,italic,700italic]
|
||||||
|
**/
|
||||||
|
|
||||||
|
/* lato-100 - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-100.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-100italic - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-100italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-regular - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-italic - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-700 - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lato-700italic - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Lato";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url("./fonts/lato-v24-latin_latin-ext-700italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Sans
|
||||||
|
* - Charsets: [latin,latin-ext]
|
||||||
|
* - Styles: [700,regular,italic,700italic]
|
||||||
|
**/
|
||||||
|
|
||||||
|
/* open-sans-regular - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Open Sans";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("./fonts/open-sans-v36-latin_latin-ext-regular.woff2")
|
||||||
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* open-sans-italic - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Open Sans";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("./fonts/open-sans-v36-latin_latin-ext-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* open-sans-700 - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Open Sans";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url("./fonts/open-sans-v36-latin_latin-ext-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* open-sans-700italic - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Open Sans";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url("./fonts/open-sans-v36-latin_latin-ext-700italic.woff2")
|
||||||
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbutus Slab
|
||||||
|
* - Charsets: [latin,latin-ext]
|
||||||
|
* - Styles: [regular]
|
||||||
|
**/
|
||||||
|
|
||||||
|
/* arbutus-slab-regular - latin_latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: "Arbutus Slab";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("./fonts/arbutus-slab-v16-latin_latin-ext-regular.woff2")
|
||||||
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-100.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-100.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-100italic.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-100italic.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-700.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-700.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-700italic.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-700italic.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-italic.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-italic.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-regular.woff2
Normal file
BIN
assets/reader/fonts/lato-v24-latin_latin-ext-regular.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-700.woff2
Normal file
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-700.woff2
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-italic.woff2
Normal file
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-italic.woff2
Normal file
Binary file not shown.
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-regular.woff2
Normal file
BIN
assets/reader/fonts/open-sans-v36-latin_latin-ext-regular.woff2
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/platform.min.js"></script>
|
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
<script src="/assets/lib/epub.min.js"></script>
|
<script src="/assets/lib/epub.min.js"></script>
|
||||||
<script src="/assets/lib/no-sleep.min.js"></script>
|
<script src="/assets/lib/no-sleep.min.js"></script>
|
||||||
@@ -71,6 +70,10 @@
|
|||||||
#top-bar:not(.top-0) {
|
#top-bar:not(.top-0) {
|
||||||
top: calc((8em + env(safe-area-inset-top)) * -1);
|
top: calc((8em + env(safe-area-inset-top)) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select:invalid {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
@@ -260,5 +263,120 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="viewer" class="w-full h-full"></div>
|
<div id="viewer" class="w-full h-full"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Device Selector -->
|
||||||
|
<div
|
||||||
|
id="device-selector"
|
||||||
|
class="hidden absolute top-0 left-0 w-full h-full z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col gap-4 p-4 max-h-[95%] w-5/6 md:w-1/2 bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 overflow-hidden shadow rounded"
|
||||||
|
>
|
||||||
|
<div class="text-center flex flex-col gap-2">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
|
||||||
|
Select Device
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 text-center">
|
||||||
|
This device appears to be new! Please either assume an existing
|
||||||
|
device, or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4 flex-col">
|
||||||
|
<div class="flex relative min-w-[12em]">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 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.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 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.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
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"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled selected hidden>
|
||||||
|
Select Existing Device
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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">Assume Device</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4 flex-col">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 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.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 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.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
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="New Device Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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">Create Device</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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/themes.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial load handler. Gets called on DOMContentLoaded. Responsible for
|
* Initial load handler. Gets called on DOMContentLoaded. Responsible for
|
||||||
@@ -17,7 +17,7 @@ async function initReader() {
|
|||||||
|
|
||||||
if (documentType == "REMOTE") {
|
if (documentType == "REMOTE") {
|
||||||
// Get Server / Cached Document
|
// Get Server / Cached Document
|
||||||
let progressResp = await fetch("/documents/" + documentID + "/progress");
|
let progressResp = await fetch("/reader/progress/" + documentID);
|
||||||
documentData = await progressResp.json();
|
documentData = await progressResp.json();
|
||||||
|
|
||||||
// Update With Local Cache
|
// Update With Local Cache
|
||||||
@@ -97,16 +97,18 @@ class EBookReader {
|
|||||||
flow: "paginated",
|
flow: "paginated",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
allowScriptedContent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup Reader
|
// Setup Reader
|
||||||
this.book.ready.then(this.setupReader.bind(this));
|
this.book.ready.then(this.setupReader.bind(this));
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
this.initCSP();
|
||||||
this.initDevice();
|
this.initDevice();
|
||||||
this.initWakeLock();
|
this.initWakeLock();
|
||||||
this.initThemes();
|
this.initThemes();
|
||||||
this.initRenditionListeners();
|
this.initViewerListeners();
|
||||||
this.initDocumentListeners();
|
this.initDocumentListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,18 +143,64 @@ class EBookReader {
|
|||||||
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
||||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
||||||
.toString(16)
|
.toString(16)
|
||||||
.toUpperCase()
|
.toUpperCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.readerSettings.deviceName =
|
// Device Already Set
|
||||||
this.readerSettings.deviceName ||
|
if (this.readerSettings.deviceID) return;
|
||||||
platform.os.toString() + " - " + platform.name;
|
|
||||||
|
|
||||||
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
|
// Get Elements
|
||||||
|
let devicePopup = document.querySelector("#device-selector");
|
||||||
|
let devSelector = devicePopup.querySelector("select");
|
||||||
|
let devInput = devicePopup.querySelector("input");
|
||||||
|
let [assumeButton, createButton] = devicePopup.querySelectorAll("button");
|
||||||
|
|
||||||
// Save Settings (Device ID)
|
// Set Visible
|
||||||
this.saveSettings();
|
devicePopup.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Add Devices
|
||||||
|
fetch("/reader/devices").then(async (r) => {
|
||||||
|
let data = await r.json();
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
let optionEl = document.createElement("option");
|
||||||
|
optionEl.value = item.id;
|
||||||
|
optionEl.textContent = item.device_name;
|
||||||
|
devSelector.appendChild(optionEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assumeButton.addEventListener("click", () => {
|
||||||
|
let deviceID = devSelector.value;
|
||||||
|
|
||||||
|
if (deviceID == "") {
|
||||||
|
// TODO - Error Message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedOption = devSelector.children[devSelector.selectedIndex];
|
||||||
|
let deviceName = selectedOption.textContent;
|
||||||
|
|
||||||
|
this.readerSettings.deviceID = deviceID;
|
||||||
|
this.readerSettings.deviceName = deviceName;
|
||||||
|
this.saveSettings();
|
||||||
|
devicePopup.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton.addEventListener("click", () => {
|
||||||
|
let deviceName = devInput.value.trim();
|
||||||
|
|
||||||
|
if (deviceName == "") {
|
||||||
|
// TODO - Error Message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readerSettings.deviceID = randomID();
|
||||||
|
this.readerSettings.deviceName = deviceName;
|
||||||
|
this.saveSettings();
|
||||||
|
devicePopup.classList.add("hidden");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,7 +246,7 @@ class EBookReader {
|
|||||||
initThemes() {
|
initThemes() {
|
||||||
// Register Themes
|
// Register Themes
|
||||||
THEMES.forEach((theme) =>
|
THEMES.forEach((theme) =>
|
||||||
this.rendition.themes.register(theme, THEME_FILE)
|
this.rendition.themes.register(theme, THEME_FILE),
|
||||||
);
|
);
|
||||||
|
|
||||||
let themeLinkEl = document.createElement("link");
|
let themeLinkEl = document.createElement("link");
|
||||||
@@ -221,25 +269,48 @@ class EBookReader {
|
|||||||
// Restore Theme
|
// Restore Theme
|
||||||
this.setTheme();
|
this.setTheme();
|
||||||
|
|
||||||
// Set Fonts - TODO: Local
|
// Set Fonts
|
||||||
// https://gwfh.mranftl.com/fonts
|
|
||||||
this.rendition.getContents().forEach((c) => {
|
this.rendition.getContents().forEach((c) => {
|
||||||
[
|
let el = c.document.head.appendChild(
|
||||||
"https://fonts.googleapis.com/css?family=Arbutus+Slab",
|
c.document.createElement("link"),
|
||||||
"https://fonts.googleapis.com/css?family=Open+Sans",
|
);
|
||||||
"https://fonts.googleapis.com/css?family=Lato:400,400i,700,700i",
|
el.setAttribute("rel", "stylesheet");
|
||||||
].forEach((url) => {
|
el.setAttribute("href", "/assets/reader/fonts.css");
|
||||||
let el = c.document.head.appendChild(
|
|
||||||
c.document.createElement("link")
|
|
||||||
);
|
|
||||||
el.setAttribute("rel", "stylesheet");
|
|
||||||
el.setAttribute("href", url);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EpubJS will set iframe sandbox when settings "allowScriptedContent: false".
|
||||||
|
* However, Safari completely blocks us from attaching listeners to the iframe
|
||||||
|
* document. So instead we just inject a restrictive CSP rule.
|
||||||
|
*
|
||||||
|
* This effectively blocks all script content within the iframe while still
|
||||||
|
* allowing us to attach listeners to the iframe document.
|
||||||
|
**/
|
||||||
|
initCSP() {
|
||||||
|
// Derive CSP Host
|
||||||
|
var protocol = document.location.protocol;
|
||||||
|
var host = document.location.host;
|
||||||
|
var cspURL = `${protocol}//${host}`;
|
||||||
|
|
||||||
|
// Add CSP Policy
|
||||||
|
this.book.spine.hooks.content.register((output, section) => {
|
||||||
|
let cspWrapper = document.createElement("div");
|
||||||
|
cspWrapper.innerHTML = `
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="require-trusted-types-for 'script';
|
||||||
|
style-src 'self' blob: 'unsafe-inline' ${cspURL};
|
||||||
|
object-src 'none';
|
||||||
|
script-src 'none';"
|
||||||
|
>`;
|
||||||
|
let cspMeta = cspWrapper.children[0];
|
||||||
|
output.head.append(cspMeta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set theme & meta theme color
|
* Set theme & meta theme color
|
||||||
**/
|
**/
|
||||||
@@ -265,7 +336,7 @@ class EBookReader {
|
|||||||
let themeColorEl = document.querySelector("[name='theme-color']");
|
let themeColorEl = document.querySelector("[name='theme-color']");
|
||||||
let themeStyleSheet = document.querySelector("#themes").sheet;
|
let themeStyleSheet = document.querySelector("#themes").sheet;
|
||||||
let themeStyleRule = Array.from(themeStyleSheet.cssRules).find(
|
let themeStyleRule = Array.from(themeStyleSheet.cssRules).find(
|
||||||
(item) => item.selectorText == "." + colorScheme
|
(item) => item.selectorText == "." + colorScheme,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Match Reader Theme
|
// Match Reader Theme
|
||||||
@@ -279,13 +350,13 @@ class EBookReader {
|
|||||||
// Set Font Family
|
// Set Font Family
|
||||||
item.document.documentElement.style.setProperty(
|
item.document.documentElement.style.setProperty(
|
||||||
"--editor-font-family",
|
"--editor-font-family",
|
||||||
fontFamily
|
fontFamily,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set Font Size
|
// Set Font Size
|
||||||
item.document.documentElement.style.setProperty(
|
item.document.documentElement.style.setProperty(
|
||||||
"--editor-font-size",
|
"--editor-font-size",
|
||||||
fontSize + "em"
|
fontSize + "em",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set Highlight Style
|
// Set Highlight Style
|
||||||
@@ -318,7 +389,7 @@ class EBookReader {
|
|||||||
|
|
||||||
// Compute Style
|
// Compute Style
|
||||||
let backgroundColor = getComputedStyle(
|
let backgroundColor = getComputedStyle(
|
||||||
this.bookState.progressElement.ownerDocument.body
|
this.bookState.progressElement.ownerDocument.body,
|
||||||
).backgroundColor;
|
).backgroundColor;
|
||||||
|
|
||||||
// Set Style
|
// Set Style
|
||||||
@@ -332,9 +403,9 @@ class EBookReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendition hooks
|
* Viewer Listeners
|
||||||
**/
|
**/
|
||||||
initRenditionListeners() {
|
initViewerListeners() {
|
||||||
/**
|
/**
|
||||||
* Initiate the debounce when the given function returns true.
|
* Initiate the debounce when the given function returns true.
|
||||||
* Don't run it again until the timeout lapses.
|
* Don't run it again until the timeout lapses.
|
||||||
@@ -362,15 +433,52 @@ class EBookReader {
|
|||||||
let bottomBar = document.querySelector("#bottom-bar");
|
let bottomBar = document.querySelector("#bottom-bar");
|
||||||
|
|
||||||
// Local Functions
|
// Local Functions
|
||||||
let getCFIFromXPath = this.getCFIFromXPath.bind(this);
|
|
||||||
let setPosition = this.setPosition.bind(this);
|
|
||||||
let nextPage = this.nextPage.bind(this);
|
let nextPage = this.nextPage.bind(this);
|
||||||
let prevPage = this.prevPage.bind(this);
|
let prevPage = this.prevPage.bind(this);
|
||||||
let saveSettings = this.saveSettings.bind(this);
|
|
||||||
|
|
||||||
// Local Vars
|
// ------------------------------------------------ //
|
||||||
let readerSettings = this.readerSettings;
|
// ----------------- Swipe Helpers ---------------- //
|
||||||
let bookState = this.bookState;
|
// ------------------------------------------------ //
|
||||||
|
let touchStartX,
|
||||||
|
touchStartY,
|
||||||
|
touchEndX,
|
||||||
|
touchEndY = undefined;
|
||||||
|
|
||||||
|
function handleGesture(event) {
|
||||||
|
let drasticity = 75;
|
||||||
|
|
||||||
|
// Swipe Down
|
||||||
|
if (touchEndY - drasticity > touchStartY) {
|
||||||
|
return handleSwipeDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Up
|
||||||
|
if (touchEndY + drasticity < touchStartY) {
|
||||||
|
// Prioritize Down & Up Swipes
|
||||||
|
return handleSwipeUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Left
|
||||||
|
if (touchEndX + drasticity < touchStartX) {
|
||||||
|
nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Right
|
||||||
|
if (touchEndX - drasticity > touchStartX) {
|
||||||
|
prevPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeDown() {
|
||||||
|
if (bottomBar.classList.contains("bottom-0"))
|
||||||
|
bottomBar.classList.remove("bottom-0");
|
||||||
|
else topBar.classList.add("top-0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeUp() {
|
||||||
|
if (topBar.classList.contains("top-0")) topBar.classList.remove("top-0");
|
||||||
|
else bottomBar.classList.add("bottom-0");
|
||||||
|
}
|
||||||
|
|
||||||
this.rendition.hooks.render.register(function (doc, data) {
|
this.rendition.hooks.render.register(function (doc, data) {
|
||||||
let renderDoc = doc.document;
|
let renderDoc = doc.document;
|
||||||
@@ -379,66 +487,14 @@ class EBookReader {
|
|||||||
// ---------------- Wake Lock Hack ---------------- //
|
// ---------------- Wake Lock Hack ---------------- //
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
let wakeLockListener = function () {
|
let wakeLockListener = function () {
|
||||||
doc.window.parent.document.dispatchEvent(new CustomEvent("wakelock"));
|
renderDoc.dispatchEvent(new CustomEvent("wakelock"));
|
||||||
};
|
};
|
||||||
renderDoc.addEventListener("click", wakeLockListener);
|
renderDoc.addEventListener("click", wakeLockListener);
|
||||||
renderDoc.addEventListener("gesturechange", wakeLockListener);
|
renderDoc.addEventListener("gesturechange", wakeLockListener);
|
||||||
renderDoc.addEventListener("touchstart", wakeLockListener);
|
renderDoc.addEventListener("touchstart", wakeLockListener);
|
||||||
|
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
// --------------- Swipe Pagination --------------- //
|
// --------------- Bars & Page Turn --------------- //
|
||||||
// ------------------------------------------------ //
|
|
||||||
let touchStartX,
|
|
||||||
touchStartY,
|
|
||||||
touchEndX,
|
|
||||||
touchEndY = undefined;
|
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
|
||||||
"touchstart",
|
|
||||||
function (event) {
|
|
||||||
touchStartX = event.changedTouches[0].screenX;
|
|
||||||
touchStartY = event.changedTouches[0].screenY;
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
|
||||||
"touchend",
|
|
||||||
function (event) {
|
|
||||||
touchEndX = event.changedTouches[0].screenX;
|
|
||||||
touchEndY = event.changedTouches[0].screenY;
|
|
||||||
handleGesture(event);
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleGesture(event) {
|
|
||||||
let drasticity = 75;
|
|
||||||
|
|
||||||
// Swipe Down
|
|
||||||
if (touchEndY - drasticity > touchStartY) {
|
|
||||||
return handleSwipeDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swipe Up
|
|
||||||
if (touchEndY + drasticity < touchStartY) {
|
|
||||||
// Prioritize Down & Up Swipes
|
|
||||||
return handleSwipeUp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swipe Left
|
|
||||||
if (touchEndX + drasticity < touchStartX) {
|
|
||||||
nextPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swipe Right
|
|
||||||
if (touchEndX - drasticity > touchStartX) {
|
|
||||||
prevPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------ //
|
|
||||||
// --------------- Bottom & Top Bar --------------- //
|
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
renderDoc.addEventListener(
|
renderDoc.addEventListener(
|
||||||
"click",
|
"click",
|
||||||
@@ -473,7 +529,7 @@ class EBookReader {
|
|||||||
bottomBar.classList.remove("bottom-0");
|
bottomBar.classList.remove("bottom-0");
|
||||||
topBar.classList.remove("top-0");
|
topBar.classList.remove("top-0");
|
||||||
}
|
}
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
renderDoc.addEventListener(
|
||||||
@@ -487,50 +543,30 @@ class EBookReader {
|
|||||||
handleSwipeDown();
|
handleSwipeDown();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}, 400)
|
}, 400),
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleSwipeDown() {
|
|
||||||
if (bottomBar.classList.contains("bottom-0"))
|
|
||||||
bottomBar.classList.remove("bottom-0");
|
|
||||||
else topBar.classList.add("top-0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSwipeUp() {
|
|
||||||
if (topBar.classList.contains("top-0"))
|
|
||||||
topBar.classList.remove("top-0");
|
|
||||||
else bottomBar.classList.add("bottom-0");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
// -------------- Keyboard Shortcuts -------------- //
|
// ------------------- Gestures ------------------- //
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
renderDoc.addEventListener(
|
||||||
"keyup",
|
"touchstart",
|
||||||
function (e) {
|
function (event) {
|
||||||
// Left Key (Previous Page)
|
touchStartX = event.changedTouches[0].screenX;
|
||||||
if ((e.keyCode || e.which) == 37) {
|
touchStartY = event.changedTouches[0].screenY;
|
||||||
prevPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right Key (Next Page)
|
|
||||||
if ((e.keyCode || e.which) == 39) {
|
|
||||||
nextPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// "t" Key (Theme Cycle)
|
|
||||||
if ((e.keyCode || e.which) == 84) {
|
|
||||||
let currentThemeIdx = THEMES.indexOf(
|
|
||||||
readerSettings.theme.colorScheme
|
|
||||||
);
|
|
||||||
let colorScheme =
|
|
||||||
THEMES.length == currentThemeIdx + 1
|
|
||||||
? THEMES[0]
|
|
||||||
: THEMES[currentThemeIdx + 1];
|
|
||||||
setTheme({ colorScheme });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
false
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
function (event) {
|
||||||
|
touchEndX = event.changedTouches[0].screenX;
|
||||||
|
touchEndY = event.changedTouches[0].screenY;
|
||||||
|
handleGesture(event);
|
||||||
|
},
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -545,7 +581,9 @@ class EBookReader {
|
|||||||
let nextPage = this.nextPage.bind(this);
|
let nextPage = this.nextPage.bind(this);
|
||||||
let prevPage = this.prevPage.bind(this);
|
let prevPage = this.prevPage.bind(this);
|
||||||
|
|
||||||
// Keyboard Shortcuts
|
// ------------------------------------------------ //
|
||||||
|
// -------------- Keyboard Shortcuts -------------- //
|
||||||
|
// ------------------------------------------------ //
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"keyup",
|
"keyup",
|
||||||
function (e) {
|
function (e) {
|
||||||
@@ -562,7 +600,7 @@ class EBookReader {
|
|||||||
// "t" Key (Theme Cycle)
|
// "t" Key (Theme Cycle)
|
||||||
if ((e.keyCode || e.which) == 84) {
|
if ((e.keyCode || e.which) == 84) {
|
||||||
let currentThemeIdx = THEMES.indexOf(
|
let currentThemeIdx = THEMES.indexOf(
|
||||||
this.readerSettings.theme.colorScheme
|
this.readerSettings.theme.colorScheme,
|
||||||
);
|
);
|
||||||
let colorScheme =
|
let colorScheme =
|
||||||
THEMES.length == currentThemeIdx + 1
|
THEMES.length == currentThemeIdx + 1
|
||||||
@@ -571,7 +609,7 @@ class EBookReader {
|
|||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}
|
}
|
||||||
}.bind(this),
|
}.bind(this),
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Color Scheme Switcher
|
// Color Scheme Switcher
|
||||||
@@ -582,9 +620,9 @@ class EBookReader {
|
|||||||
function (event) {
|
function (event) {
|
||||||
let colorScheme = event.target.innerText;
|
let colorScheme = event.target.innerText;
|
||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Font Switcher
|
// Font Switcher
|
||||||
@@ -599,9 +637,9 @@ class EBookReader {
|
|||||||
this.setTheme({ fontFamily });
|
this.setTheme({ fontFamily });
|
||||||
|
|
||||||
this.setPosition(cfi);
|
this.setPosition(cfi);
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Font Size
|
// Font Size
|
||||||
@@ -624,9 +662,9 @@ class EBookReader {
|
|||||||
|
|
||||||
// Restore CFI
|
// Restore CFI
|
||||||
this.setPosition(cfi);
|
this.setPosition(cfi);
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
}.bind(this)
|
}.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close Top Bar
|
// Close Top Bar
|
||||||
@@ -713,7 +751,7 @@ class EBookReader {
|
|||||||
if (pageWPM >= WPM_MAX)
|
if (pageWPM >= WPM_MAX)
|
||||||
return console.log(
|
return console.log(
|
||||||
"[createActivity] Page WPM Exceeds Max (2000):",
|
"[createActivity] Page WPM Exceeds Max (2000):",
|
||||||
pageWPM
|
pageWPM,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure WPM Minimum
|
// Ensure WPM Minimum
|
||||||
@@ -726,7 +764,7 @@ class EBookReader {
|
|||||||
return console.warn("[createActivity] Invalid Total Pages (0)");
|
return console.warn("[createActivity] Invalid Total Pages (0)");
|
||||||
|
|
||||||
let currentPage = Math.round(
|
let currentPage = Math.round(
|
||||||
(currentWord * totalPages) / this.bookState.words
|
(currentWord * totalPages) / this.bookState.words,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create Activity Event
|
// Create Activity Event
|
||||||
@@ -780,7 +818,7 @@ class EBookReader {
|
|||||||
response: r,
|
response: r,
|
||||||
json: await r.json(),
|
json: await r.json(),
|
||||||
data: activityEvent,
|
data: activityEvent,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,7 +879,7 @@ class EBookReader {
|
|||||||
response: r,
|
response: r,
|
||||||
json: await r.json(),
|
json: await r.json(),
|
||||||
data: progressEvent,
|
data: progressEvent,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +915,7 @@ class EBookReader {
|
|||||||
let currentWord = await this.getBookWordPosition();
|
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -914,7 +952,7 @@ class EBookReader {
|
|||||||
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) =>
|
||||||
startCFI.startsWith(item.cfiBase)
|
startCFI.startsWith(item.cfiBase),
|
||||||
).index + 1;
|
).index + 1;
|
||||||
|
|
||||||
// Base Progress
|
// Base Progress
|
||||||
@@ -1062,7 +1100,7 @@ class EBookReader {
|
|||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1107,7 +1145,7 @@ class EBookReader {
|
|||||||
|
|
||||||
// Get CFI Range
|
// Get CFI Range
|
||||||
let firstCFI = spineItem.cfiFromElement(
|
let firstCFI = spineItem.cfiFromElement(
|
||||||
spineItem.document.body.children[0]
|
spineItem.document.body.children[0],
|
||||||
);
|
);
|
||||||
let currentLocation = await this.rendition.currentLocation();
|
let currentLocation = await this.rendition.currentLocation();
|
||||||
let cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
let cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||||
@@ -1208,7 +1246,7 @@ class EBookReader {
|
|||||||
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
||||||
item.wordCount = spineWords;
|
item.wordCount = spineWords;
|
||||||
return spineWords;
|
return spineWords;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||||
@@ -1227,9 +1265,20 @@ class EBookReader {
|
|||||||
**/
|
**/
|
||||||
loadSettings() {
|
loadSettings() {
|
||||||
this.readerSettings = JSON.parse(
|
this.readerSettings = JSON.parse(
|
||||||
localStorage.getItem("readerSettings") || "{}"
|
localStorage.getItem("readerSettings") || "{}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", initReader);
|
document.addEventListener("DOMContentLoaded", initReader);
|
||||||
|
|
||||||
|
// WIP
|
||||||
|
async function getTOC() {
|
||||||
|
let toc = currentReader.book.navigation.toc;
|
||||||
|
|
||||||
|
// Alternatively:
|
||||||
|
// let nav = await currentReader.book.loaded.navigation;
|
||||||
|
// let toc = nav.toc;
|
||||||
|
|
||||||
|
currentReader.rendition.display(nav.toc[10].href);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
60
assets/sw.js
60
assets/sw.js
@@ -38,13 +38,14 @@ const ROUTES = [
|
|||||||
{ route: "/local", type: CACHE_UPDATE_ASYNC },
|
{ route: "/local", type: CACHE_UPDATE_ASYNC },
|
||||||
{ route: "/reader", type: CACHE_UPDATE_ASYNC },
|
{ route: "/reader", type: CACHE_UPDATE_ASYNC },
|
||||||
{ route: "/manifest.json", type: CACHE_UPDATE_ASYNC },
|
{ route: "/manifest.json", type: CACHE_UPDATE_ASYNC },
|
||||||
|
{ route: /^\/assets\/reader\/fonts\//, type: CACHE_ONLY },
|
||||||
{ route: /^\/assets\//, type: CACHE_UPDATE_ASYNC },
|
{ route: /^\/assets\//, type: CACHE_UPDATE_ASYNC },
|
||||||
{
|
{
|
||||||
route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/,
|
route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/,
|
||||||
type: CACHE_UPDATE_ASYNC,
|
type: CACHE_UPDATE_ASYNC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: /^\/documents\/[a-zA-Z0-9]{32}\/progress$/,
|
route: /^\/reader\/progress\/[a-zA-Z0-9]{32}$/,
|
||||||
type: CACHE_UPDATE_SYNC,
|
type: CACHE_UPDATE_SYNC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,9 +64,10 @@ const PRECACHE_ASSETS = [
|
|||||||
"/reader",
|
"/reader",
|
||||||
"/assets/local/index.js",
|
"/assets/local/index.js",
|
||||||
"/assets/reader/index.js",
|
"/assets/reader/index.js",
|
||||||
|
"/assets/reader/fonts.css",
|
||||||
|
"/assets/reader/themes.css",
|
||||||
"/assets/icons/icon512.png",
|
"/assets/icons/icon512.png",
|
||||||
"/assets/images/no-cover.jpg",
|
"/assets/images/no-cover.jpg",
|
||||||
"/assets/reader/readerThemes.css",
|
|
||||||
|
|
||||||
// Main App Assets
|
// Main App Assets
|
||||||
"/manifest.json",
|
"/manifest.json",
|
||||||
@@ -74,11 +76,23 @@ const PRECACHE_ASSETS = [
|
|||||||
"/assets/common.js",
|
"/assets/common.js",
|
||||||
|
|
||||||
// Library Assets
|
// Library Assets
|
||||||
"/assets/lib/platform.min.js",
|
|
||||||
"/assets/lib/jszip.min.js",
|
"/assets/lib/jszip.min.js",
|
||||||
"/assets/lib/epub.min.js",
|
"/assets/lib/epub.min.js",
|
||||||
"/assets/lib/no-sleep.min.js",
|
"/assets/lib/no-sleep.min.js",
|
||||||
"/assets/lib/idb-keyval.min.js",
|
"/assets/lib/idb-keyval.min.js",
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
"/assets/reader/fonts/arbutus-slab-v16-latin_latin-ext-regular.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-100.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-100italic.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-700.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-700italic.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-italic.woff2",
|
||||||
|
"/assets/reader/fonts/lato-v24-latin_latin-ext-regular.woff2",
|
||||||
|
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-700.woff2",
|
||||||
|
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-700italic.woff2",
|
||||||
|
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-italic.woff2",
|
||||||
|
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-regular.woff2",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------- //
|
// ------------------------------------------------------- //
|
||||||
@@ -120,7 +134,9 @@ async function handleFetch(event) {
|
|||||||
|
|
||||||
// Find Directive
|
// Find Directive
|
||||||
const directive = ROUTES.find(
|
const directive = ROUTES.find(
|
||||||
(item) => url.match(item.route) || url == item.route
|
(item) =>
|
||||||
|
(item.route instanceof RegExp && url.match(item.route)) ||
|
||||||
|
url == item.route
|
||||||
) || { type: CACHE_NEVER };
|
) || { type: CACHE_NEVER };
|
||||||
|
|
||||||
// Get Fallback
|
// Get Fallback
|
||||||
@@ -170,12 +186,22 @@ function handleMessage(event) {
|
|||||||
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
||||||
let allKeys = await cache.keys();
|
let allKeys = await cache.keys();
|
||||||
|
|
||||||
|
// Get Cached Resources
|
||||||
let docResources = allKeys
|
let docResources = allKeys
|
||||||
.map((item) => new URL(item.url).pathname)
|
.map((item) => new URL(item.url).pathname)
|
||||||
.filter((item) => item.startsWith("/documents/"));
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.startsWith("/documents/") ||
|
||||||
|
item.startsWith("/reader/progress/")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive Unique IDs
|
||||||
let documentIDs = Array.from(
|
let documentIDs = Array.from(
|
||||||
new Set(docResources.map((item) => item.split("/")[2]))
|
new Set(
|
||||||
|
docResources
|
||||||
|
.filter((item) => item.startsWith("/documents/"))
|
||||||
|
.map((item) => item.split("/")[2])
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,10 +214,10 @@ function handleMessage(event) {
|
|||||||
.filter(
|
.filter(
|
||||||
(id) =>
|
(id) =>
|
||||||
docResources.includes("/documents/" + id + "/file") &&
|
docResources.includes("/documents/" + id + "/file") &&
|
||||||
docResources.includes("/documents/" + id + "/progress")
|
docResources.includes("/reader/progress/" + id)
|
||||||
)
|
)
|
||||||
.map(async (id) => {
|
.map(async (id) => {
|
||||||
let url = "/documents/" + id + "/progress";
|
let url = "/reader/progress/" + id;
|
||||||
let currentCache = await caches.match(url);
|
let currentCache = await caches.match(url);
|
||||||
let resp = await updateCache(url).catch((e) => currentCache);
|
let resp = await updateCache(url).catch((e) => currentCache);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
@@ -201,13 +227,12 @@ function handleMessage(event) {
|
|||||||
event.source.postMessage({ id, data: cachedDocuments });
|
event.source.postMessage({ id, data: cachedDocuments });
|
||||||
});
|
});
|
||||||
} else if (data.type === DEL_SW_CACHE) {
|
} else if (data.type === DEL_SW_CACHE) {
|
||||||
let basePath = "/documents/" + data.id;
|
|
||||||
caches
|
caches
|
||||||
.open(SW_CACHE_NAME)
|
.open(SW_CACHE_NAME)
|
||||||
.then((cache) =>
|
.then((cache) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
cache.delete(basePath + "/file"),
|
cache.delete("/documents/" + data.id + "/file"),
|
||||||
cache.delete(basePath + "/progress"),
|
cache.delete("/reader/progress/" + data.id),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
||||||
@@ -228,6 +253,13 @@ self.addEventListener("install", function (event) {
|
|||||||
event.waitUntil(handleInstall(event));
|
event.waitUntil(handleInstall(event));
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) =>
|
self.addEventListener("fetch", (event) => {
|
||||||
event.respondWith(handleFetch(event))
|
/**
|
||||||
);
|
* Weird things happen when a service worker attempts to handle a request
|
||||||
|
* when the server responds with chunked transfer encoding. Right now we only
|
||||||
|
* use chunked encoding on POSTs. So this is to avoid processing those.
|
||||||
|
**/
|
||||||
|
|
||||||
|
if (event.request.method != "GET") return;
|
||||||
|
return event.respondWith(handleFetch(event));
|
||||||
|
});
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ function SyncNinja:userLogin(username, password, menu)
|
|||||||
self.settings.password = userkey
|
self.settings.password = userkey
|
||||||
if menu then menu:updateItems() end
|
if menu then menu:updateItems() end
|
||||||
UIManager:show(InfoMessage:new{
|
UIManager:show(InfoMessage:new{
|
||||||
text = _("Logged in to KOReader server.")
|
text = _("Logged in to AnthoLume server.")
|
||||||
})
|
})
|
||||||
|
|
||||||
self:schedulePeriodicPush(0)
|
self:schedulePeriodicPush(0)
|
||||||
@@ -532,7 +532,7 @@ function SyncNinja:userRegister(username, password, menu)
|
|||||||
self.settings.password = userkey
|
self.settings.password = userkey
|
||||||
if menu then menu:updateItems() end
|
if menu then menu:updateItems() end
|
||||||
UIManager:show(InfoMessage:new{
|
UIManager:show(InfoMessage:new{
|
||||||
text = _("Registered to KOReader server.")
|
text = _("Registered to AnthoLume server.")
|
||||||
})
|
})
|
||||||
|
|
||||||
self:schedulePeriodicPush(0)
|
self:schedulePeriodicPush(0)
|
||||||
|
|||||||
115
config/config.go
115
config/config.go
@@ -1,8 +1,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -22,28 +28,100 @@ type Config struct {
|
|||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
SearchEnabled bool
|
SearchEnabled bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
|
LogLevel string
|
||||||
|
|
||||||
// Cookie Settings
|
// Cookie Settings
|
||||||
CookieSessionKey string
|
CookieAuthKey string
|
||||||
CookieSecure bool
|
CookieEncKey string
|
||||||
CookieHTTPOnly bool
|
CookieSecure bool
|
||||||
|
CookieHTTPOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type customFormatter struct {
|
||||||
|
log.Formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force UTC & Set type (app)
|
||||||
|
func (cf customFormatter) Format(e *log.Entry) ([]byte, error) {
|
||||||
|
if e.Data["type"] == nil {
|
||||||
|
e.Data["type"] = "app"
|
||||||
|
}
|
||||||
|
e.Time = e.Time.UTC()
|
||||||
|
return cf.Formatter.Format(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set at runtime
|
||||||
|
var version string = "develop"
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
c := &Config{
|
||||||
Version: "0.0.1",
|
Version: version,
|
||||||
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
|
||||||
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"),
|
||||||
|
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
||||||
|
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
||||||
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
|
||||||
DemoMode: trimLowerString(getEnv("DEMO_MODE", "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", "")),
|
CookieAuthKey: trimLowerString(getEnv("COOKIE_AUTH_KEY", "")),
|
||||||
|
CookieEncKey: trimLowerString(getEnv("COOKIE_ENC_KEY", "")),
|
||||||
|
LogLevel: trimLowerString(getEnv("LOG_LEVEL", "info")),
|
||||||
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
||||||
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
|
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse log level
|
||||||
|
logLevel, err := log.ParseLevel(c.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
logLevel = log.InfoLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create custom formatter
|
||||||
|
logFormatter := &customFormatter{&log.JSONFormatter{
|
||||||
|
CallerPrettyfier: prettyCaller,
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Create log rotator
|
||||||
|
rotateFileHook, err := NewRotateFileHook(RotateFileConfig{
|
||||||
|
Filename: path.Join(c.ConfigPath, "logs/antholume.log"),
|
||||||
|
MaxSize: 50,
|
||||||
|
MaxBackups: 3,
|
||||||
|
MaxAge: 30,
|
||||||
|
Level: logLevel,
|
||||||
|
Formatter: logFormatter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Unable to initialize file rotate hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate now
|
||||||
|
rotateFileHook.Rotate()
|
||||||
|
|
||||||
|
// Set logger settings
|
||||||
|
log.SetLevel(logLevel)
|
||||||
|
log.SetFormatter(logFormatter)
|
||||||
|
log.SetReportCaller(true)
|
||||||
|
log.AddHook(rotateFileHook)
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
c.EnsureDirectories()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures needed directories exist
|
||||||
|
func (c *Config) EnsureDirectories() {
|
||||||
|
os.Mkdir(c.ConfigPath, 0755)
|
||||||
|
os.Mkdir(c.DataPath, 0755)
|
||||||
|
|
||||||
|
docDir := filepath.Join(c.DataPath, "documents")
|
||||||
|
coversDir := filepath.Join(c.DataPath, "covers")
|
||||||
|
backupDir := filepath.Join(c.DataPath, "backups")
|
||||||
|
|
||||||
|
os.Mkdir(docDir, 0755)
|
||||||
|
os.Mkdir(coversDir, 0755)
|
||||||
|
os.Mkdir(backupDir, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
@@ -56,3 +134,24 @@ func getEnv(key, fallback string) string {
|
|||||||
func trimLowerString(val string) string {
|
func trimLowerString(val string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(val))
|
return strings.ToLower(strings.TrimSpace(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prettyCaller(f *runtime.Frame) (function string, file string) {
|
||||||
|
purgePrefix := "reichard.io/antholume/"
|
||||||
|
|
||||||
|
pathName := strings.Replace(f.Func.Name(), purgePrefix, "", 1)
|
||||||
|
parts := strings.Split(pathName, ".")
|
||||||
|
|
||||||
|
filepath, line := f.Func.FileLine(f.PC)
|
||||||
|
splitFilePath := strings.Split(filepath, "/")
|
||||||
|
|
||||||
|
fileName := fmt.Sprintf("%s/%s@%d", parts[0], splitFilePath[len(splitFilePath)-1], line)
|
||||||
|
functionName := strings.Replace(pathName, parts[0]+".", "", 1)
|
||||||
|
|
||||||
|
// Exclude GIN Logger
|
||||||
|
if functionName == "NewApi.apiLogger.func1" {
|
||||||
|
fileName = ""
|
||||||
|
functionName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return functionName, fileName
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
func TestLoadConfig(t *testing.T) {
|
func TestLoadConfig(t *testing.T) {
|
||||||
conf := Load()
|
conf := Load()
|
||||||
want := "sqlite"
|
assert.Equal(t, "sqlite", conf.DBType)
|
||||||
if conf.DBType != want {
|
|
||||||
t.Fatalf(`Load().DBType = %q, want match for %#q, nil`, conf.DBType, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetEnvDefault(t *testing.T) {
|
func TestGetEnvDefault(t *testing.T) {
|
||||||
want := "def_val"
|
desiredValue := "def_val"
|
||||||
envDefault := getEnv("DEFAULT_TEST", want)
|
envDefault := getEnv("DEFAULT_TEST", desiredValue)
|
||||||
if envDefault != want {
|
|
||||||
t.Fatalf(`getEnv("DEFAULT_TEST", "def_val") = %q, want match for %#q, nil`, envDefault, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnvSet(t *testing.T) {
|
assert.Equal(t, desiredValue, envDefault)
|
||||||
envDefault := getEnv("SET_TEST", "not_this")
|
|
||||||
want := "set_val"
|
|
||||||
if envDefault != want {
|
|
||||||
t.Fatalf(`getEnv("SET_TEST", "not_this") = %q, want match for %#q, nil`, envDefault, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrimLowerString(t *testing.T) {
|
func TestTrimLowerString(t *testing.T) {
|
||||||
want := "trimtest"
|
desiredValue := "trimtest"
|
||||||
output := trimLowerString(" trimTest ")
|
outputValue := trimLowerString(" trimTest ")
|
||||||
if output != want {
|
|
||||||
t.Fatalf(`trimLowerString(" trimTest ") = %q, want match for %#q, nil`, output, want)
|
assert.Equal(t, desiredValue, outputValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrettyCaller(t *testing.T) {
|
||||||
|
p, _, _, _ := runtime.Caller(0)
|
||||||
|
result := runtime.CallersFrames([]uintptr{p})
|
||||||
|
f, _ := result.Next()
|
||||||
|
functionName, fileName := prettyCaller(&f)
|
||||||
|
|
||||||
|
assert.Equal(t, "TestPrettyCaller", functionName, "should have current function name")
|
||||||
|
assert.Equal(t, "config/config_test.go@30", fileName, "should have current file path and line number")
|
||||||
}
|
}
|
||||||
|
|||||||
54
config/logger.go
Normal file
54
config/logger.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modified "snowzach/rotatefilehook" to support manual rotation
|
||||||
|
|
||||||
|
type RotateFileConfig struct {
|
||||||
|
Filename string
|
||||||
|
MaxSize int
|
||||||
|
MaxBackups int
|
||||||
|
MaxAge int
|
||||||
|
Compress bool
|
||||||
|
Level logrus.Level
|
||||||
|
Formatter logrus.Formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
type RotateFileHook struct {
|
||||||
|
Config RotateFileConfig
|
||||||
|
logWriter *lumberjack.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRotateFileHook(config RotateFileConfig) (*RotateFileHook, error) {
|
||||||
|
hook := RotateFileHook{
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
hook.logWriter = &lumberjack.Logger{
|
||||||
|
Filename: config.Filename,
|
||||||
|
MaxSize: config.MaxSize,
|
||||||
|
MaxBackups: config.MaxBackups,
|
||||||
|
MaxAge: config.MaxAge,
|
||||||
|
Compress: config.Compress,
|
||||||
|
}
|
||||||
|
return &hook, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *RotateFileHook) Rotate() error {
|
||||||
|
return hook.logWriter.Rotate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *RotateFileHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels[:hook.Config.Level+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hook *RotateFileHook) Fire(entry *logrus.Entry) (err error) {
|
||||||
|
b, err := hook.Config.Formatter.Format(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hook.logWriter.Write(b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.21.0
|
// sqlc v1.25.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -3,84 +3,190 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"embed"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"path"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/bbank/config"
|
_ "reichard.io/antholume/database/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DBManager struct {
|
type DBManager struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Queries *Queries
|
Queries *Queries
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed schema.sql
|
//go:embed schema.sql
|
||||||
var ddl string
|
var ddl string
|
||||||
|
|
||||||
//go:embed update_temp_tables.sql
|
//go:embed migrations/*
|
||||||
var tsql string
|
var migrations embed.FS
|
||||||
|
|
||||||
//go:embed update_document_user_statistics.sql
|
|
||||||
var doc_user_stat_sql string
|
|
||||||
|
|
||||||
|
// Returns an initialized manager
|
||||||
func NewMgr(c *config.Config) *DBManager {
|
func NewMgr(c *config.Config) *DBManager {
|
||||||
// Create Manager
|
// Create Manager
|
||||||
dbm := &DBManager{
|
dbm := &DBManager{
|
||||||
Ctx: context.Background(),
|
Ctx: context.Background(),
|
||||||
|
cfg: c,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Database
|
if err := dbm.init(); err != nil {
|
||||||
if c.DBType == "sqlite" || c.DBType == "memory" {
|
log.Panic("Unable to init DB")
|
||||||
var dbLocation string = ":memory:"
|
|
||||||
if c.DBType == "sqlite" {
|
|
||||||
dbLocation = path.Join(c.ConfigPath, fmt.Sprintf("%s.db", c.DBName))
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
dbm.DB, err = sql.Open("sqlite", dbLocation)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single Open Connection
|
|
||||||
dbm.DB.SetMaxOpenConns(1)
|
|
||||||
if _, err := dbm.DB.Exec(ddl, nil); err != nil {
|
|
||||||
log.Info("Exec Error:", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Fatal("Unsupported Database")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbm.Queries = New(dbm.DB)
|
|
||||||
|
|
||||||
return dbm
|
return dbm
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dbm *DBManager) Shutdown() error {
|
// Init manager
|
||||||
return dbm.DB.Close()
|
func (dbm *DBManager) init() error {
|
||||||
|
// Build DB Location
|
||||||
|
var dbLocation string
|
||||||
|
switch dbm.cfg.DBType {
|
||||||
|
case "sqlite":
|
||||||
|
dbLocation = filepath.Join(dbm.cfg.ConfigPath, fmt.Sprintf("%s.db", dbm.cfg.DBName))
|
||||||
|
case "memory":
|
||||||
|
dbLocation = ":memory:"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported database")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
dbm.DB, err = sql.Open("sqlite", dbLocation)
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf("Unable to open DB: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single open connection
|
||||||
|
dbm.DB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
// Check if DB is new
|
||||||
|
isNew, err := isEmpty(dbm.DB)
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf("Unable to determine db info: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init SQLc
|
||||||
|
dbm.Queries = New(dbm.DB)
|
||||||
|
|
||||||
|
// Execute schema
|
||||||
|
if _, err := dbm.DB.Exec(ddl, nil); err != nil {
|
||||||
|
log.Panicf("Error executing schema: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform migrations
|
||||||
|
err = dbm.performMigrations(isNew)
|
||||||
|
if err != nil && err != goose.ErrNoMigrationFiles {
|
||||||
|
log.Panicf("Error running DB migrations: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
err = dbm.updateSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf("Error running DB settings update: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache tables
|
||||||
|
go dbm.CacheTempTables()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dbm *DBManager) UpdateDocumentUserStatistic(documentID string, userID string) error {
|
// Reload manager (close DB & reinit)
|
||||||
// Prepare Statement
|
func (dbm *DBManager) Reload() error {
|
||||||
stmt, err := dbm.DB.PrepareContext(dbm.Ctx, doc_user_stat_sql)
|
// Close handle
|
||||||
|
err := dbm.DB.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
// Execute
|
// Reinit DB
|
||||||
if _, err := stmt.ExecContext(dbm.Ctx, documentID, userID); err != nil {
|
if err := dbm.init(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dbm *DBManager) CacheTempTables() error {
|
func (dbm *DBManager) CacheTempTables() error {
|
||||||
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
|
start := time.Now()
|
||||||
|
user_streaks_sql := `
|
||||||
|
DELETE FROM user_streaks;
|
||||||
|
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
||||||
|
`
|
||||||
|
if _, err := dbm.DB.ExecContext(dbm.Ctx, user_streaks_sql); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Debug("Cached 'user_streaks' in: ", time.Since(start))
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
document_statistics_sql := `
|
||||||
|
DELETE FROM document_user_statistics;
|
||||||
|
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
|
||||||
|
`
|
||||||
|
if _, err := dbm.DB.ExecContext(dbm.Ctx, document_statistics_sql); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Debug("Cached 'document_user_statistics' in: ", time.Since(start))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dbm *DBManager) updateSettings() error {
|
||||||
|
// Set SQLite PRAGMA Settings
|
||||||
|
pragmaQuery := `
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
`
|
||||||
|
if _, err := dbm.DB.Exec(pragmaQuery, nil); err != nil {
|
||||||
|
log.Errorf("Error executing pragma: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Antholume Version in DB
|
||||||
|
if _, err := dbm.Queries.UpdateSettings(dbm.Ctx, UpdateSettingsParams{
|
||||||
|
Name: "version",
|
||||||
|
Value: dbm.cfg.Version,
|
||||||
|
}); err != nil {
|
||||||
|
log.Errorf("Error updating DB settings: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dbm *DBManager) performMigrations(isNew bool) error {
|
||||||
|
// Create context
|
||||||
|
ctx := context.WithValue(context.Background(), "isNew", isNew)
|
||||||
|
|
||||||
|
// Set DB migration
|
||||||
|
goose.SetBaseFS(migrations)
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
goose.SetLogger(log.StandardLogger())
|
||||||
|
if err := goose.SetDialect("sqlite"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return goose.UpContext(ctx, dbm.DB, "migrations")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmpty(db *sql.DB) (bool, error) {
|
||||||
|
var tableCount int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table';").Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tableCount == 0, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"reichard.io/bbank/config"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type databaseTest struct {
|
type databaseTest struct {
|
||||||
@@ -26,9 +29,7 @@ func TestNewMgr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbm := NewMgr(&cfg)
|
dbm := NewMgr(&cfg)
|
||||||
if dbm == nil {
|
assert.NotNil(t, dbm, "should not have nil dbm")
|
||||||
t.Fatalf(`Expected: *DBManager, Got: nil`)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("Database", func(t *testing.T) {
|
t.Run("Database", func(t *testing.T) {
|
||||||
dt := databaseTest{t, dbm}
|
dt := databaseTest{t, dbm}
|
||||||
@@ -42,19 +43,24 @@ func TestNewMgr(t *testing.T) {
|
|||||||
|
|
||||||
func (dt *databaseTest) TestUser() {
|
func (dt *databaseTest) TestUser() {
|
||||||
dt.Run("User", func(t *testing.T) {
|
dt.Run("User", func(t *testing.T) {
|
||||||
|
// Generate Auth Hash
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
assert.Nil(t, err, "should have nil err")
|
||||||
|
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{
|
changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Pass: &userPass,
|
Pass: &userPass,
|
||||||
|
AuthHash: &authHash,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil || changed != 1 {
|
assert.Nil(t, err, "should have nil err")
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, changed, err)
|
assert.Equal(t, int64(1), changed)
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID)
|
user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID)
|
||||||
if err != nil || *user.Pass != userPass {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, userPass, *user.Pass, err)
|
assert.Nil(t, err, "should have nil err")
|
||||||
}
|
assert.Equal(t, userPass, *user.Pass)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,21 +72,10 @@ func (dt *databaseTest) TestDocument() {
|
|||||||
Author: &documentAuthor,
|
Author: &documentAuthor,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
assert.Nil(t, err, "should have nil err")
|
||||||
t.Fatalf(`Expected: Document, Got: %v, Error: %v`, doc, err)
|
assert.Equal(t, documentID, doc.ID, "should have document id")
|
||||||
}
|
assert.Equal(t, documentTitle, *doc.Title, "should have document title")
|
||||||
|
assert.Equal(t, documentAuthor, *doc.Author, "should have document author")
|
||||||
if doc.ID != documentID {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, documentID, doc.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *doc.Title != documentTitle {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, documentTitle, *doc.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *doc.Author != documentAuthor {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, documentAuthor, *doc.Author)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,21 +87,10 @@ func (dt *databaseTest) TestDevice() {
|
|||||||
DeviceName: deviceName,
|
DeviceName: deviceName,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
assert.Nil(t, err, "should have nil err")
|
||||||
t.Fatalf(`Expected: Device, Got: %v, Error: %v`, device, err)
|
assert.Equal(t, deviceID, device.ID, "should have device id")
|
||||||
}
|
assert.Equal(t, userID, device.UserID, "should have user id")
|
||||||
|
assert.Equal(t, deviceName, device.DeviceName, "should have device name")
|
||||||
if device.ID != deviceID {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, deviceID, device.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if device.UserID != userID {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, userID, device.UserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if device.DeviceName != deviceName {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, deviceName, device.DeviceName)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,15 +115,8 @@ func (dt *databaseTest) TestActivity() {
|
|||||||
EndPercentage: float64(counter+1) / 100.0,
|
EndPercentage: float64(counter+1) / 100.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate No Error
|
assert.Nil(t, err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
|
||||||
if err != nil {
|
assert.Equal(t, counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
|
||||||
t.Fatalf(`expected: rawactivity, got: %v, error: %v`, activity, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Auto Increment Working
|
|
||||||
if activity.ID != counter {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, counter, activity.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initiate Cache
|
// Initiate Cache
|
||||||
@@ -152,13 +129,8 @@ func (dt *databaseTest) TestActivity() {
|
|||||||
Limit: 50,
|
Limit: 50,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
assert.Nil(t, err, "should have nil err for get activity")
|
||||||
t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, existsRows, err)
|
assert.Len(t, existsRows, 10, "should have correct number of rows get activity")
|
||||||
}
|
|
||||||
|
|
||||||
if len(existsRows) != 10 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, 10, len(existsRows))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Doesn't Exist
|
// Validate Doesn't Exist
|
||||||
doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
|
doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
|
||||||
@@ -169,13 +141,8 @@ func (dt *databaseTest) TestActivity() {
|
|||||||
Limit: 50,
|
Limit: 50,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
assert.Nil(t, err, "should have nil err for get activity")
|
||||||
t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, doesntExistsRows, err)
|
assert.Len(t, doesntExistsRows, 0, "should have no rows")
|
||||||
}
|
|
||||||
|
|
||||||
if len(doesntExistsRows) != 0 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, 0, len(doesntExistsRows))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,29 +150,19 @@ func (dt *databaseTest) TestDailyReadStats() {
|
|||||||
dt.Run("DailyReadStats", func(t *testing.T) {
|
dt.Run("DailyReadStats", func(t *testing.T) {
|
||||||
readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID)
|
readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID)
|
||||||
|
|
||||||
if err != nil {
|
assert.Nil(t, err, "should have nil err")
|
||||||
t.Fatalf(`Expected: []GetDailyReadStatsRow, Got: %v, Error: %v`, readStats, err)
|
assert.Len(t, readStats, 30, "should have length of 30")
|
||||||
}
|
|
||||||
|
|
||||||
// Validate 30 Days Stats
|
|
||||||
if len(readStats) != 30 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, 30, len(readStats))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate 1 Minute / Day - Last 10 Days
|
// Validate 1 Minute / Day - Last 10 Days
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
stat := readStats[i]
|
stat := readStats[i]
|
||||||
if stat.MinutesRead != 1 {
|
assert.Equal(t, int64(1), stat.MinutesRead, "should have one minute read")
|
||||||
t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 1, stat.MinutesRead)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate 0 Minute / Day - Remaining 20 Days
|
// Validate 0 Minute / Day - Remaining 20 Days
|
||||||
for i := 10; i < 30; i++ {
|
for i := 10; i < 30; i++ {
|
||||||
stat := readStats[i]
|
stat := readStats[i]
|
||||||
if stat.MinutesRead != 0 {
|
assert.Equal(t, int64(0), stat.MinutesRead, "should have zero minutes read")
|
||||||
t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 0, stat.MinutesRead)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
89
database/migrations/20240128012356_user_auth_hash.go
Normal file
89
database/migrations/20240128012356_user_auth_hash.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upUserAuthHash, downUserAuthHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upUserAuthHash(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// Determine if we have a new DB or not
|
||||||
|
isNew := ctx.Value("isNew").(bool)
|
||||||
|
if isNew {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy table & create column
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
-- Create Copy Table
|
||||||
|
CREATE TABLE temp_users AS SELECT * FROM users;
|
||||||
|
ALTER TABLE temp_users ADD COLUMN auth_hash TEXT;
|
||||||
|
|
||||||
|
-- Update Schema
|
||||||
|
DELETE FROM users;
|
||||||
|
ALTER TABLE users ADD COLUMN auth_hash TEXT NOT NULL;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current users
|
||||||
|
rows, err := tx.Query("SELECT id FROM temp_users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query existing users
|
||||||
|
var users []string
|
||||||
|
for rows.Next() {
|
||||||
|
var user string
|
||||||
|
if err := rows.Scan(&user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth hash per user
|
||||||
|
for _, user := range users {
|
||||||
|
rawAuthHash, err := utils.GenerateToken(64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
authHash := fmt.Sprintf("%x", rawAuthHash)
|
||||||
|
_, err = tx.Exec("UPDATE temp_users SET auth_hash = ? WHERE id = ?", authHash, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from temp to true table
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
-- Copy Into New
|
||||||
|
INSERT INTO users SELECT * FROM temp_users;
|
||||||
|
|
||||||
|
-- Drop Temp Table
|
||||||
|
DROP TABLE temp_users;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downUserAuthHash(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// Drop column
|
||||||
|
_, err := tx.Exec("ALTER users DROP COLUMN auth_hash")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
9
database/migrations/README.md
Normal file
9
database/migrations/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# DB Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
goose create migration_name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Since we update both the `schema.sql`, as well as the migration files, when we create a new DB it will inherently be up-to-date. We don't want to run the migrations if it's already up-to-date. Instead each migration checks if we have a new DB (via a value passed into the context), and if we do we simply return.
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.21.0
|
// sqlc v1.25.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import ()
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -61,14 +59,23 @@ type DocumentProgress struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DocumentUserStatistic struct {
|
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"`
|
Percentage float64 `json:"percentage"`
|
||||||
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
LastRead string `json:"last_read"`
|
||||||
ReadPercentage float64 `json:"read_percentage"`
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
Percentage float64 `json:"percentage"`
|
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
||||||
WordsRead int64 `json:"words_read"`
|
TotalWordsRead int64 `json:"total_words_read"`
|
||||||
Wpm float64 `json:"wpm"`
|
TotalWpm float64 `json:"total_wpm"`
|
||||||
|
YearlyTimeSeconds int64 `json:"yearly_time_seconds"`
|
||||||
|
YearlyWordsRead int64 `json:"yearly_words_read"`
|
||||||
|
YearlyWpm float64 `json:"yearly_wpm"`
|
||||||
|
MonthlyTimeSeconds int64 `json:"monthly_time_seconds"`
|
||||||
|
MonthlyWordsRead int64 `json:"monthly_words_read"`
|
||||||
|
MonthlyWpm float64 `json:"monthly_wpm"`
|
||||||
|
WeeklyTimeSeconds int64 `json:"weekly_time_seconds"`
|
||||||
|
WeeklyWordsRead int64 `json:"weekly_words_read"`
|
||||||
|
WeeklyWpm float64 `json:"weekly_wpm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadatum struct {
|
type Metadatum struct {
|
||||||
@@ -84,9 +91,17 @@ type Metadatum struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
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:"-"`
|
||||||
|
AuthHash *string `json:"auth_hash"`
|
||||||
Admin bool `json:"-"`
|
Admin bool `json:"-"`
|
||||||
TimeOffset *string `json:"time_offset"`
|
TimeOffset *string `json:"time_offset"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
@@ -104,14 +119,23 @@ 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 interface{} `json:"last_read"`
|
Percentage float64 `json:"percentage"`
|
||||||
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
|
LastRead interface{} `json:"last_read"`
|
||||||
ReadPercentage sql.NullFloat64 `json:"read_percentage"`
|
ReadPercentage *float64 `json:"read_percentage"`
|
||||||
Percentage float64 `json:"percentage"`
|
TotalTimeSeconds *float64 `json:"total_time_seconds"`
|
||||||
WordsRead interface{} `json:"words_read"`
|
TotalWordsRead interface{} `json:"total_words_read"`
|
||||||
Wpm int64 `json:"wpm"`
|
TotalWpm int64 `json:"total_wpm"`
|
||||||
|
YearlyTimeSeconds *float64 `json:"yearly_time_seconds"`
|
||||||
|
YearlyWordsRead interface{} `json:"yearly_words_read"`
|
||||||
|
YearlyWpm interface{} `json:"yearly_wpm"`
|
||||||
|
MonthlyTimeSeconds *float64 `json:"monthly_time_seconds"`
|
||||||
|
MonthlyWordsRead interface{} `json:"monthly_words_read"`
|
||||||
|
MonthlyWpm interface{} `json:"monthly_wpm"`
|
||||||
|
WeeklyTimeSeconds *float64 `json:"weekly_time_seconds"`
|
||||||
|
WeeklyWordsRead interface{} `json:"weekly_words_read"`
|
||||||
|
WeeklyWpm interface{} `json:"weekly_wpm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewUserStreak struct {
|
type ViewUserStreak struct {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: CreateUser :execrows
|
-- name: CreateUser :execrows
|
||||||
INSERT INTO users (id, pass)
|
INSERT INTO users (id, pass, auth_hash, admin)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: DeleteDocument :execrows
|
-- name: DeleteDocument :execrows
|
||||||
@@ -40,9 +40,12 @@ WHERE id = $id;
|
|||||||
WITH filtered_activity AS (
|
WITH filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
|
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
|
||||||
|
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
|
||||||
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
@@ -60,10 +63,13 @@ WITH filtered_activity AS (
|
|||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
|
start_percentage,
|
||||||
|
end_percentage,
|
||||||
read_percentage
|
read_percentage
|
||||||
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
|
||||||
@@ -94,7 +100,6 @@ activity_days AS (
|
|||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN users ON users.id = activity.user_id
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
LIMIT 30
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CAST(date AS TEXT),
|
CAST(date AS TEXT),
|
||||||
@@ -128,6 +133,7 @@ WHERE id = $device_id LIMIT 1;
|
|||||||
|
|
||||||
-- name: GetDevices :many
|
-- name: GetDevices :many
|
||||||
SELECT
|
SELECT
|
||||||
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
||||||
@@ -140,6 +146,20 @@ 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: GetDocumentProgress :one
|
||||||
|
SELECT
|
||||||
|
document_progress.*,
|
||||||
|
devices.device_name
|
||||||
|
FROM document_progress
|
||||||
|
JOIN devices ON document_progress.device_id = devices.id
|
||||||
|
WHERE
|
||||||
|
document_progress.user_id = $user_id
|
||||||
|
AND document_progress.document_id = $document_id
|
||||||
|
ORDER BY
|
||||||
|
document_progress.created_at
|
||||||
|
DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetDocumentWithStats :one
|
-- name: GetDocumentWithStats :one
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -151,7 +171,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
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)
|
||||||
@@ -203,7 +223,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
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)
|
||||||
@@ -252,19 +272,30 @@ WHERE
|
|||||||
AND documents.deleted = false
|
AND documents.deleted = false
|
||||||
AND documents.id NOT IN (sqlc.slice('document_ids'));
|
AND documents.id NOT IN (sqlc.slice('document_ids'));
|
||||||
|
|
||||||
-- name: GetProgress :one
|
-- name: GetProgress :many
|
||||||
SELECT
|
SELECT
|
||||||
document_progress.*,
|
documents.title,
|
||||||
devices.device_name
|
documents.author,
|
||||||
FROM document_progress
|
devices.device_name,
|
||||||
JOIN devices ON document_progress.device_id = devices.id
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
|
progress.document_id,
|
||||||
|
progress.user_id,
|
||||||
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at
|
||||||
|
FROM document_progress AS progress
|
||||||
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
|
LEFT JOIN documents ON progress.document_id = documents.id
|
||||||
WHERE
|
WHERE
|
||||||
document_progress.user_id = $user_id
|
progress.user_id = $user_id
|
||||||
AND document_progress.document_id = $document_id
|
AND (
|
||||||
ORDER BY
|
(
|
||||||
document_progress.created_at
|
CAST($doc_filter AS BOOLEAN) = TRUE
|
||||||
DESC
|
AND document_id = $document_id
|
||||||
LIMIT 1;
|
) OR $doc_filter = FALSE
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $limit
|
||||||
|
OFFSET $offset;
|
||||||
|
|
||||||
-- name: GetUser :one
|
-- name: GetUser :one
|
||||||
SELECT * FROM users
|
SELECT * FROM users
|
||||||
@@ -274,17 +305,37 @@ 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: GetWPMLeaderboard :many
|
-- name: GetUsers :many
|
||||||
|
SELECT * FROM users;
|
||||||
|
|
||||||
|
-- name: GetUserStatistics :many
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
|
|
||||||
|
CAST(SUM(total_words_read) AS INTEGER) AS total_words_read,
|
||||||
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
|
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
|
||||||
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
|
ROUND(COALESCE(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 0.0), 2)
|
||||||
AS wpm
|
AS total_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(yearly_words_read) AS INTEGER) AS yearly_words_read,
|
||||||
|
CAST(SUM(yearly_time_seconds) AS INTEGER) AS yearly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(yearly_words_read) AS REAL) / (SUM(yearly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS yearly_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(monthly_words_read) AS INTEGER) AS monthly_words_read,
|
||||||
|
CAST(SUM(monthly_time_seconds) AS INTEGER) AS monthly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(monthly_words_read) AS REAL) / (SUM(monthly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS monthly_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(weekly_words_read) AS INTEGER) AS weekly_words_read,
|
||||||
|
CAST(SUM(weekly_time_seconds) AS INTEGER) AS weekly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(weekly_words_read) AS REAL) / (SUM(weekly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS weekly_wpm
|
||||||
|
|
||||||
FROM document_user_statistics
|
FROM document_user_statistics
|
||||||
WHERE words_read > 0
|
WHERE total_words_read > 0
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY wpm DESC;
|
ORDER BY total_wpm DESC;
|
||||||
|
|
||||||
-- name: GetWantedDocuments :many
|
-- name: GetWantedDocuments :many
|
||||||
SELECT
|
SELECT
|
||||||
@@ -317,10 +368,20 @@ RETURNING *;
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
pass = COALESCE($password, pass),
|
pass = COALESCE($password, pass),
|
||||||
|
auth_hash = COALESCE($auth_hash, auth_hash),
|
||||||
time_offset = COALESCE($time_offset, time_offset)
|
time_offset = COALESCE($time_offset, time_offset)
|
||||||
WHERE id = $user_id
|
WHERE id = $user_id
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateSettings :one
|
||||||
|
INSERT INTO settings (name, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT DO UPDATE
|
||||||
|
SET
|
||||||
|
name = COALESCE(excluded.name, name),
|
||||||
|
value = COALESCE(excluded.value, value)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpsertDevice :one
|
-- name: UpsertDevice :one
|
||||||
INSERT INTO devices (id, user_id, last_synced, device_name)
|
INSERT INTO devices (id, user_id, last_synced, device_name)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.21.0
|
// sqlc v1.25.0
|
||||||
// source: query.sql
|
// source: query.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -113,18 +113,25 @@ func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metad
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createUser = `-- name: CreateUser :execrows
|
const createUser = `-- name: CreateUser :execrows
|
||||||
INSERT INTO users (id, pass)
|
INSERT INTO users (id, pass, auth_hash, admin)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Pass *string `json:"-"`
|
Pass *string `json:"-"`
|
||||||
|
AuthHash *string `json:"auth_hash"`
|
||||||
|
Admin bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, createUser, arg.ID, arg.Pass)
|
result, err := q.db.ExecContext(ctx, createUser,
|
||||||
|
arg.ID,
|
||||||
|
arg.Pass,
|
||||||
|
arg.AuthHash,
|
||||||
|
arg.Admin,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -150,9 +157,12 @@ const getActivity = `-- name: GetActivity :many
|
|||||||
WITH filtered_activity AS (
|
WITH filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
|
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
|
||||||
|
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
|
||||||
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
@@ -170,10 +180,13 @@ WITH filtered_activity AS (
|
|||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
|
start_percentage,
|
||||||
|
end_percentage,
|
||||||
read_percentage
|
read_percentage
|
||||||
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
|
||||||
@@ -189,12 +202,15 @@ type GetActivityParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetActivityRow struct {
|
type GetActivityRow struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
StartTime string `json:"start_time"`
|
DeviceID string `json:"device_id"`
|
||||||
Title *string `json:"title"`
|
StartTime string `json:"start_time"`
|
||||||
Author *string `json:"author"`
|
Title *string `json:"title"`
|
||||||
Duration int64 `json:"duration"`
|
Author *string `json:"author"`
|
||||||
ReadPercentage float64 `json:"read_percentage"`
|
Duration int64 `json:"duration"`
|
||||||
|
StartPercentage float64 `json:"start_percentage"`
|
||||||
|
EndPercentage float64 `json:"end_percentage"`
|
||||||
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||||
@@ -214,10 +230,13 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
|
|||||||
var i GetActivityRow
|
var i GetActivityRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.DocumentID,
|
&i.DocumentID,
|
||||||
|
&i.DeviceID,
|
||||||
&i.StartTime,
|
&i.StartTime,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Author,
|
&i.Author,
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
|
&i.StartPercentage,
|
||||||
|
&i.EndPercentage,
|
||||||
&i.ReadPercentage,
|
&i.ReadPercentage,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -258,7 +277,6 @@ activity_days AS (
|
|||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN users ON users.id = activity.user_id
|
LEFT JOIN users ON users.id = activity.user_id
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
LIMIT 30
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CAST(date AS TEXT),
|
CAST(date AS TEXT),
|
||||||
@@ -390,6 +408,7 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error
|
|||||||
|
|
||||||
const getDevices = `-- name: GetDevices :many
|
const getDevices = `-- name: GetDevices :many
|
||||||
SELECT
|
SELECT
|
||||||
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
||||||
@@ -400,6 +419,7 @@ ORDER BY devices.last_synced DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetDevicesRow struct {
|
type GetDevicesRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastSynced string `json:"last_synced"`
|
LastSynced string `json:"last_synced"`
|
||||||
@@ -414,7 +434,12 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
|
|||||||
var items []GetDevicesRow
|
var items []GetDevicesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetDevicesRow
|
var i GetDevicesRow
|
||||||
if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSynced); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastSynced,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -460,6 +485,51 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDocumentProgress = `-- name: GetDocumentProgress :one
|
||||||
|
SELECT
|
||||||
|
document_progress.user_id, document_progress.document_id, document_progress.device_id, document_progress.percentage, document_progress.progress, document_progress.created_at,
|
||||||
|
devices.device_name
|
||||||
|
FROM document_progress
|
||||||
|
JOIN devices ON document_progress.device_id = devices.id
|
||||||
|
WHERE
|
||||||
|
document_progress.user_id = ?1
|
||||||
|
AND document_progress.document_id = ?2
|
||||||
|
ORDER BY
|
||||||
|
document_progress.created_at
|
||||||
|
DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetDocumentProgressParams struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDocumentProgressRow struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
Progress string `json:"progress"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetDocumentProgress(ctx context.Context, arg GetDocumentProgressParams) (GetDocumentProgressRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getDocumentProgress, arg.UserID, arg.DocumentID)
|
||||||
|
var i GetDocumentProgressRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.DocumentID,
|
||||||
|
&i.DeviceID,
|
||||||
|
&i.Percentage,
|
||||||
|
&i.Progress,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DeviceName,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
|
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
|
||||||
SELECT
|
SELECT
|
||||||
docs.id,
|
docs.id,
|
||||||
@@ -471,7 +541,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
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)
|
||||||
@@ -625,7 +695,7 @@ SELECT
|
|||||||
docs.filepath,
|
docs.filepath,
|
||||||
docs.words,
|
docs.words,
|
||||||
|
|
||||||
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
|
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
|
||||||
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
COALESCE(dus.read_percentage, 0) AS read_percentage,
|
||||||
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)
|
||||||
@@ -809,53 +879,89 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProgress = `-- name: GetProgress :one
|
const getProgress = `-- name: GetProgress :many
|
||||||
SELECT
|
SELECT
|
||||||
document_progress.user_id, document_progress.document_id, document_progress.device_id, document_progress.percentage, document_progress.progress, document_progress.created_at,
|
documents.title,
|
||||||
devices.device_name
|
documents.author,
|
||||||
FROM document_progress
|
devices.device_name,
|
||||||
JOIN devices ON document_progress.device_id = devices.id
|
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
|
||||||
|
progress.document_id,
|
||||||
|
progress.user_id,
|
||||||
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at
|
||||||
|
FROM document_progress AS progress
|
||||||
|
LEFT JOIN users ON progress.user_id = users.id
|
||||||
|
LEFT JOIN devices ON progress.device_id = devices.id
|
||||||
|
LEFT JOIN documents ON progress.document_id = documents.id
|
||||||
WHERE
|
WHERE
|
||||||
document_progress.user_id = ?1
|
progress.user_id = ?1
|
||||||
AND document_progress.document_id = ?2
|
AND (
|
||||||
ORDER BY
|
(
|
||||||
document_progress.created_at
|
CAST(?2 AS BOOLEAN) = TRUE
|
||||||
DESC
|
AND document_id = ?3
|
||||||
LIMIT 1
|
) OR ?2 = FALSE
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?5
|
||||||
|
OFFSET ?4
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetProgressParams struct {
|
type GetProgressParams struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
DocFilter bool `json:"doc_filter"`
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
Limit int64 `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetProgressRow struct {
|
type GetProgressRow struct {
|
||||||
UserID string `json:"user_id"`
|
Title *string `json:"title"`
|
||||||
DocumentID string `json:"document_id"`
|
Author *string `json:"author"`
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
Percentage float64 `json:"percentage"`
|
|
||||||
Progress string `json:"progress"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) (GetProgressRow, error) {
|
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getProgress, arg.UserID, arg.DocumentID)
|
rows, err := q.db.QueryContext(ctx, getProgress,
|
||||||
var i GetProgressRow
|
arg.UserID,
|
||||||
err := row.Scan(
|
arg.DocFilter,
|
||||||
&i.UserID,
|
arg.DocumentID,
|
||||||
&i.DocumentID,
|
arg.Offset,
|
||||||
&i.DeviceID,
|
arg.Limit,
|
||||||
&i.Percentage,
|
|
||||||
&i.Progress,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.DeviceName,
|
|
||||||
)
|
)
|
||||||
return i, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetProgressRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetProgressRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.Percentage,
|
||||||
|
&i.DocumentID,
|
||||||
|
&i.UserID,
|
||||||
|
&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 getUser = `-- name: GetUser :one
|
const getUser = `-- name: GetUser :one
|
||||||
SELECT id, pass, admin, time_offset, created_at FROM users
|
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users
|
||||||
WHERE id = ?1 LIMIT 1
|
WHERE id = ?1 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -865,6 +971,7 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Pass,
|
&i.Pass,
|
||||||
|
&i.AuthHash,
|
||||||
&i.Admin,
|
&i.Admin,
|
||||||
&i.TimeOffset,
|
&i.TimeOffset,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
@@ -872,6 +979,89 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUserStatistics = `-- name: GetUserStatistics :many
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
|
||||||
|
CAST(SUM(total_words_read) AS INTEGER) AS total_words_read,
|
||||||
|
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS total_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(yearly_words_read) AS INTEGER) AS yearly_words_read,
|
||||||
|
CAST(SUM(yearly_time_seconds) AS INTEGER) AS yearly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(yearly_words_read) AS REAL) / (SUM(yearly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS yearly_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(monthly_words_read) AS INTEGER) AS monthly_words_read,
|
||||||
|
CAST(SUM(monthly_time_seconds) AS INTEGER) AS monthly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(monthly_words_read) AS REAL) / (SUM(monthly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS monthly_wpm,
|
||||||
|
|
||||||
|
CAST(SUM(weekly_words_read) AS INTEGER) AS weekly_words_read,
|
||||||
|
CAST(SUM(weekly_time_seconds) AS INTEGER) AS weekly_seconds,
|
||||||
|
ROUND(COALESCE(CAST(SUM(weekly_words_read) AS REAL) / (SUM(weekly_time_seconds) / 60.0), 0.0), 2)
|
||||||
|
AS weekly_wpm
|
||||||
|
|
||||||
|
FROM document_user_statistics
|
||||||
|
WHERE total_words_read > 0
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY total_wpm DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserStatisticsRow struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TotalWordsRead int64 `json:"total_words_read"`
|
||||||
|
TotalSeconds int64 `json:"total_seconds"`
|
||||||
|
TotalWpm float64 `json:"total_wpm"`
|
||||||
|
YearlyWordsRead int64 `json:"yearly_words_read"`
|
||||||
|
YearlySeconds int64 `json:"yearly_seconds"`
|
||||||
|
YearlyWpm float64 `json:"yearly_wpm"`
|
||||||
|
MonthlyWordsRead int64 `json:"monthly_words_read"`
|
||||||
|
MonthlySeconds int64 `json:"monthly_seconds"`
|
||||||
|
MonthlyWpm float64 `json:"monthly_wpm"`
|
||||||
|
WeeklyWordsRead int64 `json:"weekly_words_read"`
|
||||||
|
WeeklySeconds int64 `json:"weekly_seconds"`
|
||||||
|
WeeklyWpm float64 `json:"weekly_wpm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserStatistics(ctx context.Context) ([]GetUserStatisticsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getUserStatistics)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetUserStatisticsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetUserStatisticsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.TotalWordsRead,
|
||||||
|
&i.TotalSeconds,
|
||||||
|
&i.TotalWpm,
|
||||||
|
&i.YearlyWordsRead,
|
||||||
|
&i.YearlySeconds,
|
||||||
|
&i.YearlyWpm,
|
||||||
|
&i.MonthlyWordsRead,
|
||||||
|
&i.MonthlySeconds,
|
||||||
|
&i.MonthlyWpm,
|
||||||
|
&i.WeeklyWordsRead,
|
||||||
|
&i.WeeklySeconds,
|
||||||
|
&i.WeeklyWpm,
|
||||||
|
); 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 getUserStreaks = `-- name: GetUserStreaks :many
|
const getUserStreaks = `-- name: GetUserStreaks :many
|
||||||
SELECT user_id, "window", max_streak, max_streak_start_date, max_streak_end_date, current_streak, current_streak_start_date, current_streak_end_date FROM user_streaks
|
SELECT user_id, "window", max_streak, max_streak_start_date, max_streak_end_date, current_streak, current_streak_start_date, current_streak_end_date FROM user_streaks
|
||||||
WHERE user_id = ?1
|
WHERE user_id = ?1
|
||||||
@@ -909,40 +1099,26 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWPMLeaderboard = `-- name: GetWPMLeaderboard :many
|
const getUsers = `-- name: GetUsers :many
|
||||||
SELECT
|
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users
|
||||||
user_id,
|
|
||||||
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
|
|
||||||
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
|
|
||||||
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
|
|
||||||
AS wpm
|
|
||||||
FROM document_user_statistics
|
|
||||||
WHERE words_read > 0
|
|
||||||
GROUP BY user_id
|
|
||||||
ORDER BY wpm DESC
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetWPMLeaderboardRow struct {
|
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
|
||||||
UserID string `json:"user_id"`
|
rows, err := q.db.QueryContext(ctx, getUsers)
|
||||||
TotalWordsRead int64 `json:"total_words_read"`
|
|
||||||
TotalSeconds int64 `json:"total_seconds"`
|
|
||||||
Wpm float64 `json:"wpm"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetWPMLeaderboard(ctx context.Context) ([]GetWPMLeaderboardRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getWPMLeaderboard)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetWPMLeaderboardRow
|
var items []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetWPMLeaderboardRow
|
var i User
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.UserID,
|
&i.ID,
|
||||||
&i.TotalWordsRead,
|
&i.Pass,
|
||||||
&i.TotalSeconds,
|
&i.AuthHash,
|
||||||
&i.Wpm,
|
&i.Admin,
|
||||||
|
&i.TimeOffset,
|
||||||
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1043,27 +1219,62 @@ func (q *Queries) UpdateProgress(ctx context.Context, arg UpdateProgressParams)
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateSettings = `-- name: UpdateSettings :one
|
||||||
|
INSERT INTO settings (name, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT DO UPDATE
|
||||||
|
SET
|
||||||
|
name = COALESCE(excluded.name, name),
|
||||||
|
value = COALESCE(excluded.value, value)
|
||||||
|
RETURNING id, name, value, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateSettingsParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateSettings(ctx context.Context, arg UpdateSettingsParams) (Setting, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateSettings, arg.Name, arg.Value)
|
||||||
|
var i Setting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Value,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateUser = `-- name: UpdateUser :one
|
const updateUser = `-- name: UpdateUser :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
pass = COALESCE(?1, pass),
|
pass = COALESCE(?1, pass),
|
||||||
time_offset = COALESCE(?2, time_offset)
|
auth_hash = COALESCE(?2, auth_hash),
|
||||||
WHERE id = ?3
|
time_offset = COALESCE(?3, time_offset)
|
||||||
RETURNING id, pass, admin, time_offset, created_at
|
WHERE id = ?4
|
||||||
|
RETURNING id, pass, auth_hash, admin, time_offset, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserParams struct {
|
type UpdateUserParams struct {
|
||||||
Password *string `json:"-"`
|
Password *string `json:"-"`
|
||||||
|
AuthHash *string `json:"auth_hash"`
|
||||||
TimeOffset *string `json:"time_offset"`
|
TimeOffset *string `json:"time_offset"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
||||||
row := q.db.QueryRowContext(ctx, updateUser, arg.Password, arg.TimeOffset, arg.UserID)
|
row := q.db.QueryRowContext(ctx, updateUser,
|
||||||
|
arg.Password,
|
||||||
|
arg.AuthHash,
|
||||||
|
arg.TimeOffset,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Pass,
|
&i.Pass,
|
||||||
|
&i.AuthHash,
|
||||||
&i.Admin,
|
&i.Admin,
|
||||||
&i.TimeOffset,
|
&i.TimeOffset,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
------------------------ Normal Tables ------------------------
|
------------------------ Normal Tables ------------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
@@ -10,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
|
||||||
pass TEXT NOT NULL,
|
pass TEXT NOT NULL,
|
||||||
|
auth_hash TEXT NOT NULL,
|
||||||
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
|
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
|
||||||
time_offset TEXT NOT NULL DEFAULT '0 hours',
|
time_offset TEXT NOT NULL DEFAULT '0 hours',
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ CREATE TABLE IF NOT EXISTS documents (
|
|||||||
-- Metadata
|
-- Metadata
|
||||||
CREATE TABLE IF NOT EXISTS metadata (
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
document_id TEXT NOT NULL,
|
document_id TEXT NOT NULL,
|
||||||
|
|
||||||
title TEXT,
|
title TEXT,
|
||||||
@@ -110,6 +107,16 @@ CREATE TABLE IF NOT EXISTS activity (
|
|||||||
FOREIGN KEY (device_id) REFERENCES devices (id)
|
FOREIGN KEY (device_id) REFERENCES devices (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Settings
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
----------------------- Temporary Tables ----------------------
|
----------------------- Temporary Tables ----------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
@@ -128,15 +135,29 @@ CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
|
|||||||
current_streak_end_date TEXT NOT NULL
|
current_streak_end_date TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Temporary Document User Statistics Table (Cached from View)
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
|
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,
|
|
||||||
total_time_seconds INTEGER NOT NULL,
|
|
||||||
read_percentage REAL NOT NULL,
|
|
||||||
percentage REAL NOT NULL,
|
percentage REAL NOT NULL,
|
||||||
words_read INTEGER NOT NULL,
|
last_read TEXT NOT NULL,
|
||||||
wpm REAL NOT NULL,
|
read_percentage REAL NOT NULL,
|
||||||
|
|
||||||
|
total_time_seconds INTEGER NOT NULL,
|
||||||
|
total_words_read INTEGER NOT NULL,
|
||||||
|
total_wpm REAL NOT NULL,
|
||||||
|
|
||||||
|
yearly_time_seconds INTEGER NOT NULL,
|
||||||
|
yearly_words_read INTEGER NOT NULL,
|
||||||
|
yearly_wpm REAL NOT NULL,
|
||||||
|
|
||||||
|
monthly_time_seconds INTEGER NOT NULL,
|
||||||
|
monthly_words_read INTEGER NOT NULL,
|
||||||
|
monthly_wpm REAL NOT NULL,
|
||||||
|
|
||||||
|
weekly_time_seconds INTEGER NOT NULL,
|
||||||
|
weekly_words_read INTEGER NOT NULL,
|
||||||
|
weekly_wpm REAL NOT NULL,
|
||||||
|
|
||||||
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
|
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
|
||||||
);
|
);
|
||||||
@@ -153,15 +174,19 @@ CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
|
|||||||
document_id
|
document_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
---------------------------- Views ----------------------------
|
---------------------------- Views ----------------------------
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS view_user_streaks;
|
||||||
|
DROP VIEW IF EXISTS view_document_user_statistics;
|
||||||
|
|
||||||
--------------------------------
|
--------------------------------
|
||||||
--------- User Streaks ---------
|
--------- User Streaks ---------
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS view_user_streaks AS
|
CREATE VIEW view_user_streaks AS
|
||||||
|
|
||||||
WITH document_windows AS (
|
WITH document_windows AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -177,7 +202,6 @@ WITH document_windows AS (
|
|||||||
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
|
||||||
),
|
),
|
||||||
|
|
||||||
weekly_partitions AS (
|
weekly_partitions AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -190,7 +214,6 @@ weekly_partitions AS (
|
|||||||
FROM document_windows
|
FROM document_windows
|
||||||
GROUP BY user_id, weekly_read
|
GROUP BY user_id, weekly_read
|
||||||
),
|
),
|
||||||
|
|
||||||
daily_partitions AS (
|
daily_partitions AS (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -203,7 +226,6 @@ daily_partitions AS (
|
|||||||
FROM document_windows
|
FROM document_windows
|
||||||
GROUP BY user_id, daily_read
|
GROUP BY user_id, daily_read
|
||||||
),
|
),
|
||||||
|
|
||||||
streaks AS (
|
streaks AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS streak,
|
COUNT(*) AS streak,
|
||||||
@@ -279,7 +301,7 @@ LEFT JOIN current_streak ON
|
|||||||
------- Document Stats ---------
|
------- Document Stats ---------
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
|
CREATE VIEW view_document_user_statistics AS
|
||||||
|
|
||||||
WITH intermediate_ga AS (
|
WITH intermediate_ga AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -327,8 +349,8 @@ current_progress AS (
|
|||||||
WHERE
|
WHERE
|
||||||
dp.user_id = iga.user_id
|
dp.user_id = iga.user_id
|
||||||
AND dp.document_id = iga.document_id
|
AND dp.document_id = iga.document_id
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), end_percentage) AS percentage
|
), end_percentage) AS percentage
|
||||||
FROM intermediate_ga AS iga
|
FROM intermediate_ga AS iga
|
||||||
GROUP BY user_id, document_id
|
GROUP BY user_id, document_id
|
||||||
@@ -338,31 +360,122 @@ current_progress AS (
|
|||||||
SELECT
|
SELECT
|
||||||
ga.document_id,
|
ga.document_id,
|
||||||
ga.user_id,
|
ga.user_id,
|
||||||
MAX(start_time) AS last_read,
|
|
||||||
SUM(duration) AS total_time_seconds,
|
|
||||||
SUM(read_percentage) AS read_percentage,
|
|
||||||
cp.percentage,
|
cp.percentage,
|
||||||
|
MAX(start_time) AS last_read,
|
||||||
|
SUM(read_percentage) AS read_percentage,
|
||||||
|
|
||||||
|
-- All Time WPM
|
||||||
|
SUM(duration) AS total_time_seconds,
|
||||||
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
||||||
AS words_read,
|
AS total_words_read,
|
||||||
|
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
||||||
|
/ (SUM(duration) / 60.0) AS total_wpm,
|
||||||
|
|
||||||
|
-- Yearly WPM
|
||||||
|
SUM(CASE WHEN start_time >= DATE('now', '-1 year') THEN duration ELSE 0 END)
|
||||||
|
AS yearly_time_seconds,
|
||||||
|
(
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 year') THEN read_percentage
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AS yearly_words_read,
|
||||||
|
COALESCE((
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 year') THEN read_percentage
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
/ (
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 year') THEN duration
|
||||||
|
END
|
||||||
|
)
|
||||||
|
/ 60.0
|
||||||
|
), 0.0)
|
||||||
|
AS yearly_wpm,
|
||||||
|
|
||||||
|
-- Monthly WPM
|
||||||
|
SUM(
|
||||||
|
CASE WHEN start_time >= DATE('now', '-1 month') THEN duration ELSE 0 END
|
||||||
|
)
|
||||||
|
AS monthly_time_seconds,
|
||||||
|
(
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 month') THEN read_percentage
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AS monthly_words_read,
|
||||||
|
COALESCE((
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 month') THEN read_percentage
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
/ (
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-1 month') THEN duration
|
||||||
|
END
|
||||||
|
)
|
||||||
|
/ 60.0
|
||||||
|
), 0.0)
|
||||||
|
AS monthly_wpm,
|
||||||
|
|
||||||
|
-- Weekly WPM
|
||||||
|
SUM(CASE WHEN start_time >= DATE('now', '-7 days') THEN duration ELSE 0 END)
|
||||||
|
AS weekly_time_seconds,
|
||||||
|
(
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-7 days') THEN read_percentage
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AS weekly_words_read,
|
||||||
|
COALESCE((
|
||||||
|
CAST(COALESCE(d.words, 0.0) AS REAL)
|
||||||
|
* SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-7 days') THEN read_percentage
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
/ (
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN start_time >= DATE('now', '-7 days') THEN duration
|
||||||
|
END
|
||||||
|
)
|
||||||
|
/ 60.0
|
||||||
|
), 0.0)
|
||||||
|
AS weekly_wpm
|
||||||
|
|
||||||
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(read_percentage))
|
|
||||||
/ (SUM(duration) / 60.0) AS wpm
|
|
||||||
FROM grouped_activity AS ga
|
FROM grouped_activity AS ga
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
current_progress AS cp
|
current_progress AS cp
|
||||||
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
|
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
documents AS d
|
documents AS d
|
||||||
ON d.id = ga.document_id
|
ON ga.document_id = d.id
|
||||||
GROUP BY ga.document_id, ga.user_id
|
GROUP BY ga.document_id, ga.user_id
|
||||||
ORDER BY wpm DESC;
|
ORDER BY total_wpm DESC;
|
||||||
|
|
||||||
---------------------------------------------------------------
|
|
||||||
------------------ Populate Temporary Tables ------------------
|
|
||||||
---------------------------------------------------------------
|
|
||||||
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
|
||||||
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
|
|
||||||
|
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
--------------------------- Triggers --------------------------
|
--------------------------- Triggers --------------------------
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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,6 +0,0 @@
|
|||||||
DELETE FROM user_streaks;
|
|
||||||
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
|
|
||||||
DELETE FROM document_user_statistics;
|
|
||||||
INSERT INTO document_user_statistics
|
|
||||||
SELECT *
|
|
||||||
FROM view_document_user_statistics;
|
|
||||||
94
go.mod
94
go.mod
@@ -1,70 +1,80 @@
|
|||||||
module reichard.io/bbank
|
module reichard.io/antholume
|
||||||
|
|
||||||
go 1.19
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.1
|
github.com/PuerkitoBio/goquery v1.8.1
|
||||||
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736
|
github.com/alexedwards/argon2id v1.0.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2
|
github.com/gabriel-vasile/mimetype v1.4.3
|
||||||
github.com/gin-contrib/multitemplate v0.0.0-20230212012517-45920c92c271
|
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81
|
||||||
github.com/gin-contrib/sessions v0.0.4
|
github.com/gin-contrib/sessions v0.0.5
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/microcosm-cc/bluemonday v1.0.25
|
github.com/itchyny/gojq v0.12.14
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
|
github.com/pressly/goose/v3 v3.17.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115
|
github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115
|
||||||
github.com/urfave/cli/v2 v2.25.7
|
github.com/urfave/cli/v2 v2.27.1
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
|
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
|
||||||
golang.org/x/net v0.15.0
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
modernc.org/sqlite v1.26.0
|
modernc.org/sqlite v1.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/bytedance/sonic v1.10.0 // indirect
|
github.com/bytedance/sonic v1.10.2 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||||
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.15.3 // indirect
|
github.com/go-playground/validator/v10 v10.17.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/context v1.1.1 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/css v1.0.0 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.2.1 // indirect
|
github.com/gorilla/sessions v1.2.2 // indirect
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||||
|
github.com/jarcoal/httpmock v1.3.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.2.4 // indirect
|
||||||
|
github.com/stretchr/testify v1.8.4 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
||||||
golang.org/x/arch v0.4.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.13.0 // indirect
|
golang.org/x/arch v0.7.0 // indirect
|
||||||
golang.org/x/mod v0.12.0 // indirect
|
golang.org/x/crypto v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/mod v0.14.0 // indirect
|
||||||
golang.org/x/text v0.13.0 // indirect
|
golang.org/x/net v0.20.0 // indirect
|
||||||
golang.org/x/tools v0.13.0 // indirect
|
golang.org/x/sync v0.6.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
golang.org/x/sys v0.16.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.17.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.32.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
lukechampine.com/uint128 v1.2.0 // indirect
|
lukechampine.com/uint128 v1.3.0 // indirect
|
||||||
modernc.org/cc/v3 v3.40.0 // indirect
|
modernc.org/cc/v3 v3.41.0 // indirect
|
||||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||||
modernc.org/libc v1.24.1 // indirect
|
modernc.org/libc v1.40.7 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.6.0 // indirect
|
modernc.org/memory v1.7.2 // indirect
|
||||||
modernc.org/opt v0.1.3 // indirect
|
modernc.org/opt v0.1.3 // indirect
|
||||||
modernc.org/strutil v1.1.3 // indirect
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
modernc.org/token v1.0.1 // indirect
|
modernc.org/token v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
333
go.sum
333
go.sum
@@ -1,91 +1,147 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/ClickHouse/ch-go v0.58.2 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UTS0=
|
||||||
|
github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.16.0 h1:rhMfnPewXPnY4Q4lQRGdYuTLRBRKJEIEYHtbUMrzmvI=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.16.0/go.mod h1:J7SPfIxwR+x4mQ+o8MLSe0oY50NNntEqCIjFe/T1VPM=
|
||||||
|
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||||
|
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||||
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 h1:qZaEtLxnqY5mJ0fVKbk31NVhlgi0yrKm51Pq/I5wcz4=
|
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736/go.mod h1:mTeFRcTdnpzOlRjMoFYC/80HwVUreupyAiqPkCZQOXc=
|
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||||
github.com/antonlindstrom/pgstore v0.0.0-20200229204646-b08ebf1105e0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
|
|
||||||
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
|
|
||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||||
github.com/bytedance/sonic v1.10.0 h1:qtNZduETEIWJVIyDl01BeNxur2rW9OwTQ/yBqFRkKEk=
|
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
|
||||||
github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
|
||||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||||
|
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||||
|
github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg=
|
||||||
|
github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
|
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
|
||||||
|
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||||
|
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
|
||||||
github.com/gin-contrib/multitemplate v0.0.0-20230212012517-45920c92c271 h1:s+boMV47gwTyff2PL+k6V33edJpp+K5y3QPzZlRhno8=
|
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
|
||||||
github.com/gin-contrib/multitemplate v0.0.0-20230212012517-45920c92c271/go.mod h1:XLLtIXoP9+9zGcEDc7gAGV3AksGPO+vzv4kXHMJSdU0=
|
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
|
||||||
github.com/gin-contrib/sessions v0.0.4 h1:gq4fNa1Zmp564iHP5G6EBuktilEos8VKhe2sza1KMgo=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gin-contrib/sessions v0.0.4/go.mod h1:pQ3sIyviBBGcxgyR8mkeJuXbeV3h3NYmhJADQTq5+Vo=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81 h1:hQ/WeoPMTbN8NHk5i96dWy3D4uF7yCU+kORyWG+P4oU=
|
||||||
|
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81/go.mod h1:XLLtIXoP9+9zGcEDc7gAGV3AksGPO+vzv4kXHMJSdU0=
|
||||||
|
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
|
||||||
|
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
|
|
||||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI=
|
||||||
|
github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY=
|
||||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
|
||||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
|
||||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||||
github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo=
|
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
|
||||||
github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||||
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||||
|
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||||
|
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||||
|
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
|
||||||
|
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
||||||
|
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||||
|
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4=
|
||||||
|
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
|
||||||
|
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
||||||
|
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
|
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
|
||||||
|
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
@@ -95,35 +151,49 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
|
||||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
|
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
|
||||||
|
github.com/opencontainers/runc v1.1.10 h1:EaL5WeO9lv9wmS6SASjszOeQdSctvpbu0DdBQBizE40=
|
||||||
|
github.com/opencontainers/runc v1.1.10/go.mod h1:+/R6+KmDlh+hOO8NkjmgkG9Qzvypzk0yXxAPYYR65+M=
|
||||||
|
github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4=
|
||||||
|
github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg=
|
||||||
|
github.com/paulmach/orb v0.10.0 h1:guVYVqzxHE/CQ1KpfGO077TR0ATHSNjp4s6XGLn3W9s=
|
||||||
|
github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
|
github.com/pressly/goose/v3 v3.17.0 h1:fT4CL3LRm4kfyLuPWzDFAoxjR5ZHjeJ6uQhibQtBaIs=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/pressly/goose/v3 v3.17.0/go.mod h1:22aw7NpnCPlS86oqkO/+3+o9FuCaJg4ZVWRUO3oGzHQ=
|
||||||
|
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
@@ -131,13 +201,18 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
|
|||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec=
|
||||||
|
github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw=
|
||||||
|
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||||
|
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -150,50 +225,64 @@ github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115 h1:OEAIMYp5l
|
|||||||
github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115/go.mod h1:AIVbkIe1G7fpFHiKOdxZnU5p9tFPYNTQyH3H5IrRkGw=
|
github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115/go.mod h1:AIVbkIe1G7fpFHiKOdxZnU5p9tFPYNTQyH3H5IrRkGw=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
|
||||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
|
||||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
|
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd h1:dzWP1Lu+A40W883dK/Mr3xyDSM/2MggS8GtHT0qgAnE=
|
||||||
|
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||||
|
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2 h1:E0yUuuX7UmPxXm92+yQCjMveLFO3zfvYFIJVuAqsVRA=
|
||||||
|
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2/go.mod h1:fjBLQ2TdQNl4bMjuWl9adoTGBypwUTPoGC+EqYqiIcU=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.opentelemetry.io/otel v1.20.0 h1:vsb/ggIY+hUjD/zCAQHpzTmndPqv/ml2ArbsbfBYTAc=
|
||||||
|
go.opentelemetry.io/otel v1.20.0/go.mod h1:oUIGj3D77RwJdM6PPZImDpSZGDvkD9fhesHny69JFrs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.20.0 h1:+yxVAPZPbQhbC3OfAkeIVTky6iTFpcr4SiY9om7mXSQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.20.0/go.mod h1:HJSK7F/hA5RlzpZ0zKDCHCDHm556LCDtKaAo6JmBFUU=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
|
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||||
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -204,68 +293,84 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
|
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
|
||||||
|
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
||||||
|
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||||
|
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||||
|
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
||||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||||
|
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/libc v1.40.7/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||||
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||||
|
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
53
main.go
53
main.go
@@ -1,31 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"reichard.io/bbank/server"
|
"reichard.io/antholume/config"
|
||||||
|
"reichard.io/antholume/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UTCFormatter struct {
|
//go:embed templates/* assets/*
|
||||||
log.Formatter
|
var embeddedAssets embed.FS
|
||||||
}
|
|
||||||
|
|
||||||
func (u UTCFormatter) Format(e *log.Entry) ([]byte, error) {
|
|
||||||
e.Time = e.Time.UTC()
|
|
||||||
return u.Formatter.Format(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFormatter(UTCFormatter{&log.TextFormatter{FullTimestamp: true}})
|
|
||||||
|
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "AnthoLume",
|
Name: "AnthoLume",
|
||||||
Usage: "A self hosted e-book progress tracker.",
|
Usage: "A self hosted e-book progress tracker.",
|
||||||
|
EnableBashCompletion: true,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "serve",
|
Name: "serve",
|
||||||
@@ -42,23 +37,29 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cmdServer(ctx *cli.Context) error {
|
func cmdServer(ctx *cli.Context) error {
|
||||||
|
var assets fs.FS = embeddedAssets
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
c := config.Load()
|
||||||
|
if c.Version == "develop" {
|
||||||
|
assets = os.DirFS("./")
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Starting AnthoLume Server")
|
log.Info("Starting AnthoLume Server")
|
||||||
|
|
||||||
// Create Channel
|
// Create notify channel
|
||||||
wg := sync.WaitGroup{}
|
signals := make(chan os.Signal, 1)
|
||||||
done := make(chan struct{})
|
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||||
interrupt := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
// Start Server
|
// Start server
|
||||||
server := server.NewServer()
|
s := server.New(c, assets)
|
||||||
server.StartServer(&wg, done)
|
s.Start()
|
||||||
|
|
||||||
// Wait & Close
|
// Wait & close
|
||||||
<-interrupt
|
<-signals
|
||||||
server.StopServer(&wg, done)
|
s.Stop()
|
||||||
|
|
||||||
// Stop Server
|
// Stop server
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
110
metadata/_test_files/gbooks_id_response.json
Normal file
110
metadata/_test_files/gbooks_id_response.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"kind": "books#volume",
|
||||||
|
"id": "ZxwpakTv_MIC",
|
||||||
|
"etag": "mhqr3GsebaQ",
|
||||||
|
"selfLink": "https://www.googleapis.com/books/v1/volumes/ZxwpakTv_MIC",
|
||||||
|
"volumeInfo": {
|
||||||
|
"title": "Alice in Wonderland",
|
||||||
|
"authors": [
|
||||||
|
"Lewis Carroll"
|
||||||
|
],
|
||||||
|
"publisher": "The Floating Press",
|
||||||
|
"publishedDate": "2009-01-01",
|
||||||
|
"description": "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures. Lewis Carroll's prominent example of the genre of \"literary nonsense\" has endured in popularity with its clever way of playing with logic and a narrative structure that has influence generations of fiction writing.",
|
||||||
|
"industryIdentifiers": [
|
||||||
|
{
|
||||||
|
"type": "ISBN_10",
|
||||||
|
"identifier": "1877527815"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ISBN_13",
|
||||||
|
"identifier": "9781877527814"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"readingModes": {
|
||||||
|
"text": true,
|
||||||
|
"image": false
|
||||||
|
},
|
||||||
|
"pageCount": 104,
|
||||||
|
"printedPageCount": 112,
|
||||||
|
"printType": "BOOK",
|
||||||
|
"categories": [
|
||||||
|
"Fiction / Classics",
|
||||||
|
"Juvenile Fiction / General"
|
||||||
|
],
|
||||||
|
"averageRating": 5,
|
||||||
|
"ratingsCount": 1,
|
||||||
|
"maturityRating": "NOT_MATURE",
|
||||||
|
"allowAnonLogging": true,
|
||||||
|
"contentVersion": "0.2.3.0.preview.2",
|
||||||
|
"panelizationSummary": {
|
||||||
|
"containsEpubBubbles": false,
|
||||||
|
"containsImageBubbles": false
|
||||||
|
},
|
||||||
|
"imageLinks": {
|
||||||
|
"smallThumbnail": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=5&edge=curl&imgtk=AFLRE71e5b-TeAKTiPSvXNUPeUi8rItzur2xSzwH8QU3qjKH0A2opmoq1o5I9RqJFt1BtcCCqILhnYRcB2aFLJmEvom11gx3Qn3PNN1iBLj2H5y2JHjM8wIwGT7iWFQmEn0Od7s6sOdk&source=gbs_api",
|
||||||
|
"thumbnail": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=1&edge=curl&imgtk=AFLRE70QORt9J_DmKJgfyf9UEjQkdDMZ0qAu0GP315a1Q4CRS3snEjKnJJO2fYFdxjMwsSpmHoXDFPZbsy4gw-kMvF7lL8LtwxGbJGlfETHw_jbQBKBlKTrneK4XFvvV-EXNrZRgylxj&source=gbs_api",
|
||||||
|
"small": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=2&edge=curl&imgtk=AFLRE70r1pAUt6VhuEEW8vXFhu8LvKln3yj0mdlaWPO4ZQuODLFQnH0fTebKMMX4ANR5i4PtC0oaI48XkwF-EdzlEM1WmUcR5383N4kRMXcta_i9nmb2y38dnh3hObwQW5VoAxbc9psn&source=gbs_api",
|
||||||
|
"medium": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=3&edge=curl&imgtk=AFLRE7019EVuXvhzbhmtbz1QFh-ajB6kTKRHGhqijFf8big_GPRMMdpCdKlklFbkCfXvy8F64t5NKlThUHb3tFP-51bbDXkrVErFbCqKGzGnDSSm8cewqT8HiYDNHqn0hXYnuYvN4vYf&source=gbs_api",
|
||||||
|
"large": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=4&edge=curl&imgtk=AFLRE72I15XZqp_8c8BAj4EskxkdC6nQz8F0Fs6VJhkykwIqfjzwuM34tUSQa3UnMGbx-UYjZjSLmCNFlePS8aR7yy-0UP9BRnYD-h5Qbesnnt_xdOb3u7Wdiobi6VbciNCBwUwbCyeH&source=gbs_api",
|
||||||
|
"extraLarge": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=6&edge=curl&imgtk=AFLRE70rC6ktY6U0K_hqG1HxPl_9hMjpKb10p9DryVIwQgUjoJfWQOjpNA3EQ-5yk167yYDlO27gylqNAdJBYWu7ZHr3GuqkjTDpXjDvzBBppVyWaVNxKwhOz3gfJ-gzM6cC4kLHP26R&source=gbs_api"
|
||||||
|
},
|
||||||
|
"language": "en",
|
||||||
|
"previewLink": "http://books.google.com/books?id=ZxwpakTv_MIC&hl=&source=gbs_api",
|
||||||
|
"infoLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC&source=gbs_api",
|
||||||
|
"canonicalVolumeLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC"
|
||||||
|
},
|
||||||
|
"layerInfo": {
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"layerId": "geo",
|
||||||
|
"volumeAnnotationsVersion": "2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"saleInfo": {
|
||||||
|
"country": "US",
|
||||||
|
"saleability": "FOR_SALE",
|
||||||
|
"isEbook": true,
|
||||||
|
"listPrice": {
|
||||||
|
"amount": 3.99,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"retailPrice": {
|
||||||
|
"amount": 3.99,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"buyLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC&rdid=book-ZxwpakTv_MIC&rdot=1&source=gbs_api",
|
||||||
|
"offers": [
|
||||||
|
{
|
||||||
|
"finskyOfferType": 1,
|
||||||
|
"listPrice": {
|
||||||
|
"amountInMicros": 3990000,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"retailPrice": {
|
||||||
|
"amountInMicros": 3990000,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"giftable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"accessInfo": {
|
||||||
|
"country": "US",
|
||||||
|
"viewability": "PARTIAL",
|
||||||
|
"embeddable": true,
|
||||||
|
"publicDomain": false,
|
||||||
|
"textToSpeechPermission": "ALLOWED",
|
||||||
|
"epub": {
|
||||||
|
"isAvailable": true,
|
||||||
|
"acsTokenLink": "http://books.google.com/books/download/Alice_in_Wonderland-sample-epub.acsm?id=ZxwpakTv_MIC&format=epub&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"isAvailable": false
|
||||||
|
},
|
||||||
|
"webReaderLink": "http://play.google.com/books/reader?id=ZxwpakTv_MIC&hl=&source=gbs_api",
|
||||||
|
"accessViewStatus": "SAMPLE",
|
||||||
|
"quoteSharingAllowed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
105
metadata/_test_files/gbooks_query_response.json
Normal file
105
metadata/_test_files/gbooks_query_response.json
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"kind": "books#volumes",
|
||||||
|
"totalItems": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"kind": "books#volume",
|
||||||
|
"id": "ZxwpakTv_MIC",
|
||||||
|
"etag": "F2eR9VV6VwQ",
|
||||||
|
"selfLink": "https://www.googleapis.com/books/v1/volumes/ZxwpakTv_MIC",
|
||||||
|
"volumeInfo": {
|
||||||
|
"title": "Alice in Wonderland",
|
||||||
|
"authors": [
|
||||||
|
"Lewis Carroll"
|
||||||
|
],
|
||||||
|
"publisher": "The Floating Press",
|
||||||
|
"publishedDate": "2009-01-01",
|
||||||
|
"description": "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures. Lewis Carroll's prominent example of the genre of \"literary nonsense\" has endured in popularity with its clever way of playing with logic and a narrative structure that has influence generations of fiction writing.",
|
||||||
|
"industryIdentifiers": [
|
||||||
|
{
|
||||||
|
"type": "ISBN_13",
|
||||||
|
"identifier": "9781877527814"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ISBN_10",
|
||||||
|
"identifier": "1877527815"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"readingModes": {
|
||||||
|
"text": true,
|
||||||
|
"image": false
|
||||||
|
},
|
||||||
|
"pageCount": 104,
|
||||||
|
"printType": "BOOK",
|
||||||
|
"categories": [
|
||||||
|
"Fiction"
|
||||||
|
],
|
||||||
|
"averageRating": 5,
|
||||||
|
"ratingsCount": 1,
|
||||||
|
"maturityRating": "NOT_MATURE",
|
||||||
|
"allowAnonLogging": true,
|
||||||
|
"contentVersion": "0.2.3.0.preview.2",
|
||||||
|
"panelizationSummary": {
|
||||||
|
"containsEpubBubbles": false,
|
||||||
|
"containsImageBubbles": false
|
||||||
|
},
|
||||||
|
"imageLinks": {
|
||||||
|
"smallThumbnail": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api",
|
||||||
|
"thumbnail": "http://books.google.com/books/content?id=ZxwpakTv_MIC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api"
|
||||||
|
},
|
||||||
|
"language": "en",
|
||||||
|
"previewLink": "http://books.google.com/books?id=ZxwpakTv_MIC&printsec=frontcover&dq=isbn:1877527815&hl=&cd=1&source=gbs_api",
|
||||||
|
"infoLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC&source=gbs_api",
|
||||||
|
"canonicalVolumeLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC"
|
||||||
|
},
|
||||||
|
"saleInfo": {
|
||||||
|
"country": "US",
|
||||||
|
"saleability": "FOR_SALE",
|
||||||
|
"isEbook": true,
|
||||||
|
"listPrice": {
|
||||||
|
"amount": 3.99,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"retailPrice": {
|
||||||
|
"amount": 3.99,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"buyLink": "https://play.google.com/store/books/details?id=ZxwpakTv_MIC&rdid=book-ZxwpakTv_MIC&rdot=1&source=gbs_api",
|
||||||
|
"offers": [
|
||||||
|
{
|
||||||
|
"finskyOfferType": 1,
|
||||||
|
"listPrice": {
|
||||||
|
"amountInMicros": 3990000,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"retailPrice": {
|
||||||
|
"amountInMicros": 3990000,
|
||||||
|
"currencyCode": "USD"
|
||||||
|
},
|
||||||
|
"giftable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"accessInfo": {
|
||||||
|
"country": "US",
|
||||||
|
"viewability": "PARTIAL",
|
||||||
|
"embeddable": true,
|
||||||
|
"publicDomain": false,
|
||||||
|
"textToSpeechPermission": "ALLOWED",
|
||||||
|
"epub": {
|
||||||
|
"isAvailable": true,
|
||||||
|
"acsTokenLink": "http://books.google.com/books/download/Alice_in_Wonderland-sample-epub.acsm?id=ZxwpakTv_MIC&format=epub&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"isAvailable": false
|
||||||
|
},
|
||||||
|
"webReaderLink": "http://play.google.com/books/reader?id=ZxwpakTv_MIC&hl=&source=gbs_api",
|
||||||
|
"accessViewStatus": "SAMPLE",
|
||||||
|
"quoteSharingAllowed": false
|
||||||
|
},
|
||||||
|
"searchInfo": {
|
||||||
|
"textSnippet": "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
@@ -14,11 +15,34 @@ func getEPUBMetadata(filepath string) (*MetadataInfo, error) {
|
|||||||
}
|
}
|
||||||
rf := rc.Rootfiles[0]
|
rf := rc.Rootfiles[0]
|
||||||
|
|
||||||
return &MetadataInfo{
|
parsedMetadata := &MetadataInfo{
|
||||||
|
Type: TYPE_EPUB,
|
||||||
Title: &rf.Title,
|
Title: &rf.Title,
|
||||||
Author: &rf.Creator,
|
Author: &rf.Creator,
|
||||||
Description: &rf.Description,
|
Description: &rf.Description,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Parse Possible ISBN
|
||||||
|
if rf.Source != "" {
|
||||||
|
replaceRE := regexp.MustCompile(`[-\s]`)
|
||||||
|
possibleISBN := replaceRE.ReplaceAllString(rf.Source, "")
|
||||||
|
|
||||||
|
// ISBN Matches
|
||||||
|
isbn13RE := regexp.MustCompile(`(?P<ISBN>\d{13})`)
|
||||||
|
isbn10RE := regexp.MustCompile(`(?P<ISBN>\d{10})`)
|
||||||
|
isbn13Matches := isbn13RE.FindStringSubmatch(possibleISBN)
|
||||||
|
isbn10Matches := isbn10RE.FindStringSubmatch(possibleISBN)
|
||||||
|
|
||||||
|
if len(isbn13Matches) > 0 {
|
||||||
|
isbnIndex := isbn13RE.SubexpIndex("ISBN")
|
||||||
|
parsedMetadata.ISBN13 = &isbn13Matches[isbnIndex]
|
||||||
|
} else if len(isbn10Matches) > 0 {
|
||||||
|
isbnIndex := isbn10RE.SubexpIndex("ISBN")
|
||||||
|
parsedMetadata.ISBN10 = &isbn10Matches[isbnIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedMetadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func countEPUBWords(filepath string) (int64, error) {
|
func countEPUBWords(filepath string) (int64, error) {
|
||||||
|
|||||||
@@ -122,33 +122,33 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
|||||||
// Validate File Doesn't Exists
|
// Validate File Doesn't Exists
|
||||||
_, err := os.Stat(coverFilePath)
|
_, err := os.Stat(coverFilePath)
|
||||||
if err == nil && overwrite == false {
|
if err == nil && overwrite == false {
|
||||||
log.Warn("[saveGBooksCover] File Alreads Exists")
|
log.Warn("File Alreads Exists")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create File
|
// Create File
|
||||||
out, err := os.Create(coverFilePath)
|
out, err := os.Create(coverFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[saveGBooksCover] File Create Error")
|
log.Error("File Create Error")
|
||||||
return errors.New("File Failure")
|
return errors.New("File Failure")
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Download File
|
// Download File
|
||||||
log.Info("[saveGBooksCover] Downloading Cover")
|
log.Info("Downloading Cover")
|
||||||
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
|
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
|
||||||
resp, err := http.Get(coverURL)
|
resp, err := http.Get(coverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[saveGBooksCover] Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return errors.New("API Failure")
|
return errors.New("API Failure")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Copy File to Disk
|
// Copy File to Disk
|
||||||
log.Info("[saveGBooksCover] Saving Cover")
|
log.Info("Saving Cover")
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[saveGBooksCover] File Copy Error")
|
log.Error("File Copy Error")
|
||||||
return errors.New("File Failure")
|
return errors.New("File Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,22 +157,22 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
|
|||||||
|
|
||||||
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
||||||
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
|
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
|
||||||
log.Info("[performSearchRequest] Acquiring Metadata: ", apiQuery)
|
log.Info("Acquiring Metadata: ", apiQuery)
|
||||||
resp, err := http.Get(apiQuery)
|
resp, err := http.Get(apiQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[performSearchRequest] Google Books Query URL API Failure")
|
log.Error("Google Books Query URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedResp := gBooksQueryResponse{}
|
parsedResp := gBooksQueryResponse{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[performSearchRequest] Google Books Query API Decode Failure")
|
log.Error("Google Books Query API Decode Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parsedResp.Items) == 0 {
|
if len(parsedResp.Items) == 0 {
|
||||||
log.Warn("[performSearchRequest] No Results")
|
log.Warn("No Results")
|
||||||
return nil, errors.New("No Results")
|
return nil, errors.New("No Results")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,17 +182,17 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
|
|||||||
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
|
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
|
||||||
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
|
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
|
||||||
|
|
||||||
log.Info("[performGBIDRequest] Acquiring CoverID")
|
log.Info("Acquiring CoverID")
|
||||||
resp, err := http.Get(apiQuery)
|
resp, err := http.Get(apiQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[performGBIDRequest] Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedResp := gBooksQueryItem{}
|
parsedResp := gBooksQueryItem{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[performGBIDRequest] Google Books ID API Decode Failure")
|
log.Error("Google Books ID API Decode Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
metadata/gbooks_test.go
Normal file
126
metadata/gbooks_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jarcoal/httpmock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed _test_files/gbooks_id_response.json
|
||||||
|
var idResp string
|
||||||
|
|
||||||
|
//go:embed _test_files/gbooks_query_response.json
|
||||||
|
var queryResp string
|
||||||
|
|
||||||
|
type details struct {
|
||||||
|
URLs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook API Helper
|
||||||
|
func hookAPI() *details {
|
||||||
|
// Start HTTPMock
|
||||||
|
httpmock.Activate()
|
||||||
|
|
||||||
|
// Create details struct
|
||||||
|
d := &details{
|
||||||
|
URLs: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Hook
|
||||||
|
matchRE := regexp.MustCompile(`^https://www\.googleapis\.com/books/v1/volumes.*`)
|
||||||
|
httpmock.RegisterRegexpResponder("GET", matchRE, func(req *http.Request) (*http.Response, error) {
|
||||||
|
// Append URL
|
||||||
|
d.URLs = append(d.URLs, req.URL.String())
|
||||||
|
|
||||||
|
// Get Raw Response
|
||||||
|
var rawResp string
|
||||||
|
if req.URL.Query().Get("q") != "" {
|
||||||
|
rawResp = queryResp
|
||||||
|
} else {
|
||||||
|
rawResp = idResp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to JSON Response
|
||||||
|
var responseData map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(rawResp), &responseData)
|
||||||
|
|
||||||
|
// Return Response
|
||||||
|
return httpmock.NewJsonResponse(200, responseData)
|
||||||
|
})
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGBooksGBIDMetadata(t *testing.T) {
|
||||||
|
hookDetails := hookAPI()
|
||||||
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
|
GBID := "ZxwpakTv_MIC"
|
||||||
|
expectedURL := fmt.Sprintf(GBOOKS_GBID_INFO_URL, GBID)
|
||||||
|
metadataResp, err := getGBooksMetadata(MetadataInfo{ID: &GBID})
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should not have error")
|
||||||
|
assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL")
|
||||||
|
assert.Equal(t, 1, len(metadataResp), "should have single result")
|
||||||
|
|
||||||
|
mResult := metadataResp[0]
|
||||||
|
validateResult(t, &mResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGBooksISBNQuery(t *testing.T) {
|
||||||
|
hookDetails := hookAPI()
|
||||||
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
|
ISBN10 := "1877527815"
|
||||||
|
expectedURL := fmt.Sprintf(GBOOKS_QUERY_URL, "isbn:"+ISBN10)
|
||||||
|
metadataResp, err := getGBooksMetadata(MetadataInfo{
|
||||||
|
ISBN10: &ISBN10,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should not have error")
|
||||||
|
assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL")
|
||||||
|
assert.Equal(t, 1, len(metadataResp), "should have single result")
|
||||||
|
|
||||||
|
mResult := metadataResp[0]
|
||||||
|
validateResult(t, &mResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGBooksTitleQuery(t *testing.T) {
|
||||||
|
hookDetails := hookAPI()
|
||||||
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
|
title := "Alice in Wonderland 1877527815"
|
||||||
|
expectedURL := fmt.Sprintf(GBOOKS_QUERY_URL, url.QueryEscape(strings.TrimSpace(title)))
|
||||||
|
metadataResp, err := getGBooksMetadata(MetadataInfo{
|
||||||
|
Title: &title,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should not have error")
|
||||||
|
assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL")
|
||||||
|
assert.NotEqual(t, 0, len(metadataResp), "should not have no results")
|
||||||
|
|
||||||
|
mResult := metadataResp[0]
|
||||||
|
validateResult(t, &mResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResult(t *testing.T, m *MetadataInfo) {
|
||||||
|
expectedTitle := "Alice in Wonderland"
|
||||||
|
expectedAuthor := "Lewis Carroll"
|
||||||
|
expectedDesc := "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures. Lewis Carroll's prominent example of the genre of \"literary nonsense\" has endured in popularity with its clever way of playing with logic and a narrative structure that has influence generations of fiction writing."
|
||||||
|
expectedISBN10 := "1877527815"
|
||||||
|
expectedISBN13 := "9781877527814"
|
||||||
|
|
||||||
|
assert.Equal(t, expectedTitle, *m.Title, "should have title")
|
||||||
|
assert.Equal(t, expectedAuthor, *m.Author, "should have author")
|
||||||
|
assert.Equal(t, expectedDesc, *m.Description, "should have description")
|
||||||
|
assert.Equal(t, expectedISBN10, *m.ISBN10, "should have ISBN10")
|
||||||
|
assert.Equal(t, expectedISBN13, *m.ISBN13, "should have ISBN10")
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package metadata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGBooksGBIDMetadata(t *testing.T) {
|
|
||||||
GBID := "ZxwpakTv_MIC"
|
|
||||||
metadataResp, err := getGBooksMetadata(MetadataInfo{
|
|
||||||
ID: &GBID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(metadataResp) != 1 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, len(metadataResp), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mResult := metadataResp[0]
|
|
||||||
validateResult(&mResult, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGBooksISBNQuery(t *testing.T) {
|
|
||||||
ISBN10 := "1877527815"
|
|
||||||
metadataResp, err := getGBooksMetadata(MetadataInfo{
|
|
||||||
ISBN10: &ISBN10,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(metadataResp) != 1 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, len(metadataResp), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mResult := metadataResp[0]
|
|
||||||
validateResult(&mResult, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGBooksTitleQuery(t *testing.T) {
|
|
||||||
title := "Alice in Wonderland 1877527815"
|
|
||||||
metadataResp, err := getGBooksMetadata(MetadataInfo{
|
|
||||||
Title: &title,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(metadataResp) == 0 {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, "> 0", len(metadataResp), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mResult := metadataResp[0]
|
|
||||||
validateResult(&mResult, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateResult(m *MetadataInfo, t *testing.T) {
|
|
||||||
expect := "Lewis Carroll"
|
|
||||||
if *m.Author != expect {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Author)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect = "Alice in Wonderland"
|
|
||||||
if *m.Title != expect {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect = "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures. Lewis Carroll's prominent example of the genre of \"literary nonsense\" has endured in popularity with its clever way of playing with logic and a narrative structure that has influence generations of fiction writing."
|
|
||||||
if *m.Description != expect {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect = "1877527815"
|
|
||||||
if *m.ISBN10 != expect {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, expect, *m.ISBN10)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect = "9781877527814"
|
|
||||||
if *m.ISBN13 != expect {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v`, expect, *m.ISBN13)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,27 +3,47 @@ package metadata
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"reichard.io/antholume/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MetadataHandler func(string) (*MetadataInfo, error)
|
||||||
|
|
||||||
|
type DocumentType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TYPE_EPUB DocumentType = ".epub"
|
||||||
|
)
|
||||||
|
|
||||||
|
var extensionHandlerMap = map[DocumentType]MetadataHandler{
|
||||||
|
TYPE_EPUB: getEPUBMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
type Source int
|
type Source int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
GBOOK Source = iota
|
SOURCE_GBOOK Source = iota
|
||||||
OLIB
|
SOURCE_OLIB
|
||||||
)
|
)
|
||||||
|
|
||||||
type MetadataInfo struct {
|
type MetadataInfo struct {
|
||||||
ID *string
|
ID *string
|
||||||
|
MD5 *string
|
||||||
|
PartialMD5 *string
|
||||||
|
WordCount *int64
|
||||||
|
|
||||||
Title *string
|
Title *string
|
||||||
Author *string
|
Author *string
|
||||||
Description *string
|
Description *string
|
||||||
ISBN10 *string
|
ISBN10 *string
|
||||||
ISBN13 *string
|
ISBN13 *string
|
||||||
|
Type DocumentType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Downloads the Google Books cover file and saves it to the provided directory.
|
||||||
func CacheCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
|
func CacheCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
|
||||||
// Get Filepath
|
// Get Filepath
|
||||||
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
|
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
|
||||||
@@ -39,11 +59,12 @@ func CacheCover(gbid string, coverDir string, documentID string, overwrite bool)
|
|||||||
return &coverFile, nil
|
return &coverFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Searches source for metadata based on the provided information.
|
||||||
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case GBOOK:
|
case SOURCE_GBOOK:
|
||||||
return getGBooksMetadata(metadataSearch)
|
return getGBooksMetadata(metadataSearch)
|
||||||
case OLIB:
|
case SOURCE_OLIB:
|
||||||
return nil, errors.New("Not implemented")
|
return nil, errors.New("Not implemented")
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("Not implemented")
|
return nil, errors.New("Not implemented")
|
||||||
@@ -51,32 +72,112 @@ func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetWordCount(filepath string) (int64, error) {
|
// Returns the word count of the provided filepath. An error will be returned
|
||||||
fileMime, err := mimetype.DetectFile(filepath)
|
// if the file is not supported.
|
||||||
if err != nil {
|
func GetWordCount(filepath string) (*int64, error) {
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileExtension := fileMime.Extension(); fileExtension == ".epub" {
|
|
||||||
totalWords, err := countEPUBWords(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return totalWords, nil
|
|
||||||
} else {
|
|
||||||
return 0, errors.New("Invalid Extension")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetMetadata(filepath string) (*MetadataInfo, error) {
|
|
||||||
fileMime, err := mimetype.DetectFile(filepath)
|
fileMime, err := mimetype.DetectFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if fileExtension := fileMime.Extension(); fileExtension == ".epub" {
|
if fileExtension := fileMime.Extension(); fileExtension == ".epub" {
|
||||||
return getEPUBMetadata(filepath)
|
totalWords, err := countEPUBWords(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &totalWords, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New("Invalid Extension")
|
return nil, fmt.Errorf("Invalid extension")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns embedded metadata of the provided file. An error will be returned if
|
||||||
|
// the file is not supported.
|
||||||
|
func GetMetadata(filepath string) (*MetadataInfo, error) {
|
||||||
|
// Detect Extension Type
|
||||||
|
fileMime, err := mimetype.DetectFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Extension Type Metadata Handler
|
||||||
|
fileExtension := fileMime.Extension()
|
||||||
|
handler, ok := extensionHandlerMap[DocumentType(fileExtension)]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid extension %s", fileExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire Metadata
|
||||||
|
metadataInfo, err := handler(filepath)
|
||||||
|
|
||||||
|
// Calculate MD5 & Partial MD5
|
||||||
|
partialMD5, err := utils.CalculatePartialMD5(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to calculate partial MD5")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Actual MD5
|
||||||
|
MD5, err := utils.CalculateMD5(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to calculate MD5")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Word Count
|
||||||
|
wordCount, err := GetWordCount(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to calculate word count")
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataInfo.WordCount = wordCount
|
||||||
|
metadataInfo.PartialMD5 = partialMD5
|
||||||
|
metadataInfo.MD5 = MD5
|
||||||
|
|
||||||
|
return metadataInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the extension of the provided filepath (e.g. ".epub"). An error
|
||||||
|
// will be returned if the file is not supported.
|
||||||
|
func GetDocumentType(filepath string) (*DocumentType, error) {
|
||||||
|
// Detect Extension Type
|
||||||
|
fileMime, err := mimetype.DetectFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect
|
||||||
|
fileExtension := fileMime.Extension()
|
||||||
|
docType, ok := ParseDocumentType(fileExtension)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("filetype not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &docType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the extension of the provided file reader (e.g. ".epub"). An error
|
||||||
|
// will be returned if the file is not supported.
|
||||||
|
func GetDocumentTypeReader(r io.Reader) (*DocumentType, error) {
|
||||||
|
// Detect Extension Type
|
||||||
|
fileMime, err := mimetype.DetectReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect
|
||||||
|
fileExtension := fileMime.Extension()
|
||||||
|
docType, ok := ParseDocumentType(fileExtension)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("filetype not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &docType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a filetype string, attempt to resolve a DocumentType
|
||||||
|
func ParseDocumentType(input string) (DocumentType, bool) {
|
||||||
|
validTypes := map[string]DocumentType{
|
||||||
|
string(TYPE_EPUB): TYPE_EPUB,
|
||||||
|
}
|
||||||
|
found, ok := validTypes[input]
|
||||||
|
return found, ok
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,46 @@
|
|||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetWordCount(t *testing.T) {
|
func TestGetWordCount(t *testing.T) {
|
||||||
var want int64 = 30080
|
var desiredCount int64 = 30080
|
||||||
wordCount, err := countEPUBWords("../_test_files/alice.epub")
|
actualCount, err := countEPUBWords("../_test_files/alice.epub")
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should have no error")
|
||||||
|
assert.Equal(t, desiredCount, actualCount, "should be correct word count")
|
||||||
|
|
||||||
if wordCount != want {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, wordCount, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetMetadata(t *testing.T) {
|
func TestGetMetadata(t *testing.T) {
|
||||||
metadataInfo, err := getEPUBMetadata("../_test_files/alice.epub")
|
desiredTitle := "Alice's Adventures in Wonderland / Illustrated by Arthur Rackham. With a Proem by Austin Dobson"
|
||||||
if err != nil {
|
desiredAuthor := "Lewis Carroll"
|
||||||
t.Fatalf(`Expected: *MetadataInfo, Got: nil, Error: %v`, err)
|
desiredDescription := ""
|
||||||
}
|
|
||||||
|
|
||||||
want := "Alice's Adventures in Wonderland / Illustrated by Arthur Rackham. With a Proem by Austin Dobson"
|
metadataInfo, err := GetMetadata("../_test_files/alice.epub")
|
||||||
if *metadataInfo.Title != want {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Title, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "Lewis Carroll"
|
assert.Nil(t, err, "should have no error")
|
||||||
if *metadataInfo.Author != want {
|
assert.Equal(t, desiredTitle, *metadataInfo.Title, "should be correct title")
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Author, err)
|
assert.Equal(t, desiredAuthor, *metadataInfo.Author, "should be correct author")
|
||||||
}
|
assert.Equal(t, desiredDescription, *metadataInfo.Description, "should be correct author")
|
||||||
|
assert.Equal(t, TYPE_EPUB, metadataInfo.Type, "should be correct type")
|
||||||
want = ""
|
}
|
||||||
if *metadataInfo.Description != want {
|
|
||||||
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, *metadataInfo.Description, err)
|
func TestGetExtension(t *testing.T) {
|
||||||
}
|
docType, err := GetDocumentType("../_test_files/alice.epub")
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should have no error")
|
||||||
|
assert.Equal(t, TYPE_EPUB, *docType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetExtensionReader(t *testing.T) {
|
||||||
|
file, _ := os.Open("../_test_files/alice.epub")
|
||||||
|
docType, err := GetDocumentTypeReader(file)
|
||||||
|
|
||||||
|
assert.Nil(t, err, "should have no error")
|
||||||
|
assert.Equal(t, TYPE_EPUB, *docType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,24 +32,24 @@ const OLIB_ISBN_LINK_URL string = "https://openlibrary.org/isbn/%s"
|
|||||||
|
|
||||||
func GetCoverOLIDs(title *string, author *string) ([]string, error) {
|
func GetCoverOLIDs(title *string, author *string) ([]string, error) {
|
||||||
if title == nil || author == nil {
|
if title == nil || author == nil {
|
||||||
log.Error("[metadata] Invalid Search Query")
|
log.Error("Invalid Search Query")
|
||||||
return nil, errors.New("Invalid Query")
|
return nil, errors.New("Invalid Query")
|
||||||
}
|
}
|
||||||
|
|
||||||
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *title, *author))
|
searchQuery := url.QueryEscape(fmt.Sprintf("%s %s", *title, *author))
|
||||||
apiQuery := fmt.Sprintf(OLIB_QUERY_URL, searchQuery)
|
apiQuery := fmt.Sprintf(OLIB_QUERY_URL, searchQuery)
|
||||||
|
|
||||||
log.Info("[metadata] Acquiring CoverID")
|
log.Info("Acquiring CoverID")
|
||||||
resp, err := http.Get(apiQuery)
|
resp, err := http.Get(apiQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
target := oLibQueryResponse{}
|
target := oLibQueryResponse{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&target)
|
err = json.NewDecoder(resp.Body).Decode(&target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] Cover URL API Decode Failure")
|
log.Error("Cover URL API Decode Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,24 +73,24 @@ func DownloadAndSaveCover(coverID string, dirPath string) (*string, error) {
|
|||||||
// Validate File Doesn't Exists
|
// Validate File Doesn't Exists
|
||||||
_, err := os.Stat(safePath)
|
_, err := os.Stat(safePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.Warn("[metadata] File Alreads Exists")
|
log.Warn("File Alreads Exists")
|
||||||
return &safePath, nil
|
return &safePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create File
|
// Create File
|
||||||
out, err := os.Create(safePath)
|
out, err := os.Create(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] File Create Error")
|
log.Error("File Create Error")
|
||||||
return nil, errors.New("File Failure")
|
return nil, errors.New("File Failure")
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Download File
|
// Download File
|
||||||
log.Info("[metadata] Downloading Cover")
|
log.Info("Downloading Cover")
|
||||||
coverURL := fmt.Sprintf(OLIB_OLID_COVER_URL, coverID)
|
coverURL := fmt.Sprintf(OLIB_OLID_COVER_URL, coverID)
|
||||||
resp, err := http.Get(coverURL)
|
resp, err := http.Get(coverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] Cover URL API Failure")
|
log.Error("Cover URL API Failure")
|
||||||
return nil, errors.New("API Failure")
|
return nil, errors.New("API Failure")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -98,7 +98,7 @@ func DownloadAndSaveCover(coverID string, dirPath string) (*string, error) {
|
|||||||
// Copy File to Disk
|
// Copy File to Disk
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[metadata] File Copy Error")
|
log.Error("File Copy Error")
|
||||||
return nil, errors.New("File Failure")
|
return nil, errors.New("File Failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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,74 +0,0 @@
|
|||||||
{{ $data := (GetSVGGraphData .Data.GraphData 800 150 )}}
|
|
||||||
<svg viewBox="0 0 {{ $data.Width }} {{ $data.Height }}">
|
|
||||||
<!-- Box Graph -->
|
|
||||||
{{ range $idx, $item := $data.BarPoints }}
|
|
||||||
<g class="bar" transform="translate({{ $item.X }}, 0)" fill="gray">
|
|
||||||
<rect
|
|
||||||
y="{{ $item.Y }}"
|
|
||||||
height="{{ $item.Size }}"
|
|
||||||
width="{{ $data.Offset }}"
|
|
||||||
></rect>
|
|
||||||
</g>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<!-- Linear Line Graph -->
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
stroke="black"
|
|
||||||
stroke-width="2"
|
|
||||||
points="
|
|
||||||
{{ range $item := $data.LinePoints }}
|
|
||||||
{{ $item.X }},{{ $item.Y }}
|
|
||||||
{{ end }}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Bezier Line Graph -->
|
|
||||||
<path
|
|
||||||
fill="#316BBE"
|
|
||||||
fill-opacity="0.5"
|
|
||||||
stroke="none"
|
|
||||||
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
|
||||||
|
|
||||||
{{ range $index, $item := $data.LinePoints }}
|
|
||||||
<line
|
|
||||||
class="hover-trigger"
|
|
||||||
stroke="black"
|
|
||||||
stroke-opacity="0.0"
|
|
||||||
stroke-width="{{ $data.Offset }}"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="0"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<g class="hover-item">
|
|
||||||
<line
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
stroke-opacity="0.2"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="30"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 5) translate(-30, 8)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).Date }}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 25) translate(-30, -2)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).MinutesRead }} minutes
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
{{ end }}
|
|
||||||
</svg>
|
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
// Feed root element for acquisition or navigation feed
|
// Feed root element for acquisition or navigation feed
|
||||||
type Feed struct {
|
type Feed struct {
|
||||||
|
ID string `xml:"id,omitempty"`
|
||||||
XMLName xml.Name `xml:"feed"`
|
XMLName xml.Name `xml:"feed"`
|
||||||
ID string `xml:"id,omitempty",`
|
|
||||||
Title string `xml:"title,omitempty"`
|
Title string `xml:"title,omitempty"`
|
||||||
Updated time.Time `xml:"updated,omitempty"`
|
Updated time.Time `xml:"updated,omitempty"`
|
||||||
Entries []Entry `xml:"entry,omitempty"`
|
Entries []Entry `xml:"entry,omitempty"`
|
||||||
|
|||||||
75
search/anna.go
Normal file
75
search/anna.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseAnnasArchiveDownloadURL(body io.ReadCloser) (string, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, _ := goquery.NewDocumentFromReader(body)
|
||||||
|
|
||||||
|
// Return Download URL
|
||||||
|
downloadURL, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
|
||||||
|
if exists == false {
|
||||||
|
return "", fmt.Errorf("Download URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Possible Funky URL
|
||||||
|
downloadURL = strings.ReplaceAll(downloadURL, "\\", "/")
|
||||||
|
|
||||||
|
return downloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, err := goquery.NewDocumentFromReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Results
|
||||||
|
var allEntries []SearchItem
|
||||||
|
doc.Find("form > div.w-full > div.w-full > div > div.justify-center").Each(func(ix int, rawBook *goquery.Selection) {
|
||||||
|
// Parse Details
|
||||||
|
details := rawBook.Find("div:nth-child(2) > div:nth-child(1)").Text()
|
||||||
|
detailsSplit := strings.Split(details, ", ")
|
||||||
|
|
||||||
|
// Invalid Details
|
||||||
|
if len(detailsSplit) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
language := detailsSplit[0]
|
||||||
|
fileType := detailsSplit[1]
|
||||||
|
fileSize := detailsSplit[2]
|
||||||
|
|
||||||
|
// Get Title & Author
|
||||||
|
title := rawBook.Find("h3").Text()
|
||||||
|
author := rawBook.Find("div:nth-child(2) > div:nth-child(4)").Text()
|
||||||
|
|
||||||
|
// Parse MD5
|
||||||
|
itemHref, _ := rawBook.Find("a").Attr("href")
|
||||||
|
hrefArray := strings.Split(itemHref, "/")
|
||||||
|
id := hrefArray[len(hrefArray)-1]
|
||||||
|
|
||||||
|
item := SearchItem{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Author: author,
|
||||||
|
Language: language,
|
||||||
|
FileType: fileType,
|
||||||
|
FileSize: fileSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return Results
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
42
search/goodreads.go
Normal file
42
search/goodreads.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
|
||||||
|
body, err := getPage("https://www.goodreads.com/book/most_read?category=all&country=US&duration=" + string(c))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseGoodReads(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGoodReads(body io.ReadCloser) ([]SearchItem, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, err := goquery.NewDocumentFromReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Results
|
||||||
|
var allEntries []SearchItem
|
||||||
|
|
||||||
|
doc.Find("[itemtype=\"http://schema.org/Book\"]").Each(func(ix int, rawBook *goquery.Selection) {
|
||||||
|
title := rawBook.Find(".bookTitle span").Text()
|
||||||
|
author := rawBook.Find(".authorName span").Text()
|
||||||
|
|
||||||
|
item := SearchItem{
|
||||||
|
Title: title,
|
||||||
|
Author: author,
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return Results
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
123
search/libgen.go
Normal file
123
search/libgen.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseLibGenFiction(body io.ReadCloser) ([]SearchItem, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, err := goquery.NewDocumentFromReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Results
|
||||||
|
var allEntries []SearchItem
|
||||||
|
doc.Find("table.catalog tbody > tr").Each(func(ix int, rawBook *goquery.Selection) {
|
||||||
|
|
||||||
|
// Parse File Details
|
||||||
|
fileItem := rawBook.Find("td:nth-child(5)")
|
||||||
|
fileDesc := fileItem.Text()
|
||||||
|
fileDescSplit := strings.Split(fileDesc, "/")
|
||||||
|
fileType := strings.ToLower(strings.TrimSpace(fileDescSplit[0]))
|
||||||
|
fileSize := strings.TrimSpace(fileDescSplit[1])
|
||||||
|
|
||||||
|
// Parse Upload Date
|
||||||
|
uploadedRaw, _ := fileItem.Attr("title")
|
||||||
|
uploadedDateRaw := strings.Split(uploadedRaw, "Uploaded at ")[1]
|
||||||
|
uploadDate, _ := time.Parse("2006-01-02 15:04:05", uploadedDateRaw)
|
||||||
|
|
||||||
|
// Parse MD5
|
||||||
|
editHref, _ := rawBook.Find("td:nth-child(7) a").Attr("href")
|
||||||
|
hrefArray := strings.Split(editHref, "/")
|
||||||
|
id := hrefArray[len(hrefArray)-1]
|
||||||
|
|
||||||
|
// Parse Other Details
|
||||||
|
title := rawBook.Find("td:nth-child(3) p a").Text()
|
||||||
|
author := rawBook.Find(".catalog_authors li a").Text()
|
||||||
|
language := rawBook.Find("td:nth-child(4)").Text()
|
||||||
|
series := rawBook.Find("td:nth-child(2)").Text()
|
||||||
|
|
||||||
|
item := SearchItem{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Author: author,
|
||||||
|
Series: series,
|
||||||
|
Language: language,
|
||||||
|
FileType: fileType,
|
||||||
|
FileSize: fileSize,
|
||||||
|
UploadDate: uploadDate.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return Results
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLibGenNonFiction(body io.ReadCloser) ([]SearchItem, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, err := goquery.NewDocumentFromReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Results
|
||||||
|
var allEntries []SearchItem
|
||||||
|
doc.Find("table.c tbody > tr:nth-child(n + 2)").Each(func(ix int, rawBook *goquery.Selection) {
|
||||||
|
|
||||||
|
// Parse Type & Size
|
||||||
|
fileSize := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(8)").Text()))
|
||||||
|
fileType := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(9)").Text()))
|
||||||
|
|
||||||
|
// Parse MD5
|
||||||
|
titleRaw := rawBook.Find("td:nth-child(3) [id]")
|
||||||
|
editHref, _ := titleRaw.Attr("href")
|
||||||
|
hrefArray := strings.Split(editHref, "?md5=")
|
||||||
|
id := hrefArray[1]
|
||||||
|
|
||||||
|
// Parse Other Details
|
||||||
|
title := titleRaw.Text()
|
||||||
|
author := rawBook.Find("td:nth-child(2)").Text()
|
||||||
|
language := rawBook.Find("td:nth-child(7)").Text()
|
||||||
|
series := rawBook.Find("td:nth-child(3) [href*='column=series']").Text()
|
||||||
|
|
||||||
|
item := SearchItem{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Author: author,
|
||||||
|
Series: series,
|
||||||
|
Language: language,
|
||||||
|
FileType: fileType,
|
||||||
|
FileSize: fileSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return Results
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLibGenDownloadURL(body io.ReadCloser) (string, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, _ := goquery.NewDocumentFromReader(body)
|
||||||
|
|
||||||
|
// Return Download URL
|
||||||
|
// downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href")
|
||||||
|
downloadURL, exists := doc.Find("#download h2 a").Attr("href")
|
||||||
|
if exists == false {
|
||||||
|
return "", fmt.Errorf("Download URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadURL, nil
|
||||||
|
}
|
||||||
316
search/search.go
316
search/search.go
@@ -1,23 +1,24 @@
|
|||||||
package search
|
package search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const userAgent string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||||
|
|
||||||
type Cadence string
|
type Cadence string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TOP_YEAR Cadence = "y"
|
CADENCE_TOP_YEAR Cadence = "y"
|
||||||
TOP_MONTH Cadence = "m"
|
CADENCE_TOP_MONTH Cadence = "m"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BookType int
|
type BookType int
|
||||||
@@ -27,6 +28,14 @@ const (
|
|||||||
BOOK_NON_FICTION
|
BOOK_NON_FICTION
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive"
|
||||||
|
SOURCE_LIBGEN_FICTION Source = "LibGen Fiction"
|
||||||
|
SOURCE_LIBGEN_NON_FICTION Source = "LibGen Non-fiction"
|
||||||
|
)
|
||||||
|
|
||||||
type SearchItem struct {
|
type SearchItem struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
@@ -38,34 +47,89 @@ type SearchItem struct {
|
|||||||
UploadDate string
|
UploadDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchBook(query string, bookType BookType) ([]SearchItem, error) {
|
type sourceDef struct {
|
||||||
if bookType == BOOK_FICTION {
|
searchURL string
|
||||||
// Search Fiction
|
downloadURL string
|
||||||
url := "https://libgen.is/fiction/?q=" + url.QueryEscape(query) + "&language=English&format=epub"
|
parseSearchFunc func(io.ReadCloser) ([]SearchItem, error)
|
||||||
body, err := getPage(url)
|
parseDownloadFunc func(io.ReadCloser) (string, error)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseLibGenFiction(body)
|
|
||||||
} else if bookType == BOOK_NON_FICTION {
|
|
||||||
// Search NonFiction
|
|
||||||
url := "https://libgen.is/search.php?req=" + url.QueryEscape(query)
|
|
||||||
body, err := getPage(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseLibGenNonFiction(body)
|
|
||||||
} else {
|
|
||||||
return nil, errors.New("Invalid Book Type")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
|
var sourceDefs = map[Source]sourceDef{
|
||||||
body, err := getPage("https://www.goodreads.com/book/most_read?category=all&country=US&duration=" + string(c))
|
SOURCE_ANNAS_ARCHIVE: {
|
||||||
|
searchURL: "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en",
|
||||||
|
downloadURL: "http://libgen.li/ads.php?md5=%s",
|
||||||
|
parseSearchFunc: parseAnnasArchive,
|
||||||
|
parseDownloadFunc: parseAnnasArchiveDownloadURL,
|
||||||
|
},
|
||||||
|
SOURCE_LIBGEN_FICTION: {
|
||||||
|
searchURL: "https://libgen.is/fiction/?q=%s&language=English&format=epub",
|
||||||
|
downloadURL: "http://library.lol/fiction/%s",
|
||||||
|
parseSearchFunc: parseLibGenFiction,
|
||||||
|
parseDownloadFunc: parseLibGenDownloadURL,
|
||||||
|
},
|
||||||
|
SOURCE_LIBGEN_NON_FICTION: {
|
||||||
|
searchURL: "https://libgen.is/search.php?req=%s",
|
||||||
|
downloadURL: "http://library.lol/main/%s",
|
||||||
|
parseSearchFunc: parseLibGenNonFiction,
|
||||||
|
parseDownloadFunc: parseLibGenDownloadURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchBook(query string, source Source) ([]SearchItem, error) {
|
||||||
|
def := sourceDefs[source]
|
||||||
|
log.Debug("Source: ", def)
|
||||||
|
url := fmt.Sprintf(def.searchURL, url.QueryEscape(query))
|
||||||
|
body, err := getPage(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return parseGoodReads(body)
|
return def.parseSearchFunc(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveBook(id string, source Source) (string, error) {
|
||||||
|
def := sourceDefs[source]
|
||||||
|
log.Debug("Source: ", def)
|
||||||
|
url := fmt.Sprintf(def.downloadURL, id)
|
||||||
|
|
||||||
|
body, err := getPage(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
bookURL, err := def.parseDownloadFunc(body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Parse Download URL Error: ", err)
|
||||||
|
return "", fmt.Errorf("Download Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create File
|
||||||
|
tempFile, err := os.CreateTemp("", "book")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("File Create Error: ", err)
|
||||||
|
return "", fmt.Errorf("File Failure")
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Download File
|
||||||
|
log.Info("Downloading Book: ", bookURL)
|
||||||
|
resp, err := downloadBook(bookURL)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
log.Error("Book URL API Failure: ", err)
|
||||||
|
return "", fmt.Errorf("API Failure")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy File to Disk
|
||||||
|
log.Info("Saving Book")
|
||||||
|
_, err = io.Copy(tempFile, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
log.Error("File Copy Error: ", err)
|
||||||
|
return "", fmt.Errorf("File Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile.Name(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBookURL(id string, bookType BookType) (string, error) {
|
func GetBookURL(id string, bookType BookType) (string, error) {
|
||||||
@@ -87,57 +151,9 @@ func GetBookURL(id string, bookType BookType) (string, error) {
|
|||||||
return parseLibGenDownloadURL(body)
|
return parseLibGenDownloadURL(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveBook(id string, bookType BookType) (string, error) {
|
|
||||||
// Derive Info URL
|
|
||||||
var infoURL string
|
|
||||||
if bookType == BOOK_FICTION {
|
|
||||||
infoURL = "http://library.lol/fiction/" + id
|
|
||||||
} else if bookType == BOOK_NON_FICTION {
|
|
||||||
infoURL = "http://library.lol/main/" + id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse & Derive Download URL
|
|
||||||
body, err := getPage(infoURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
bookURL, err := parseLibGenDownloadURL(body)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[SaveBook] Parse Download URL Error: ", err)
|
|
||||||
return "", errors.New("Download Failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create File
|
|
||||||
tempFile, err := os.CreateTemp("", "book")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[SaveBook] File Create Error: ", err)
|
|
||||||
return "", errors.New("File Failure")
|
|
||||||
}
|
|
||||||
defer tempFile.Close()
|
|
||||||
|
|
||||||
// Download File
|
|
||||||
log.Info("[SaveBook] Downloading Book")
|
|
||||||
resp, err := http.Get(bookURL)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tempFile.Name())
|
|
||||||
log.Error("[SaveBook] Cover URL API Failure")
|
|
||||||
return "", errors.New("API Failure")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Copy File to Disk
|
|
||||||
log.Info("[SaveBook] Saving Book")
|
|
||||||
_, err = io.Copy(tempFile, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tempFile.Name())
|
|
||||||
log.Error("[SaveBook] File Copy Error")
|
|
||||||
return "", errors.New("File Failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempFile.Name(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPage(page string) (io.ReadCloser, error) {
|
func getPage(page string) (io.ReadCloser, error) {
|
||||||
|
log.Debug("URL: ", page)
|
||||||
|
|
||||||
// Set 10s Timeout
|
// Set 10s Timeout
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
@@ -153,142 +169,20 @@ func getPage(page string) (io.ReadCloser, error) {
|
|||||||
return resp.Body, err
|
return resp.Body, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLibGenFiction(body io.ReadCloser) ([]SearchItem, error) {
|
func downloadBook(bookURL string) (*http.Response, error) {
|
||||||
// Parse
|
// Allow Insecure
|
||||||
defer body.Close()
|
client := &http.Client{Transport: &http.Transport{
|
||||||
doc, err := goquery.NewDocumentFromReader(body)
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Start Request
|
||||||
|
req, err := http.NewRequest("GET", bookURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize Results
|
// Set UserAgent
|
||||||
var allEntries []SearchItem
|
req.Header.Set("User-Agent", userAgent)
|
||||||
doc.Find("table.catalog tbody > tr").Each(func(ix int, rawBook *goquery.Selection) {
|
|
||||||
|
|
||||||
// Parse File Details
|
return client.Do(req)
|
||||||
fileItem := rawBook.Find("td:nth-child(5)")
|
|
||||||
fileDesc := fileItem.Text()
|
|
||||||
fileDescSplit := strings.Split(fileDesc, "/")
|
|
||||||
fileType := strings.ToLower(strings.TrimSpace(fileDescSplit[0]))
|
|
||||||
fileSize := strings.TrimSpace(fileDescSplit[1])
|
|
||||||
|
|
||||||
// Parse Upload Date
|
|
||||||
uploadedRaw, _ := fileItem.Attr("title")
|
|
||||||
uploadedDateRaw := strings.Split(uploadedRaw, "Uploaded at ")[1]
|
|
||||||
uploadDate, _ := time.Parse("2006-01-02 15:04:05", uploadedDateRaw)
|
|
||||||
|
|
||||||
// Parse MD5
|
|
||||||
editHref, _ := rawBook.Find("td:nth-child(7) a").Attr("href")
|
|
||||||
hrefArray := strings.Split(editHref, "/")
|
|
||||||
id := hrefArray[len(hrefArray)-1]
|
|
||||||
|
|
||||||
// Parse Other Details
|
|
||||||
title := rawBook.Find("td:nth-child(3) p a").Text()
|
|
||||||
author := rawBook.Find(".catalog_authors li a").Text()
|
|
||||||
language := rawBook.Find("td:nth-child(4)").Text()
|
|
||||||
series := rawBook.Find("td:nth-child(2)").Text()
|
|
||||||
|
|
||||||
item := SearchItem{
|
|
||||||
ID: id,
|
|
||||||
Title: title,
|
|
||||||
Author: author,
|
|
||||||
Series: series,
|
|
||||||
Language: language,
|
|
||||||
FileType: fileType,
|
|
||||||
FileSize: fileSize,
|
|
||||||
UploadDate: uploadDate.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
allEntries = append(allEntries, item)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return Results
|
|
||||||
return allEntries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLibGenNonFiction(body io.ReadCloser) ([]SearchItem, error) {
|
|
||||||
// Parse
|
|
||||||
defer body.Close()
|
|
||||||
doc, err := goquery.NewDocumentFromReader(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize Results
|
|
||||||
var allEntries []SearchItem
|
|
||||||
doc.Find("table.c tbody > tr:nth-child(n + 2)").Each(func(ix int, rawBook *goquery.Selection) {
|
|
||||||
|
|
||||||
// Parse Type & Size
|
|
||||||
fileSize := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(8)").Text()))
|
|
||||||
fileType := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(9)").Text()))
|
|
||||||
|
|
||||||
// Parse MD5
|
|
||||||
titleRaw := rawBook.Find("td:nth-child(3) [id]")
|
|
||||||
editHref, _ := titleRaw.Attr("href")
|
|
||||||
hrefArray := strings.Split(editHref, "?md5=")
|
|
||||||
id := hrefArray[1]
|
|
||||||
|
|
||||||
// Parse Other Details
|
|
||||||
title := titleRaw.Text()
|
|
||||||
author := rawBook.Find("td:nth-child(2)").Text()
|
|
||||||
language := rawBook.Find("td:nth-child(7)").Text()
|
|
||||||
series := rawBook.Find("td:nth-child(3) [href*='column=series']").Text()
|
|
||||||
|
|
||||||
item := SearchItem{
|
|
||||||
ID: id,
|
|
||||||
Title: title,
|
|
||||||
Author: author,
|
|
||||||
Series: series,
|
|
||||||
Language: language,
|
|
||||||
FileType: fileType,
|
|
||||||
FileSize: fileSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
allEntries = append(allEntries, item)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return Results
|
|
||||||
return allEntries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLibGenDownloadURL(body io.ReadCloser) (string, error) {
|
|
||||||
// Parse
|
|
||||||
defer body.Close()
|
|
||||||
doc, _ := goquery.NewDocumentFromReader(body)
|
|
||||||
|
|
||||||
// Return Download URL
|
|
||||||
// downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href")
|
|
||||||
downloadURL, exists := doc.Find("#download h2 a").Attr("href")
|
|
||||||
if exists == false {
|
|
||||||
return "", errors.New("Download URL not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return downloadURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGoodReads(body io.ReadCloser) ([]SearchItem, error) {
|
|
||||||
// Parse
|
|
||||||
defer body.Close()
|
|
||||||
doc, err := goquery.NewDocumentFromReader(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize Results
|
|
||||||
var allEntries []SearchItem
|
|
||||||
|
|
||||||
doc.Find("[itemtype=\"http://schema.org/Book\"]").Each(func(ix int, rawBook *goquery.Selection) {
|
|
||||||
title := rawBook.Find(".bookTitle span").Text()
|
|
||||||
author := rawBook.Find(".authorName span").Text()
|
|
||||||
|
|
||||||
item := SearchItem{
|
|
||||||
Title: title,
|
|
||||||
Author: author,
|
|
||||||
}
|
|
||||||
|
|
||||||
allEntries = append(allEntries, item)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return Results
|
|
||||||
return allEntries, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
107
server/server.go
107
server/server.go
@@ -1,101 +1,90 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"reichard.io/bbank/api"
|
"reichard.io/antholume/api"
|
||||||
"reichard.io/bbank/config"
|
"reichard.io/antholume/config"
|
||||||
"reichard.io/bbank/database"
|
"reichard.io/antholume/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type server struct {
|
||||||
API *api.API
|
db *database.DBManager
|
||||||
Config *config.Config
|
api *api.API
|
||||||
Database *database.DBManager
|
done chan int
|
||||||
httpServer *http.Server
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer() *Server {
|
// Create new server
|
||||||
c := config.Load()
|
func New(c *config.Config, assets fs.FS) *server {
|
||||||
db := database.NewMgr(c)
|
db := database.NewMgr(c)
|
||||||
api := api.NewApi(db, c)
|
api := api.NewApi(db, c, assets)
|
||||||
|
|
||||||
// Create Paths
|
return &server{
|
||||||
docDir := filepath.Join(c.DataPath, "documents")
|
db: db,
|
||||||
coversDir := filepath.Join(c.DataPath, "covers")
|
api: api,
|
||||||
os.Mkdir(docDir, os.ModePerm)
|
done: make(chan int),
|
||||||
os.Mkdir(coversDir, os.ModePerm)
|
|
||||||
|
|
||||||
return &Server{
|
|
||||||
API: api,
|
|
||||||
Config: c,
|
|
||||||
Database: db,
|
|
||||||
httpServer: &http.Server{
|
|
||||||
Handler: api.Router,
|
|
||||||
Addr: (":" + c.ListenPort),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StartServer(wg *sync.WaitGroup, done <-chan struct{}) {
|
// Start server
|
||||||
ticker := time.NewTicker(15 * time.Minute)
|
func (s *server) Start() {
|
||||||
|
log.Info("Starting server...")
|
||||||
wg.Add(2)
|
s.wg.Add(2)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := s.api.Start()
|
||||||
if err != nil && err != http.ErrServerClosed {
|
if err != nil && err != http.ErrServerClosed {
|
||||||
log.Error("Error Starting Server:", err)
|
log.Error("Starting server failed: ", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer s.wg.Done()
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
s.RunScheduledTasks()
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
s.RunScheduledTasks()
|
s.runScheduledTasks()
|
||||||
case <-done:
|
case <-s.done:
|
||||||
log.Info("Stopping Task Runner...")
|
log.Info("Stopping task runner...")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
log.Info("Server started")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) RunScheduledTasks() {
|
// Stop server
|
||||||
log.Info("[RunScheduledTasks] Refreshing Temp Table Cache")
|
func (s *server) Stop() {
|
||||||
if err := s.API.DB.CacheTempTables(); err != nil {
|
log.Info("Stopping server...")
|
||||||
log.Warn("[RunScheduledTasks] Refreshing Temp Table Cache Failure:", err)
|
|
||||||
|
if err := s.api.Stop(); err != nil {
|
||||||
|
log.Error("HTTP server stop failed: ", err)
|
||||||
}
|
}
|
||||||
log.Info("[RunScheduledTasks] Refreshing Temp Table Success")
|
|
||||||
|
close(s.done)
|
||||||
|
s.wg.Wait()
|
||||||
|
|
||||||
|
log.Info("Server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StopServer(wg *sync.WaitGroup, done chan<- struct{}) {
|
// Run normal scheduled tasks
|
||||||
log.Info("Stopping HTTP Server...")
|
func (s *server) runScheduledTasks() {
|
||||||
|
start := time.Now()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
if err := s.db.CacheTempTables(); err != nil {
|
||||||
defer cancel()
|
log.Warn("Refreshing temp table cache failed: ", err)
|
||||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
|
||||||
log.Info("Shutting Error")
|
|
||||||
}
|
}
|
||||||
s.API.DB.Shutdown()
|
log.Debug("Completed in: ", time.Since(start))
|
||||||
|
|
||||||
close(done)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
log.Info("Server Stopped")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ pkgs.mkShell {
|
|||||||
nodePackages.tailwindcss
|
nodePackages.tailwindcss
|
||||||
python311Packages.grip
|
python311Packages.grip
|
||||||
];
|
];
|
||||||
|
shellHook = ''
|
||||||
|
export PATH=$PATH:~/go/bin
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ sql:
|
|||||||
package: "database"
|
package: "database"
|
||||||
out: "database"
|
out: "database"
|
||||||
emit_json_tags: true
|
emit_json_tags: true
|
||||||
|
emit_pointers_for_null_types: true
|
||||||
overrides:
|
overrides:
|
||||||
# Documents
|
# Documents
|
||||||
- column: "documents.md5"
|
- column: "documents.md5"
|
||||||
@@ -123,6 +124,10 @@ sql:
|
|||||||
go_type:
|
go_type:
|
||||||
type: "string"
|
type: "string"
|
||||||
pointer: true
|
pointer: true
|
||||||
|
- column: "users.auth_hash"
|
||||||
|
go_type:
|
||||||
|
type: "string"
|
||||||
|
pointer: true
|
||||||
|
|
||||||
# Override Time
|
# Override Time
|
||||||
- db_type: "DATETIME"
|
- db_type: "DATETIME"
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./templates/**/*.html",
|
"./templates/**/*.{tmpl,html,htm,svg}",
|
||||||
"./assets/local/*.{html,js}",
|
"./assets/local/*.{html,htm,svg,js}",
|
||||||
"./assets/reader/*.{html,js}",
|
"./assets/reader/*.{html,htm,svg,js}",
|
||||||
|
],
|
||||||
|
safelist: [
|
||||||
|
"peer-checked/All:block",
|
||||||
|
"peer-checked/Year:block",
|
||||||
|
"peer-checked/Month:block",
|
||||||
|
"peer-checked/Week:block",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
minWidth: {
|
||||||
|
40: "10rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define "header"}}
|
|
||||||
<a href="./activity">Activity</a>
|
|
||||||
{{end}} {{define "content"}}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
|
||||||
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
|
||||||
<thead class="text-gray-800 dark:text-gray-400">
|
|
||||||
<tr>
|
|
||||||
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
|
||||||
Document
|
|
||||||
</th>
|
|
||||||
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
|
||||||
Time
|
|
||||||
</th>
|
|
||||||
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
|
||||||
Duration
|
|
||||||
</th>
|
|
||||||
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
|
||||||
Percent
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="text-black dark:text-white">
|
|
||||||
{{ if not .Data }}
|
|
||||||
<tr>
|
|
||||||
<td class="text-center p-3" colspan="4">No Results</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
{{range $activity := .Data }}
|
|
||||||
<tr>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<a href="./documents/{{ $activity.DocumentID }}">{{ $activity.Author }} - {{ $activity.Title }}</p></a>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ $activity.StartTime }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ $activity.Duration }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ $activity.ReadPercentage }}%</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
<!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 - {{block "title" .}}{{end}}</title>
|
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
|
||||||
|
|
||||||
<!-- Service Worker / Offline Cache Flush -->
|
|
||||||
<script src="/assets/lib/idb-keyval.min.js"></script>
|
|
||||||
<script src="/assets/common.js"></script>
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* -------- PWA Styling -------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* ------- User Dropdown ------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
#user-dropdown-button:checked + #user-dropdown {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-dropdown {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* ----- Mobile Navigation ----- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
#mobile-nav-button span {
|
|
||||||
transform-origin: 5px 0px;
|
|
||||||
transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0),
|
|
||||||
background 0.5s cubic-bezier(0.77,0.2,0.05,1.0),
|
|
||||||
opacity 0.55s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button span:first-child {
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button span:nth-last-child(2) {
|
|
||||||
transform-origin: 0% 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(45deg) translate(2px, -2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(0deg) scale(0.2, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ span:nth-last-child(2) {
|
|
||||||
transform: rotate(-45deg) translate(0, 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mobile-nav-button input:checked ~ div {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#mobile-nav-button input ~ div {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#menu {
|
|
||||||
top: 0;
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
transform: translate(-100%, 0);
|
|
||||||
transition: transform 0.5s cubic-bezier(0.77,0.2,0.05,1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
|
||||||
#menu {
|
|
||||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
|
||||||
<div class="flex items-center justify-between w-full h-16">
|
|
||||||
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
|
||||||
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0" />
|
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
|
||||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
|
||||||
|
|
||||||
<div id="menu" class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
|
|
||||||
<div class="h-16 flex justify-end lg:justify-around">
|
|
||||||
<p class="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">AnthoLume</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "home"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
<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="M2.5192 7.82274C2 8.77128 2 9.91549 2 12.2039V13.725C2 17.6258 2 19.5763 3.17157 20.7881C4.34315 22 6.22876 22 10 22H14C17.7712 22 19.6569 22 20.8284 20.7881C22 19.5763 22 17.6258 22 13.725V12.2039C22 9.91549 22 8.77128 21.4808 7.82274C20.9616 6.87421 20.0131 6.28551 18.116 5.10812L16.116 3.86687C14.1106 2.62229 13.1079 2 12 2C10.8921 2 9.88939 2.62229 7.88403 3.86687L5.88403 5.10813C3.98695 6.28551 3.0384 6.87421 2.5192 7.82274ZM11.25 18C11.25 18.4142 11.5858 18.75 12 18.75C12.4142 18.75 12.75 18.4142 12.75 18V15C12.75 14.5858 12.4142 14.25 12 14.25C11.5858 14.25 11.25 14.5858 11.25 15V18Z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="mx-4 text-sm font-normal">Home</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "documents"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
|
||||||
href="/documents"
|
|
||||||
>
|
|
||||||
<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">Documents</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
href="/local"
|
|
||||||
>
|
|
||||||
<span class="text-left">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27103 2.11151C5.46135 2.21816 5.03258 2.41324 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.82716 15.513 6.44305 15.5132 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.2092 2 7.10452 2.00172 6.27103 2.11151ZM6.75862 6.59459C6.75862 6.1468 7.12914 5.78378 7.58621 5.78378H16.4138C16.8709 5.78378 17.2414 6.1468 17.2414 6.59459C17.2414 7.04239 16.8709 7.40541 16.4138 7.40541H7.58621C7.12914 7.40541 6.75862 7.04239 6.75862 6.59459ZM7.58621 9.56757C7.12914 9.56757 6.75862 9.93058 6.75862 10.3784C6.75862 10.8262 7.12914 11.1892 7.58621 11.1892H13.1034C13.5605 11.1892 13.931 10.8262 13.931 10.3784C13.931 9.93058 13.5605 9.56757 13.1034 9.56757H7.58621Z" />
|
|
||||||
<path d="M7.47341 17.1351H8.68965H13.1034H19.9991C19.9956 18.2657 19.9776 19.1088 19.8862 19.775C19.7773 20.5683 19.5782 20.9884 19.2728 21.2876C18.9674 21.5868 18.5387 21.7818 17.729 21.8885C16.8955 21.9983 15.7908 22 14.2069 22H9.7931C8.2092 22 7.10452 21.9983 6.27103 21.8885C5.46135 21.7818 5.03258 21.5868 4.72718 21.2876C4.42179 20.9884 4.22268 20.5683 4.11382 19.775C4.07259 19.4746 4.0463 19.1382 4.02952 18.7558C4.30088 18.0044 4.93365 17.4264 5.72738 17.218C6.01657 17.1421 6.39395 17.1351 7.47341 17.1351Z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="mx-4 text-sm font-normal">Local</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{if eq .RouteName "activity"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
|
||||||
href="/activity"
|
|
||||||
>
|
|
||||||
<span class="text-left">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
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>
|
|
||||||
</span>
|
|
||||||
<span class="mx-4 text-sm font-normal">Activity</span>
|
|
||||||
</a>
|
|
||||||
{{ if .SearchEnabled }}
|
|
||||||
<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 "search"}}border-purple-500 dark:text-white{{else}}border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{end}}"
|
|
||||||
href="/search"
|
|
||||||
>
|
|
||||||
<span class="text-left">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
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 12ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="mx-4 text-sm font-normal">Search</span>
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
<a class="flex justify-center items-center p-6 w-full absolute bottom-0" target="_blank" href="https://gitea.va.reichard.io/evan/AnthoLume">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 219 92"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="a"><path d="M159 .79h25V69h-25Zm0 0" /></clipPath>
|
|
||||||
<clipPath id="b"><path d="M183 9h35.371v60H183Zm0 0" /></clipPath>
|
|
||||||
<clipPath id="c"><path d="M0 .79h92V92H0Zm0 0" /></clipPath>
|
|
||||||
</defs>
|
|
||||||
<path
|
|
||||||
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
|
||||||
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
|
|
||||||
/>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path
|
|
||||||
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
|
||||||
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g clip-path="url(#b)">
|
|
||||||
<path
|
|
||||||
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
|
||||||
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g clip-path="url(#c)">
|
|
||||||
<path
|
|
||||||
style="stroke: none; fill-rule: nonzero; fill-opacity: 1"
|
|
||||||
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
|
|
||||||
<div class="relative flex items-center justify-end w-full p-4 space-x-4">
|
|
||||||
<a href="#" class="relative block">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
fill="currentColor"
|
|
||||||
height="20"
|
|
||||||
class="text-gray-800 dark:text-gray-200"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<input type="checkbox" id="user-dropdown-button" class="hidden"/>
|
|
||||||
<div
|
|
||||||
id="user-dropdown"
|
|
||||||
class="transition duration-200 z-20 absolute right-4 top-16 pt-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-56 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="py-1"
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-labelledby="options-menu"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/settings"
|
|
||||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span class="flex flex-col">
|
|
||||||
<span>Settings</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/logout"
|
|
||||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span class="flex flex-col">
|
|
||||||
<span>Logout</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label for="user-dropdown-button">
|
|
||||||
<div
|
|
||||||
class="flex items-center text-gray-500 dark:text-white text-md py-4 cursor-pointer"
|
|
||||||
>
|
|
||||||
{{ .User }}
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
class="ml-2 text-gray-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="relative overflow-hidden">
|
|
||||||
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
|
|
||||||
{{block "content" .}}{{end}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
284
templates/base.tmpl
Normal file
284
templates/base.tmpl
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<!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 - {{ block "title" . }}{{ end }}</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
<!-- Service Worker / Offline Cache Flush -->
|
||||||
|
<script src="/assets/lib/idb-keyval.min.js"></script>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/index.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* -------- PWA Styling -------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* ------- User Dropdown ------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
#user-dropdown-button:checked+#user-dropdown {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-dropdown {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* ----- Mobile Navigation ----- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
#mobile-nav-button span {
|
||||||
|
transform-origin: 5px 0px;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0),
|
||||||
|
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0),
|
||||||
|
opacity 0.55s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button span:first-child {
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button span:nth-last-child(2) {
|
||||||
|
transform-origin: 0% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button input:checked~span {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(45deg) translate(2px, -2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button input:checked~span:nth-last-child(3) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(0deg) scale(0.2, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button input:checked~span:nth-last-child(2) {
|
||||||
|
transform: rotate(-45deg) translate(0, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-nav-button input:checked~div {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
#mobile-nav-button input~div {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu {
|
||||||
|
top: 0;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
transform: translate(-100%, 0);
|
||||||
|
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
#menu {
|
||||||
|
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between w-full h-16">
|
||||||
|
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0" />
|
||||||
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
||||||
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
|
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
|
<div id="menu"
|
||||||
|
class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
|
||||||
|
<div class="h-16 flex justify-end lg:justify-around">
|
||||||
|
<p class="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">AnthoLume</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $default := "flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4" }}
|
||||||
|
{{ $inactive := "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"}}
|
||||||
|
{{ $active := "border-purple-500 dark:text-white"}}
|
||||||
|
<a class="{{ $default }} {{ if eq .RouteName "home" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}"
|
||||||
|
href="/">
|
||||||
|
{{ template "svg/home" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Home</span>
|
||||||
|
</a>
|
||||||
|
<a class="{{ $default }} {{ if eq .RouteName "documents" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}"
|
||||||
|
href="/documents">
|
||||||
|
{{ template "svg/documents" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Documents</span>
|
||||||
|
</a>
|
||||||
|
<a class="{{ $default }} {{ if eq .RouteName "progress" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}"
|
||||||
|
href="/progress">
|
||||||
|
{{ template "svg/activity" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Progress</span>
|
||||||
|
</a>
|
||||||
|
<a class="{{ $default }} {{ if eq .RouteName "activity" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}"
|
||||||
|
href="/activity">
|
||||||
|
{{ template "svg/activity" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Activity</span>
|
||||||
|
</a>
|
||||||
|
{{ if .Config.SearchEnabled }}
|
||||||
|
<a class="{{ $default }} {{ if eq .RouteName "search" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}"
|
||||||
|
href="/search">
|
||||||
|
{{ template "svg/search" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Search</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Authorization.IsAdmin }}
|
||||||
|
<div class="flex flex-col gap-4 p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{ if hasPrefix .RouteName "admin" }}dark:text-white border-purple-500{{ else if true }}border-transparent text-gray-400{{ end }}">
|
||||||
|
<a href="/admin"
|
||||||
|
class="flex justify-start w-full {{ if not (hasPrefix .RouteName "admin") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
{{ template "svg/settings" (dict "Size" 20) }}
|
||||||
|
<span class="mx-4 text-sm font-normal">Admin</span>
|
||||||
|
</a>
|
||||||
|
{{ if hasPrefix .RouteName "admin" }}
|
||||||
|
<a href="/admin"
|
||||||
|
style="padding-left: 1.75em"
|
||||||
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
<span class="mx-4 text-sm font-normal">General</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/import"
|
||||||
|
style="padding-left: 1.75em"
|
||||||
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin-import") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
<span class="mx-4 text-sm font-normal">Import</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/users"
|
||||||
|
style="padding-left: 1.75em"
|
||||||
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin-users") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
<span class="mx-4 text-sm font-normal">Users</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/logs"
|
||||||
|
style="padding-left: 1.75em"
|
||||||
|
class="flex justify-start w-full {{ if not (eq .RouteName "admin-logs") }}text-gray-400 hover:text-gray-800 dark:hover:text-gray-100{{ end }}">
|
||||||
|
<span class="mx-4 text-sm font-normal">Logs</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<a class="flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"
|
||||||
|
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>
|
||||||
|
<span class="text-xs">{{ .Config.Version }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{ block "header" . }}{{ end }}</h1>
|
||||||
|
<div class="relative flex items-center justify-end w-full p-4 space-x-4">
|
||||||
|
<a href="#" class="relative block text-gray-800 dark:text-gray-200">{{ template "svg/user" (dict "Size" 20) }}</a>
|
||||||
|
<input type="checkbox" id="user-dropdown-button" class="hidden" />
|
||||||
|
<div id="user-dropdown"
|
||||||
|
class="transition duration-200 z-20 absolute right-4 top-16 pt-4">
|
||||||
|
<div class="w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5">
|
||||||
|
<div class="py-1"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-labelledby="options-menu">
|
||||||
|
<a href="/settings"
|
||||||
|
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
role="menuitem">
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span>Settings</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/local"
|
||||||
|
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
role="menuitem">
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span>Offline</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/logout"
|
||||||
|
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
role="menuitem">
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span>Logout</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="user-dropdown-button">
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer">
|
||||||
|
<span>{{ .Authorization.UserName }}</span>
|
||||||
|
<span class="text-gray-800 dark:text-gray-200">{{ template "svg/dropdown" (dict "Size" 20) }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="relative overflow-hidden">
|
||||||
|
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">{{ block "content" . }}{{ end }}</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
templates/components/download-progress.tmpl
Normal file
22
templates/components/download-progress.tmpl
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="absolute -translate-y-1/2 p-4 m-auto bg-gray-700 dark:bg-gray-300 rounded-lg shadow w-full text-black dark:text-white">
|
||||||
|
<span class="inline-flex gap-2 items-center font-medium text-xs inline-block py-1 px-2 uppercase rounded-full {{ if .Error }} bg-red-500 {{ else if true }} bg-green-600 {{ end }}">
|
||||||
|
{{ if and (ne .Progress 100) (not .Error) }}
|
||||||
|
{{ template "svg/loading" (dict "Size" 16) }}
|
||||||
|
{{ end }}
|
||||||
|
{{ .Message }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col gap-2 mt-2">
|
||||||
|
<div class="relative w-full h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
|
||||||
|
{{ if .Error }}
|
||||||
|
<div class="absolute h-full bg-red-500 rounded-full" style="width: 100%"></div>
|
||||||
|
<p class="absolute w-full h-full font-bold text-center text-xs">ERROR</p>
|
||||||
|
{{ else }}
|
||||||
|
<div class="absolute h-full bg-green-600 rounded-full"
|
||||||
|
style="width: {{ .Progress }}%"></div>
|
||||||
|
<p class="absolute w-full h-full font-bold text-center text-xs">{{ .Progress }}%</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<a href="{{ .ButtonHref }}"
|
||||||
|
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100">{{ .ButtonText }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
64
templates/components/leaderboard-card.tmpl
Normal file
64
templates/components/leaderboard-card.tmpl
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
{{ .Name }} Leaderboard
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 text-xs text-gray-400 items-center">
|
||||||
|
<label for="all-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">all</label>
|
||||||
|
<label for="year-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">year</label>
|
||||||
|
<label for="month-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">month</label>
|
||||||
|
<label for="week-{{ .Name }}"
|
||||||
|
class="cursor-pointer hover:text-black dark:hover:text-white">week</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="all-{{ .Name }}"
|
||||||
|
class="hidden peer/All"
|
||||||
|
checked />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="year-{{ .Name }}"
|
||||||
|
class="hidden peer/Year" />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="month-{{ .Name }}"
|
||||||
|
class="hidden peer/Month" />
|
||||||
|
<input type="radio"
|
||||||
|
name="options-{{ .Name }}"
|
||||||
|
id="week-{{ .Name }}"
|
||||||
|
class="hidden peer/Week" />
|
||||||
|
{{ range $key, $data := .Data }}
|
||||||
|
<div class="flex items-end my-6 space-x-2 hidden peer-checked/{{ $key }}:block">
|
||||||
|
{{ $length := len $data }}
|
||||||
|
{{ if eq $length 0 }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">{{ (index $data 0).UserID }}</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="hidden dark:text-white peer-checked/{{ $key }}:block">
|
||||||
|
{{ range $index, $item := $data }}
|
||||||
|
{{ if lt $index 3 }}
|
||||||
|
{{ if eq $index 0 }}
|
||||||
|
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
|
||||||
|
{{ else }}
|
||||||
|
<div class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200">
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<p>{{ $item.UserID }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.Value }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,525 +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 relative">
|
|
||||||
<!-- Document Info -->
|
|
||||||
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4">
|
|
||||||
<div class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative">
|
|
||||||
<label class="z-10 cursor-pointer" for="edit-cover-button">
|
|
||||||
<img class="rounded object-fill w-full" src="/documents/{{.Data.ID}}/cover"></img>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{{ if .Data.Filepath }}
|
|
||||||
<a
|
|
||||||
href="/reader#id={{ .Data.ID }}&type=REMOTE"
|
|
||||||
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
|
||||||
>Read</a>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
|
|
||||||
<div class="min-w-[50%] md:mr-2">
|
|
||||||
<div class="flex gap-1 text-sm">
|
|
||||||
<p class="text-gray-500">ISBN-10:</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ or .Data.Isbn10 "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 text-sm">
|
|
||||||
<p class="text-gray-500">ISBN-13:</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ or .Data.Isbn13 "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500">
|
|
||||||
<input type="checkbox" id="edit-cover-button" class="hidden css-button"/>
|
|
||||||
<div class="absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
action="./{{ .Data.ID }}/edit"
|
|
||||||
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="cover_file"
|
|
||||||
name="cover_file"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Upload Cover</button>
|
|
||||||
</form>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/edit"
|
|
||||||
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked id="remove_cover" name="remove_cover" class="hidden" />
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Remove Cover</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<label for="delete-button">
|
|
||||||
<svg
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
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 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/delete"
|
|
||||||
class="text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="../activity?document={{ .Data.ID }}">
|
|
||||||
<svg
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
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="28"
|
|
||||||
height="28"
|
|
||||||
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 12ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
<input type="checkbox" id="edit-button" class="hidden css-button"/>
|
|
||||||
<div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/identify"
|
|
||||||
class="flex flex-col gap-2 text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
placeholder="Title"
|
|
||||||
value="{{ or .Data.Title nil }}"
|
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="author"
|
|
||||||
name="author"
|
|
||||||
placeholder="Author"
|
|
||||||
value="{{ or .Data.Author nil }}"
|
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="isbn"
|
|
||||||
name="isbn"
|
|
||||||
placeholder="ISBN 10 / ISBN 13"
|
|
||||||
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
|
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Identify</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ if .Data.Filepath }}
|
|
||||||
<a href="./{{.Data.ID}}/file">
|
|
||||||
<svg
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
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="28"
|
|
||||||
height="28"
|
|
||||||
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>
|
|
||||||
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
|
||||||
<p>Title</p>
|
|
||||||
<label class="my-auto" for="edit-title-button">
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
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-title-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">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/edit"
|
|
||||||
class="flex flex-col gap-2 text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
value="{{ or .Data.Title "N/A" }}"
|
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ or .Data.Title "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
|
||||||
<p>Author</p>
|
|
||||||
<label class="my-auto" for="edit-author-button">
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
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-author-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">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/edit"
|
|
||||||
class="flex flex-col gap-2 text-black dark:text-white text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="author"
|
|
||||||
name="author"
|
|
||||||
value="{{ or .Data.Author "N/A" }}"
|
|
||||||
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ or .Data.Author "N/A" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
|
||||||
<p>Time Read</p>
|
|
||||||
<label class="my-auto" for="progress-info-button">
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
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="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 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 22ZM12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75ZM12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
<input type="checkbox" id="progress-info-button" class="hidden css-button"/>
|
|
||||||
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
|
||||||
<div class="text-xs flex">
|
|
||||||
<p class="text-gray-400 w-32">Seconds / Percent</p>
|
|
||||||
<p class="font-medium dark:text-white">
|
|
||||||
{{ .Data.SecondsPerPercent }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs flex">
|
|
||||||
<p class="text-gray-400 w-32">Words / Minute</p>
|
|
||||||
<p class="font-medium dark:text-white">
|
|
||||||
{{ .Data.Wpm }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs flex">
|
|
||||||
<p class="text-gray-400 w-32">Est. Time Left</p>
|
|
||||||
<p class="font-medium dark:text-white whitespace-nowrap">
|
|
||||||
{{ NiceSeconds .TotalTimeLeftSeconds }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ NiceSeconds .Data.TotalTimeSeconds }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500">Progress</p>
|
|
||||||
<p class="font-medium text-lg">
|
|
||||||
{{ .Data.Percentage }}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative">
|
|
||||||
<div class="text-gray-500 inline-flex gap-2 relative">
|
|
||||||
<p>Description</p>
|
|
||||||
<label class="my-auto" for="edit-description-button">
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="relative font-medium text-justify hyphens-auto">
|
|
||||||
<input type="checkbox" id="edit-description-button" class="hidden css-button"/>
|
|
||||||
<div
|
|
||||||
class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200"
|
|
||||||
>
|
|
||||||
<img class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill" src="/documents/{{.Data.ID}}/cover"></img>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="./{{ .Data.ID }}/edit"
|
|
||||||
class="flex flex-col gap-2 w-full text-black bg-gray-200 rounded shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
type="text"
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
|
|
||||||
>{{ or .Data.Description "N/A" }}</textarea>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<p>{{ or .Data.Description "N/A" }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .MetadataError }}
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-50">
|
|
||||||
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
|
||||||
<div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
|
|
||||||
</div>
|
|
||||||
<a href="/documents/{{ .Data.ID }}"
|
|
||||||
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Back to Document</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<!-- Metadata Info -->
|
|
||||||
{{ if .Metadata }}
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-50">
|
|
||||||
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
|
||||||
<div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
|
|
||||||
<div class="py-5 text-center">
|
|
||||||
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
id="metadata-save"
|
|
||||||
method="POST"
|
|
||||||
action="/documents/{{ .Data.ID }}/edit"
|
|
||||||
class="text-black dark:text-white border-b dark:border-black"
|
|
||||||
>
|
|
||||||
<dl>
|
|
||||||
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
Cover
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
|
||||||
<img class="rounded object-fill h-32" src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690"></img>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
Title
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
|
||||||
{{ or .Metadata.Title "N/A" }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
Author
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
|
||||||
{{ or .Metadata.Author "N/A" }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
ISBN 10
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
|
||||||
{{ or .Metadata.ISBN10 "N/A" }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
ISBN 13
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
|
||||||
{{ or .Metadata.ISBN13 "N/A" }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6">
|
|
||||||
<dt class="my-auto font-medium text-gray-500">
|
|
||||||
Description
|
|
||||||
</dt>
|
|
||||||
<dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2">
|
|
||||||
{{ or .Metadata.Description "N/A" }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="hidden">
|
|
||||||
<input type="text" id="title" name="title" value="{{ .Metadata.Title }}">
|
|
||||||
<input type="text" id="author" name="author" value="{{ .Metadata.Author }}">
|
|
||||||
<input type="text" id="description" name="description" value="{{ .Metadata.Description }}">
|
|
||||||
<input type="text" id="isbn_10" name="isbn_10" value="{{ .Metadata.ISBN10 }}">
|
|
||||||
<input type="text" id="isbn_13" name="isbn_13" value="{{ .Metadata.ISBN13 }}">
|
|
||||||
<input type="text" id="cover_gbid" name="cover_gbid" value="{{ .Metadata.ID }}">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="flex justify-end gap-4 m-4">
|
|
||||||
<a href="/documents/{{ .Data.ID }}"
|
|
||||||
class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Cancel</a>
|
|
||||||
<button
|
|
||||||
form="metadata-save"
|
|
||||||
class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
type="submit"
|
|
||||||
>Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.css-button:checked + div {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button + div {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
{{template "base.html" .}}
|
|
||||||
|
|
||||||
{{define "title"}}Documents{{end}}
|
|
||||||
|
|
||||||
{{define "header"}}
|
|
||||||
<a href="./documents">Documents</a>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
|
||||||
>
|
|
||||||
<form class="flex gap-4 flex-col lg:flex-row" action="./documents" method="GET">
|
|
||||||
<div class="flex flex-col w-full grow">
|
|
||||||
<div class="flex relative">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="15"
|
|
||||||
height="15"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect width="24" height="24" fill="none" />
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="search"
|
|
||||||
name="search"
|
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
||||||
placeholder="Search Author / Title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
|
||||||
>
|
|
||||||
<span class="w-full">Search</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{{range $doc := .Data }}
|
|
||||||
<div class="w-full relative">
|
|
||||||
<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="./documents/{{$doc.ID}}">
|
|
||||||
<img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></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">
|
|
||||||
{{ or $doc.Title "Unknown" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Author</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ or $doc.Author "Unknown" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Progress</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ $doc.Percentage }}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Time Read</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ NiceSeconds $doc.TotalTimeSeconds }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
|
|
||||||
<a href="./activity?document={{ $doc.ID }}">
|
|
||||||
<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="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>
|
|
||||||
|
|
||||||
{{ if $doc.Filepath }}
|
|
||||||
<a href="./documents/{{$doc.ID}}/file">
|
|
||||||
<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>
|
|
||||||
{{ 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>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
|
||||||
{{ if .PreviousPage }}
|
|
||||||
<a href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">◄</a>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if .NextPage }}
|
|
||||||
<a href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}" class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">►</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center">
|
|
||||||
<input type="checkbox" id="upload-file-button" class="hidden css-button"/>
|
|
||||||
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2">
|
|
||||||
<form method="POST" enctype="multipart/form-data" action="./documents" class="flex flex-col gap-2">
|
|
||||||
<input type="file" accept=".epub" id="document_file" name="document_file">
|
|
||||||
<button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800" type="submit">Upload File</button>
|
|
||||||
</form>
|
|
||||||
<label for="upload-file-button">
|
|
||||||
<div class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800">Cancel Upload</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
|
||||||
for="upload-file-button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="34"
|
|
||||||
height="34"
|
|
||||||
class="text-gray-200 dark:text-gray-600"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.css-button:checked + div {
|
|
||||||
display: block;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button + div {
|
|
||||||
display: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button:checked + div + label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<!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 - Error</title>
|
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
|
||||||
</head>
|
|
||||||
<body
|
|
||||||
class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"
|
|
||||||
>
|
|
||||||
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
|
||||||
<div class="mx-auto max-w-screen-sm text-center">
|
|
||||||
<h1
|
|
||||||
class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
|
|
||||||
>
|
|
||||||
{{ .Status }}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white"
|
|
||||||
>
|
|
||||||
{{ .Error }}
|
|
||||||
</p>
|
|
||||||
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">
|
|
||||||
{{ .Message }}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="rounded text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
|
||||||
>Back to Homepage</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "header"}}
|
|
||||||
<a href="./">Home</a>
|
|
||||||
{{end}} {{define "content"}}
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="relative w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="absolute top-3 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
|
||||||
>
|
|
||||||
Daily Read Totals
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
|
||||||
<svg
|
|
||||||
viewBox="26 0 755 {{ $data.Height }}"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
width="100%"
|
|
||||||
height="4em"
|
|
||||||
>
|
|
||||||
<!-- Bezier Line Graph -->
|
|
||||||
<path
|
|
||||||
fill="#316BBE"
|
|
||||||
fill-opacity="0.5"
|
|
||||||
stroke="none"
|
|
||||||
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
|
||||||
|
|
||||||
{{ range $index, $item := $data.LinePoints }}
|
|
||||||
<line
|
|
||||||
class="hover-trigger"
|
|
||||||
stroke="black"
|
|
||||||
stroke-opacity="0.0"
|
|
||||||
stroke-width="{{ $data.Offset }}"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="0"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<g class="hover-item">
|
|
||||||
<line
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
stroke-opacity="0.2"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="30"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 5) translate(-30, 8)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).Date }}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 25) translate(-30, -2)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).MinutesRead }} minutes
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
{{ end }}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Interactive Hover */
|
|
||||||
.hover-item {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.hover-trigger:hover + .hover-item,
|
|
||||||
.hover-item:hover {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SVG Component Styling */
|
|
||||||
svg text.text-black {
|
|
||||||
fill: black;
|
|
||||||
}
|
|
||||||
svg line.text-black {
|
|
||||||
stroke: black;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
svg text.dark\:text-white {
|
|
||||||
fill: white;
|
|
||||||
}
|
|
||||||
svg line.dark\:text-white {
|
|
||||||
stroke: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
||||||
<a href="./documents" class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
|
||||||
>
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.DocumentsSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Documents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="./activity" class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
|
||||||
>
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.ActivitySize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Activity Records</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
|
||||||
>
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.ProgressSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Progress Records</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
|
||||||
>
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.DevicesSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Devices</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{{ range $item := .Data.Streaks }}
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
|
||||||
>
|
|
||||||
{{ if eq $item.Window "WEEK" }} Weekly Read Streak {{ else }} Daily
|
|
||||||
Read Streak {{ end }}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-end my-6 space-x-2">
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">
|
|
||||||
{{ $item.CurrentStreak }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="dark:text-white">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
|
|
||||||
Current Daily Streak {{ end }}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-end text-sm text-gray-400">
|
|
||||||
{{ $item.CurrentStreakStartDate }} ➞ {{
|
|
||||||
$item.CurrentStreakEndDate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end font-bold">
|
|
||||||
{{ $item.CurrentStreak }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ if eq $item.Window "WEEK" }} Best Weekly Streak {{ else }}
|
|
||||||
Best Daily Streak {{ end }}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-end text-sm text-gray-400">
|
|
||||||
{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
|
||||||
>
|
|
||||||
WPM Leaderboard
|
|
||||||
</p>
|
|
||||||
<div class="flex items-end my-6 space-x-2">
|
|
||||||
{{ $length := len .Data.WPMLeaderboard }} {{ if eq $length 0 }}
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
|
|
||||||
{{ else }}
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">
|
|
||||||
{{ (index .Data.WPMLeaderboard 0).UserID }}
|
|
||||||
</p>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dark:text-white">
|
|
||||||
{{ range $index, $item := .Data.WPMLeaderboard }} {{ if lt $index 3 }}
|
|
||||||
{{ if eq $index 0 }}
|
|
||||||
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
|
|
||||||
{{ else }}
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200"
|
|
||||||
>
|
|
||||||
{{ end }}
|
|
||||||
<div>
|
|
||||||
<p>{{ $item.UserID }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end font-bold">{{ $item.Wpm }} WPM</div>
|
|
||||||
</div>
|
|
||||||
{{ end }} {{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!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 - {{if .Register}}Register{{else}}Login{{end}}</title>
|
|
||||||
|
|
||||||
<link rel="manifest" href="./manifest.json" />
|
|
||||||
<link rel="stylesheet" href="./assets/style.css" />
|
|
||||||
|
|
||||||
<!-- Service Worker / Offline Cache Flush -->
|
|
||||||
<script src="/assets/lib/idb-keyval.min.js"></script>
|
|
||||||
<script src="/assets/common.js"></script>
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* -------- PWA Styling -------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
margin: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: calc(100% + env(safe-area-inset-bottom));
|
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
|
||||||
env(safe-area-inset-left);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - IE, Edge, Firefox */
|
|
||||||
* {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - WebKit */
|
|
||||||
*::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
|
||||||
<div class="flex flex-wrap w-full">
|
|
||||||
<div class="flex flex-col w-full md:w-1/2">
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-center px-8 pt-8 my-auto md:justify-start md:pt-0 md:px-24 lg:px-32"
|
|
||||||
>
|
|
||||||
<p class="text-3xl text-center">Welcome.</p>
|
|
||||||
<form
|
|
||||||
class="flex flex-col pt-3 md:pt-8"
|
|
||||||
{{if
|
|
||||||
.Register}}action="./register"
|
|
||||||
{{else}}action="./login"
|
|
||||||
{{end}}
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col pt-4">
|
|
||||||
<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"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
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="Username"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col pt-4 mb-12">
|
|
||||||
<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"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
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="Password"
|
|
||||||
/>
|
|
||||||
<span class="absolute -bottom-5 text-red-400 text-xs"
|
|
||||||
>{{ .Error }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 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"
|
|
||||||
>
|
|
||||||
{{if .Register}}
|
|
||||||
<span class="w-full"> Register </span>
|
|
||||||
{{else}}
|
|
||||||
<span class="w-full"> Submit </span>
|
|
||||||
{{end}}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div class="pt-12 pb-12 text-center">
|
|
||||||
{{ if .RegistrationEnabled }} {{ if .Register }}
|
|
||||||
<p>
|
|
||||||
Trying to login?
|
|
||||||
<a href="./login" class="font-semibold underline">
|
|
||||||
Login here.
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{{else}}
|
|
||||||
<p>
|
|
||||||
Don't have an account?
|
|
||||||
<a href="./register" class="font-semibold underline">
|
|
||||||
Register here.
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{{end}} {{ end }}
|
|
||||||
<p class="mt-4">
|
|
||||||
<a href="./local" class="font-semibold underline">
|
|
||||||
Offline / Local Mode
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
|
||||||
src="/assets/images/book1.jpg"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
|
||||||
src="/assets/images/book2.jpg"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
|
||||||
src="/assets/images/book3.jpg"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
|
||||||
src="/assets/images/book4.jpg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<style>
|
|
||||||
.image-fader img {
|
|
||||||
position: absolute;
|
|
||||||
animation-name: imagefade;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
animation-duration: 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes imagefade {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
17% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
92% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-fader img:nth-of-type(1) {
|
|
||||||
animation-delay: 45s;
|
|
||||||
}
|
|
||||||
.image-fader img:nth-of-type(2) {
|
|
||||||
animation-delay: 30s;
|
|
||||||
}
|
|
||||||
.image-fader img:nth-of-type(3) {
|
|
||||||
animation-delay: 15s;
|
|
||||||
}
|
|
||||||
.image-fader img:nth-of-type(4) {
|
|
||||||
animation-delay: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
<!-- https://stackoverflow.com/questions/60748752/change-an-image-after-some-time -->
|
|
||||||
44
templates/pages/activity.tmpl
Normal file
44
templates/pages/activity.tmpl
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Activity{{ end }}
|
||||||
|
{{ define "header" }}<a href="./activity">Activity</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Time</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Duration</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Percent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="4">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $activity := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./documents/{{ $activity.DocumentID }}">{{ $activity.Author }} - {{ $activity.Title }}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.StartTime }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.Duration }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $activity.EndPercentage }}%</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
79
templates/pages/admin-import.tmpl
Normal file
79
templates/pages/admin-import.tmpl
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Admin - Import{{ end }}
|
||||||
|
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
{{ if .SelectedDirectory }}
|
||||||
|
<div class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold text-gray-500">Selected Import Directory</p>
|
||||||
|
<form class="flex gap-4 flex-col" action="./import" method="POST">
|
||||||
|
<input type="text"
|
||||||
|
name="directory"
|
||||||
|
value="{{ .SelectedDirectory }}"
|
||||||
|
class="hidden" />
|
||||||
|
<div class="flex justify-between gap-4 w-full">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<span>{{ template "svg/import" }}</span>
|
||||||
|
<p class="font-medium text-lg break-all">{{ .SelectedDirectory }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-around gap-2 mr-4">
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
<input checked type="radio" id="copy" name="type" value="COPY" />
|
||||||
|
<label for="copy">Copy</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
<input type="radio" id="direct" name="type" value="DIRECT" />
|
||||||
|
<label for="direct">Direct</label>
|
||||||
|
</div>
|
||||||
|
</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">Import Directory</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if not .SelectedDirectory }}
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 w-12"></th>
|
||||||
|
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 break-all">{{ .CurrentPath }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not (eq .CurrentPath "/") }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400"></td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./import?directory={{$.CurrentPath}}/../">
|
||||||
|
<p>../</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="2">No Folders</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $item := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400">
|
||||||
|
<a href="./import?select={{ $.CurrentPath }}/{{ $item }}">{{ template "svg/import" }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./import?directory={{ $.CurrentPath }}/{{ $item }}">
|
||||||
|
<p>{{ $item }}</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
34
templates/pages/admin-logs.tmpl
Normal file
34
templates/pages/admin-logs.tmpl
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Admin - Logs{{ end }}
|
||||||
|
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Logs</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row" action="./logs" 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">
|
||||||
|
{{ template "svg/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="filter"
|
||||||
|
name="filter"
|
||||||
|
value="{{ .Filter }}"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 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="JQ Filter" />
|
||||||
|
</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">Filter</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Required for iOS "Hover" Events (onclick) -->
|
||||||
|
<div onclick
|
||||||
|
class="flex flex-col-reverse text-black dark:text-white w-full overflow-scroll"
|
||||||
|
style="font-family: monospace">
|
||||||
|
{{ range $log := .Data }}
|
||||||
|
<span class="whitespace-nowrap hover:whitespace-pre">{{ $log }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
45
templates/pages/admin-users.tmpl
Normal file
45
templates/pages/admin-users.tmpl
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Admin - Users{{ end }}
|
||||||
|
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Users</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-12">
|
||||||
|
{{ template "svg/add" }}
|
||||||
|
</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
|
||||||
|
Permissions
|
||||||
|
</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-48">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="2">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $user := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400">{{ template "svg/delete" }}</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $user.ID }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-center min-w-40">
|
||||||
|
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span>
|
||||||
|
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $user.CreatedAt }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
85
templates/pages/admin.tmpl
Normal file
85
templates/pages/admin.tmpl
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Admin - General{{ end }}
|
||||||
|
{{ define "header" }}<a class="whitespace-pre" href="./admin">Admin - General</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="w-full flex flex-col gap-4 grow">
|
||||||
|
<div class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold mb-2">Backup & Restore</p>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<form class="flex justify-between" action="./admin" method="POST">
|
||||||
|
<input type="text" name="action" value="BACKUP" class="hidden" />
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" id="backup_covers" name="backup_types" value="COVERS" />
|
||||||
|
<label for="backup_covers">Covers</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox"
|
||||||
|
id="backup_documents"
|
||||||
|
name="backup_types"
|
||||||
|
value="DOCUMENTS" />
|
||||||
|
<label for="backup_documents">Documents</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-40 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">Backup</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="./admin"
|
||||||
|
class="flex justify-between grow">
|
||||||
|
<input type="text" name="action" value="RESTORE" class="hidden" />
|
||||||
|
<div class="flex items-center w-1/2">
|
||||||
|
<input type="file" accept=".zip" name="restore_file" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-40 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">Restore</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ if .PasswordErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
||||||
|
{{ else if .PasswordMessage }}
|
||||||
|
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold">Tasks</p>
|
||||||
|
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
<tr>
|
||||||
|
<td class="pl-0">
|
||||||
|
<p>Metadata Matching</p>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 float-right">
|
||||||
|
<form action="./admin" method="POST">
|
||||||
|
<input type="text" name="action" value="METADATA_MATCH" class="hidden" />
|
||||||
|
<button type="submit"
|
||||||
|
class="w-40 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">Run</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p>Cache Tables</p>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 float-right">
|
||||||
|
<form action="./admin" method="POST">
|
||||||
|
<input type="text" name="action" value="CACHE_TABLES" class="hidden" />
|
||||||
|
<button type="submit"
|
||||||
|
class="w-40 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">Run</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
299
templates/pages/document.tmpl
Normal file
299
templates/pages/document.tmpl
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Documents{{ end }}
|
||||||
|
{{ define "header" }}<a href="/documents">Documents</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="h-full w-full relative">
|
||||||
|
<!-- Document Info -->
|
||||||
|
<div class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4">
|
||||||
|
<div class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative">
|
||||||
|
<label class="z-10 cursor-pointer" for="edit-cover-button">
|
||||||
|
<img class="rounded object-fill w-full"
|
||||||
|
src="/documents/{{.Data.ID}}/cover" />
|
||||||
|
</label>
|
||||||
|
{{ if .Data.Filepath }}
|
||||||
|
<a href="/reader#id={{ .Data.ID }}&type=REMOTE"
|
||||||
|
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">Read</a>
|
||||||
|
{{ end }}
|
||||||
|
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
|
||||||
|
<div class="min-w-[50%] md:mr-2">
|
||||||
|
<div class="flex gap-1 text-sm">
|
||||||
|
<p class="text-gray-500">ISBN-10:</p>
|
||||||
|
<p class="font-medium">{{ or .Data.Isbn10 "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 text-sm">
|
||||||
|
<p class="text-gray-500">ISBN-13:</p>
|
||||||
|
<p class="font-medium">{{ or .Data.Isbn13 "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500">
|
||||||
|
<input type="checkbox" id="edit-cover-button" class="hidden css-button" />
|
||||||
|
<div class="absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
||||||
|
<form method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
||||||
|
<input type="file" id="cover_file" name="cover_file">
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Upload Cover</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked
|
||||||
|
id="remove_cover"
|
||||||
|
name="remove_cover"
|
||||||
|
class="hidden" />
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Remove Cover</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<label for="delete-button" class="cursor-pointer">{{ template "svg/delete" (dict "Size" 28) }}</label>
|
||||||
|
<input type="checkbox" id="delete-button" class="hidden css-button" />
|
||||||
|
<div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/delete"
|
||||||
|
class="text-black dark:text-white text-sm">
|
||||||
|
<button class="font-medium w-24 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="../activity?document={{ .Data.ID }}">{{ template "svg/activity" (dict "Size" 28) }}</a>
|
||||||
|
<div class="relative">
|
||||||
|
<label for="search-button">{{ template "svg/search" (dict "Size" 28) }}</label>
|
||||||
|
<input type="checkbox" id="search-button" class="hidden css-button" />
|
||||||
|
<div class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/identify"
|
||||||
|
class="flex flex-col gap-2 text-black dark:text-white text-sm">
|
||||||
|
<input type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
value="{{ or .Data.Title nil }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
||||||
|
<input type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
placeholder="Author"
|
||||||
|
value="{{ or .Data.Author nil }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
||||||
|
<input type="text"
|
||||||
|
id="isbn"
|
||||||
|
name="isbn"
|
||||||
|
placeholder="ISBN 10 / ISBN 13"
|
||||||
|
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
|
||||||
|
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Identify</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .Data.Filepath }}
|
||||||
|
<a href="./{{.Data.ID}}/file">{{ template "svg/download" (dict "Size" 28) }}</a>
|
||||||
|
{{ else }}
|
||||||
|
{{ template "svg/download" (dict "Size" 28 "Disabled" true) }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Title</p>
|
||||||
|
<label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
|
||||||
|
<input type="checkbox" id="edit-title-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">
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 text-black dark:text-white text-sm">
|
||||||
|
<input type="text" id="title" name="title" value="{{ or .Data.Title "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-lg">{{ or .Data.Title "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Author</p>
|
||||||
|
<label class="my-auto" for="edit-author-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
|
||||||
|
<input type="checkbox" id="edit-author-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">
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 text-black dark:text-white text-sm">
|
||||||
|
<input type="text" id="author" name="author" value="{{ or .Data.Author "N/A" }}" class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-lg">{{ or .Data.Author "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Time Read</p>
|
||||||
|
<label class="my-auto" for="progress-info-button">{{ template "svg/info" (dict "Size" 18) }}</label>
|
||||||
|
<input type="checkbox" id="progress-info-button" class="hidden css-button" />
|
||||||
|
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Seconds / Percent</p>
|
||||||
|
<p class="font-medium dark:text-white">{{ .Data.SecondsPerPercent }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Words / Minute</p>
|
||||||
|
<p class="font-medium dark:text-white">{{ .Data.Wpm }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs flex">
|
||||||
|
<p class="text-gray-400 w-32">Est. Time Left</p>
|
||||||
|
<p class="font-medium dark:text-white whitespace-nowrap">{{ niceSeconds .TotalTimeLeftSeconds }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-lg">{{ niceSeconds .Data.TotalTimeSeconds }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Progress</p>
|
||||||
|
<p class="font-medium text-lg">{{ .Data.Percentage }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Description</p>
|
||||||
|
<label class="my-auto" for="edit-description-button">{{ template "svg/edit" (dict "Size" 18) }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative font-medium text-justify hyphens-auto">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="edit-description-button"
|
||||||
|
class="hidden css-button" />
|
||||||
|
<div class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200">
|
||||||
|
<img class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill"
|
||||||
|
src="/documents/{{.Data.ID}}/cover" />
|
||||||
|
<form method="POST"
|
||||||
|
action="./{{ .Data.ID }}/edit"
|
||||||
|
class="flex flex-col gap-2 w-full text-black bg-gray-200 rounded shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3">
|
||||||
|
<textarea type="text"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white">{{ or .Data.Description "N/A" }}</textarea>
|
||||||
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p>{{ or .Data.Description "N/A" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if .MetadataError }}
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full z-50">
|
||||||
|
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
||||||
|
<div class="relative flex flex-col gap-4 p-4 max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3>
|
||||||
|
</div>
|
||||||
|
<a href="/documents/{{ .Data.ID }}"
|
||||||
|
class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Back to Document</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<!-- Metadata Info -->
|
||||||
|
{{ if .Metadata }}
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full z-50">
|
||||||
|
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
||||||
|
<div class="relative max-h-[95%] bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 w-5/6 overflow-hidden shadow rounded">
|
||||||
|
<div class="py-5 text-center">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">Metadata Results</h3>
|
||||||
|
</div>
|
||||||
|
<form id="metadata-save"
|
||||||
|
method="POST"
|
||||||
|
action="/documents/{{ .Data.ID }}/edit"
|
||||||
|
class="text-black dark:text-white border-b dark:border-black">
|
||||||
|
<dl>
|
||||||
|
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Cover</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
<img class="rounded object-fill h-32"
|
||||||
|
src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Title</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Title "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Author</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Author "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">ISBN 10</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.ISBN10 "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">ISBN 13</dt>
|
||||||
|
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.ISBN13 "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6">
|
||||||
|
<dt class="my-auto font-medium text-gray-500">Description</dt>
|
||||||
|
<dd class="max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2">
|
||||||
|
{{ or .Metadata.Description "N/A" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="hidden">
|
||||||
|
<input type="text" id="title" name="title" value="{{ .Metadata.Title }}">
|
||||||
|
<input type="text" id="author" name="author" value="{{ .Metadata.Author }}">
|
||||||
|
<input type="text"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value="{{ .Metadata.Description }}">
|
||||||
|
<input type="text"
|
||||||
|
id="isbn_10"
|
||||||
|
name="isbn_10"
|
||||||
|
value="{{ .Metadata.ISBN10 }}">
|
||||||
|
<input type="text"
|
||||||
|
id="isbn_13"
|
||||||
|
name="isbn_13"
|
||||||
|
value="{{ .Metadata.ISBN13 }}">
|
||||||
|
<input type="text"
|
||||||
|
id="cover_gbid"
|
||||||
|
name="cover_gbid"
|
||||||
|
value="{{ .Metadata.ID }}">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="flex justify-end gap-4 m-4">
|
||||||
|
<a href="/documents/{{ .Data.ID }}"
|
||||||
|
class="w-24 text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Cancel</a>
|
||||||
|
<button form="metadata-save"
|
||||||
|
class="w-24 font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||||
|
type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.css-button:checked+div {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.css-button+div {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
120
templates/pages/documents.tmpl
Normal file
120
templates/pages/documents.tmpl
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Documents{{ end }}
|
||||||
|
{{ define "header" }}<a href="./documents">Documents</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./documents"
|
||||||
|
method="GET">
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex relative">
|
||||||
|
<span class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm">
|
||||||
|
{{ template "svg/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Search Author / Title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2">
|
||||||
|
<span class="w-full">Search</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{{ range $doc := .Data }}
|
||||||
|
<div class="w-full relative">
|
||||||
|
<div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded">
|
||||||
|
<div class="min-w-fit my-auto h-48 relative">
|
||||||
|
<a href="./documents/{{$doc.ID}}">
|
||||||
|
<img class="rounded object-cover h-full"
|
||||||
|
src="./documents/{{$doc.ID}}/cover" />
|
||||||
|
</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">{{ or $doc.Title "Unknown" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Author</p>
|
||||||
|
<p class="font-medium">{{ or $doc.Author "Unknown" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Progress</p>
|
||||||
|
<p class="font-medium">{{ $doc.Percentage }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Time Read</p>
|
||||||
|
<p class="font-medium">{{ niceSeconds $doc.TotalTimeSeconds }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<a href="./activity?document={{ $doc.ID }}">{{ template "svg/activity" }}</a>
|
||||||
|
{{ if $doc.Filepath }}
|
||||||
|
<a href="./documents/{{$doc.ID}}/file">{{ template "svg/download" }}</a>
|
||||||
|
{{ else }}
|
||||||
|
{{ template "svg/download" (dict "Disabled" true) }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||||
|
{{ if .PreviousPage }}
|
||||||
|
<a href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}"
|
||||||
|
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">◄</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .NextPage }}
|
||||||
|
<a href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}"
|
||||||
|
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none">►</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center">
|
||||||
|
<input type="checkbox" id="upload-file-button" class="hidden css-button" />
|
||||||
|
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2">
|
||||||
|
<form method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="./documents"
|
||||||
|
class="flex flex-col gap-2">
|
||||||
|
<input type="file" accept=".epub" id="document_file" name="document_file">
|
||||||
|
<button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
type="submit">Upload File</button>
|
||||||
|
</form>
|
||||||
|
<label for="upload-file-button">
|
||||||
|
<div class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
Cancel Upload
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||||
|
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.css-button:checked+div {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.css-button+div {
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.css-button:checked+div+label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
31
templates/pages/error.tmpl
Normal file
31
templates/pages/error.tmpl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!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 - Error</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen">
|
||||||
|
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
||||||
|
<div class="mx-auto max-w-screen-sm text-center">
|
||||||
|
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500">{{ .Status }}</h1>
|
||||||
|
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">{{ .Error }}</p>
|
||||||
|
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">{{ .Message }}</p>
|
||||||
|
<a href="/"
|
||||||
|
class="rounded text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100">Back to Homepage</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
137
templates/pages/home.tmpl
Normal file
137
templates/pages/home.tmpl
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Home{{ end }}
|
||||||
|
{{ define "header" }}<a href="./">Home</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<p class="absolute top-3 left-5 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
Daily Read Totals
|
||||||
|
</p>
|
||||||
|
{{ $data := (getSVGGraphData .Data.GraphData 800 70 )}}
|
||||||
|
<div class="relative">
|
||||||
|
<svg viewBox="26 0 755 {{ $data.Height }}"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
width="100%"
|
||||||
|
height="6em">
|
||||||
|
<!-- Bezier Line Graph -->
|
||||||
|
<path fill="#316BBE" fill-opacity="0.5" stroke="none" d="{{ $data.BezierPath }} {{ $data.BezierFill }}" />
|
||||||
|
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex absolute w-full h-full top-0"
|
||||||
|
style="width: calc(100%*31/30);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
left: 50%">
|
||||||
|
{{ range $index, $item := $data.LinePoints }}
|
||||||
|
<!-- Required for iOS "Hover" Events (onclick) -->
|
||||||
|
<div onclick
|
||||||
|
class="opacity-0 hover:opacity-100 w-full"
|
||||||
|
style="background: linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%">
|
||||||
|
<div class="flex flex-col items-center p-2 rounded absolute top-3 dark:text-white text-xs pointer-events-none"
|
||||||
|
style="transform: translateX(-50%);
|
||||||
|
background-color: rgba(128, 128, 128, 0.2);
|
||||||
|
left: 50%">
|
||||||
|
<span>{{ (index $.Data.GraphData $index).Date }}</span>
|
||||||
|
<span>{{ (index $.Data.GraphData $index).MinutesRead }} minutes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<a href="./documents" class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DocumentsSize }}</p>
|
||||||
|
<p class="text-sm text-gray-400">Documents</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="./activity" class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ActivitySize }}</p>
|
||||||
|
<p class="text-sm text-gray-400">Activity Records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="./progress" class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ProgressSize }}</p>
|
||||||
|
<p class="text-sm text-gray-400">Progress Records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DevicesSize }}</p>
|
||||||
|
<p class="text-sm text-gray-400">Devices</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{{ range $item := .Data.Streaks }}
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
{{ if eq $item.Window "WEEK" }}
|
||||||
|
Weekly Read Streak
|
||||||
|
{{ else }}
|
||||||
|
Daily Read Streak
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end my-6 space-x-2">
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">{{ $item.CurrentStreak }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dark:text-white">
|
||||||
|
<div class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
|
||||||
|
Current Daily Streak {{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">
|
||||||
|
{{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ if eq $item.Window "WEEK" }}
|
||||||
|
Best Weekly Streak
|
||||||
|
{{ else }}
|
||||||
|
Best Daily Streak
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "WPM"
|
||||||
|
"Data" .Data.UserStatistics.WPM
|
||||||
|
)}}
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "Duration"
|
||||||
|
"Data" .Data.UserStatistics.Duration
|
||||||
|
)}}
|
||||||
|
{{ template "component/leaderboard-card" (dict
|
||||||
|
"Name" "Words"
|
||||||
|
"Data" .Data.UserStatistics.Words
|
||||||
|
)}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
173
templates/pages/login.tmpl
Normal file
173
templates/pages/login.tmpl
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!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 - {{ if .Register }}Register{{ else }}Login{{ end }}</title>
|
||||||
|
<link rel="manifest" href="./manifest.json" />
|
||||||
|
<link rel="stylesheet" href="./assets/style.css" />
|
||||||
|
<!-- Service Worker / Offline Cache Flush -->
|
||||||
|
<script src="/assets/lib/idb-keyval.min.js"></script>
|
||||||
|
<script src="/assets/common.js"></script>
|
||||||
|
<script src="/assets/index.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* -------- PWA Styling -------- */
|
||||||
|
/* ----------------------------- */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: calc(100% + env(safe-area-inset-bottom));
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Scrollbar - IE, Edge, Firefox */
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Scrollbar - WebKit */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||||
|
<div class="flex flex-wrap w-full">
|
||||||
|
<div class="flex flex-col w-full md:w-1/2">
|
||||||
|
<div class="flex flex-col justify-center px-8 pt-8 my-auto md:justify-start md:pt-0 md:px-24 lg:px-32">
|
||||||
|
<p class="text-3xl text-center">Welcome.</p>
|
||||||
|
<form
|
||||||
|
class="flex flex-col pt-3 md:pt-8"
|
||||||
|
{{if
|
||||||
|
.Register}}action="./register"
|
||||||
|
{{ else }}action="./login"
|
||||||
|
{{ end }}
|
||||||
|
method="POST"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col pt-4">
|
||||||
|
<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">
|
||||||
|
{{ template "svg/user" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
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="Username" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col pt-4 mb-12">
|
||||||
|
<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">
|
||||||
|
{{ template "svg/password" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
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="Password" />
|
||||||
|
<span class="absolute -bottom-5 text-red-400 text-xs">{{ .Error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full px-4 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">
|
||||||
|
{{ if .Register }}
|
||||||
|
<span class="w-full">Register</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="w-full">Submit</span>
|
||||||
|
{{ end }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="pt-12 pb-12 text-center">
|
||||||
|
{{ if .Config.RegistrationEnabled }} {{ if .Register }}
|
||||||
|
<p>
|
||||||
|
Trying to login?
|
||||||
|
<a href="./login" class="font-semibold underline">Login here.</a>
|
||||||
|
</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>
|
||||||
|
Don't have an account?
|
||||||
|
<a href="./register" class="font-semibold underline">Register here.</a>
|
||||||
|
</p>
|
||||||
|
{{ end }} {{ end }}
|
||||||
|
<p class="mt-4">
|
||||||
|
<a href="./local" class="font-semibold underline">Offline / Local Mode</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block">
|
||||||
|
<img class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
|
src="/assets/images/book1.jpg" />
|
||||||
|
<img class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
|
src="/assets/images/book2.jpg" />
|
||||||
|
<img class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
|
src="/assets/images/book3.jpg" />
|
||||||
|
<img class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
|
src="/assets/images/book4.jpg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.image-fader img {
|
||||||
|
position: absolute;
|
||||||
|
animation-name: imagefade;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-duration: 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes imagefade {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
17% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
92% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-fader img:nth-of-type(1) {
|
||||||
|
animation-delay: 45s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-fader img:nth-of-type(2) {
|
||||||
|
animation-delay: 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-fader img:nth-of-type(3) {
|
||||||
|
animation-delay: 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-fader img:nth-of-type(4) {
|
||||||
|
animation-delay: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
templates/pages/progress.tmpl
Normal file
44
templates/pages/progress.tmpl
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Progress{{ end }}
|
||||||
|
{{ define "header" }}<a href="./progress">Progress</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Device</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Percent</th>
|
||||||
|
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="4">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $progress := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<a href="./documents/{{ $progress.DocumentID }}">{{ $progress.Author }} - {{ $progress.Title }}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $progress.DeviceName }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $progress.Percentage }}%</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ $progress.CreatedAt }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
109
templates/pages/search.tmpl
Normal file
109
templates/pages/search.tmpl
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Search{{ end }}
|
||||||
|
{{ define "header" }}<a href="./search">Search</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="w-full flex flex-col md:flex-row gap-4">
|
||||||
|
<div class="flex flex-col gap-4 grow">
|
||||||
|
<div class="flex flex-col gap-2 grow p-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="./search">
|
||||||
|
<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">
|
||||||
|
{{ template "svg/search2" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="query"
|
||||||
|
name="query"
|
||||||
|
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="Query" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex relative min-w-[12em]">
|
||||||
|
<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">
|
||||||
|
{{ template "svg/documents" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<select 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"
|
||||||
|
id="source"
|
||||||
|
name="source">
|
||||||
|
<option value="Annas Archive">Annas Archive</option>
|
||||||
|
<option value="LibGen Fiction">LibGen Fiction</option>
|
||||||
|
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
{{ if .SearchErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"></th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Series</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Type</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Size</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="6">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }} {{ range $item := .Data }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500">
|
||||||
|
<form action="./search" method="POST">
|
||||||
|
<input class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
value="{{ $.Source }}" />
|
||||||
|
<input class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ $item.Title }}" />
|
||||||
|
<input class="hidden"
|
||||||
|
type="text"
|
||||||
|
id="author"
|
||||||
|
name="author"
|
||||||
|
value="{{ $item.Author }}" />
|
||||||
|
<button name="id" value="{{ $item.ID }}">{{ template "svg/download" }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">{{ $item.Author }} - {{ $item.Title }}</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.Series "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.FileType "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.FileSize "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell p-3 border-b border-gray-200">
|
||||||
|
<p>{{ or $item.UploadDate "N/A" }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
124
templates/pages/settings.tmpl
Normal file
124
templates/pages/settings.tmpl
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{{ template "base" . }}
|
||||||
|
{{ define "title" }}Settings{{ end }}
|
||||||
|
{{ define "header" }}<a href="./settings">Settings</a>{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="w-full flex flex-col md:flex-row gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
{{ template "svg/user" (dict "Size" 60) }}
|
||||||
|
<p class="text-lg">{{ .Authorization.UserName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-4 grow">
|
||||||
|
<div class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold mb-2">Change Password</p>
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./settings"
|
||||||
|
method="POST">
|
||||||
|
<div class="flex flex-col 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">
|
||||||
|
{{ template "svg/password" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
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="Password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col 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">
|
||||||
|
{{ template "svg/password" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
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="New Password" />
|
||||||
|
</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">Submit</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ if .PasswordErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
||||||
|
{{ else if .PasswordMessage }}
|
||||||
|
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold mb-2">Change Time Offset</p>
|
||||||
|
<form class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./settings"
|
||||||
|
method="POST">
|
||||||
|
<div class="flex relative grow">
|
||||||
|
<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">
|
||||||
|
{{ template "svg/clock" (dict "Size" 15) }}
|
||||||
|
</span>
|
||||||
|
<select 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"
|
||||||
|
id="time_offset"
|
||||||
|
name="time_offset">
|
||||||
|
{{ range $item := getUTCOffsets }}
|
||||||
|
<option {{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }} value="{{ $item.Value }}">
|
||||||
|
{{ $item.Name }}
|
||||||
|
</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
</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">Submit</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ if .TimeOffsetErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
|
||||||
|
{{ else if .TimeOffsetMessage }}
|
||||||
|
<span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white">
|
||||||
|
<p class="text-lg font-semibold">Devices</p>
|
||||||
|
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">
|
||||||
|
Last Sync
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data.Devices }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="3">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $device := .Data.Devices }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 pl-0">
|
||||||
|
<p>{{ $device.DeviceName }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<p>{{ $device.LastSynced }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<p>{{ $device.CreatedAt }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Search{{end}} {{define "header"}}
|
|
||||||
<a href="./search">Search</a>
|
|
||||||
{{end}} {{define "content"}}
|
|
||||||
<div class="w-full flex flex-col md:flex-row gap-4">
|
|
||||||
<div class="flex flex-col gap-4 grow">
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 grow p-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="./search">
|
|
||||||
<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="query"
|
|
||||||
name="query"
|
|
||||||
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="Query"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex relative min-w-[12em]">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 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.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 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.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
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"
|
|
||||||
id="book_type"
|
|
||||||
name="book_type"
|
|
||||||
>
|
|
||||||
<option value="FICTION">Fiction</option>
|
|
||||||
<option value="NON_FICTION">Non-Fiction</option>
|
|
||||||
</select>
|
|
||||||
</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>
|
|
||||||
{{ if .SearchErrorMessage }}
|
|
||||||
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<thead class="text-gray-800 dark:text-gray-400">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
></th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Document
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Series
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Size
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="text-black dark:text-white">
|
|
||||||
{{ if not .Data }}
|
|
||||||
<tr>
|
|
||||||
<td class="text-center p-3" colspan="6">No Results</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }} {{range $item := .Data }}
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
|
|
||||||
>
|
|
||||||
<form action="./search" method="POST">
|
|
||||||
<input
|
|
||||||
class="hidden"
|
|
||||||
type="text"
|
|
||||||
id="book_type"
|
|
||||||
name="book_type"
|
|
||||||
value="{{ $.BookType }}"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
class="hidden"
|
|
||||||
type="text"
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
value="{{ $item.Title }}"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
class="hidden"
|
|
||||||
type="text"
|
|
||||||
id="author"
|
|
||||||
name="author"
|
|
||||||
value="{{ $item.Author }}"
|
|
||||||
/>
|
|
||||||
<button name="id" value="{{ $item.ID }}">
|
|
||||||
<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"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
{{ $item.Author }} - {{ $item.Title }}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ or $item.Series "N/A" }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ or $item.FileType "N/A" }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 border-b border-gray-200">
|
|
||||||
<p>{{ or $item.FileSize "N/A" }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="hidden md:table-cell p-3 border-b border-gray-200">
|
|
||||||
<p>{{ or $item.UploadDate "N/A" }}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user