Compare commits

40 Commits

Author SHA1 Message Date
bc076a4f44 fix: metadata count test
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 17:02:10 -05:00
f9f23f2d3f fix: word count calculation
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-12 19:13:04 -05:00
3cff965393 fix: annas archive parsing
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-17 17:04:46 -04:00
7937890acd fix: docker build
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-10 13:18:37 -04:00
938dd69e5e chore(db): use context & add db helper 2025-08-10 13:17:51 -04:00
7c92c346fa feat(utils): add pkg utils 2025-08-10 13:17:44 -04:00
456b6e457c chore: update go & flake
Some checks failed
continuous-integration/drone/push Build is failing
2025-08-07 17:42:41 -04:00
d304421798 hm
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-05 18:17:47 -04:00
0fe52bc541 fix: search parsing
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-05 16:46:06 -04:00
49f3d53170 chore: nix flake
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-05 15:21:44 -04:00
57f81e5dd7 fix(api): ko json content type
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-13 12:37:45 -04:00
162adfbe16 feat: basic toc
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-26 10:19:00 -04:00
e2cfdb3a0c update cicd
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-14 08:36:01 -04:00
acf4119d9a fix(sql): document user stats
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build was killed
2025-01-25 15:03:07 -05:00
f6dd8cee50 fix(streaks): incorrect calculation logic
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-02 19:27:50 -05:00
a981d98ba5 feat(admin): basic log filter
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 19:48:51 -05:00
a193f97d29 perf(db): incremental user streaks cache
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 18:58:46 -05:00
841b29c425 improve(search): progress & retries
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 17:04:41 -05:00
3d61d0f5ef perf(db): incremental document stats cache
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 12:48:25 -05:00
5e388730a5 formatting: lua plugin 2024-12-01 11:28:33 -05:00
0a1dfeab65 fix(search): set user agent for dl
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-13 22:32:16 -04:00
d4c8e4d2da fix(search): broken parser & download source
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-11 11:02:46 -04:00
bbd3a00102 tests(db): additional document tests 2024-08-10 09:26:30 -04:00
3a633235ea tests(db): add additional tests & comments
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-16 20:00:41 -04:00
9809a09d2e chore(prettier): format templates
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-16 18:04:43 -04:00
f37bff365f chore(templates): prettier plugin & tables 2024-06-16 17:08:10 -04:00
77527bfb05 chore(templates): add better template loading
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-27 20:20:47 -04:00
8de6fed5df fix(ui): document add styling 2024-05-27 14:01:10 -04:00
f9277d3b32 feat(admin): handle user deletion
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-27 13:32:40 -04:00
db9629a618 chore(lint): address linter
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-26 19:56:59 -04:00
546600db93 feat(admin): handle user demotion & promotion
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 21:12:07 -04:00
7c6acad689 chore(templates): component-ize things
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 20:04:26 -04:00
5482899075 feat(admin): adding user & importing 2024-05-25 20:02:57 -04:00
5a64ff7029 fix(tz): incorrect local_time function use
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 20:56:30 -04:00
a7ecb1a6f8 fix(tz): add tzdata to docker image
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 09:39:04 -04:00
2d206826d6 add(admin): add user
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-11 22:20:41 -07:00
f1414e3e4e fix(timezones): move from utc offsets to timezones
This fixed various issues related to calculating streaks, etc. Now we
appropriately handle time as it was, vs as it is relative to an offset.
2024-03-11 22:20:21 -07:00
8e81acd381 fix(users): update user stomped on admin
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-10 21:48:43 -04:00
6c6a6dd329 feat(api): first user is admin
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-25 19:40:36 -05:00
c4602c8c3b chore(db): update sqlc 2024-02-25 19:01:34 -05:00
84 changed files with 5810 additions and 3537 deletions

View File

@@ -1,7 +1,11 @@
kind: pipeline kind: pipeline
type: kubernetes type: docker
name: default name: default
trigger:
branch:
- master
steps: steps:
# Unit Tests # Unit Tests
- name: tests - name: tests
@@ -23,6 +27,8 @@ steps:
registry: gitea.va.reichard.io registry: gitea.va.reichard.io
tags: tags:
- dev - dev
custom_dns:
- 8.8.8.8
username: username:
from_secret: docker_username from_secret: docker_username
password: password:

2
.envrc
View File

@@ -1 +1 @@
use nix use flake

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ data/
build/ build/
.direnv/ .direnv/
cover.html cover.html
node_modules

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-go-template"]
}

View File

@@ -1,9 +1,9 @@
# Certificate Store # Certificates & Timezones
FROM alpine AS certs FROM alpine AS alpine
RUN apk update && apk add ca-certificates RUN apk update && apk add --no-cache ca-certificates tzdata
# Build Image # Build Image
FROM golang:1.21 AS build FROM golang:1.24 AS build
# Create Package Directory # Create Package Directory
RUN mkdir -p /opt/antholume RUN mkdir -p /opt/antholume
@@ -19,7 +19,8 @@ RUN go build \
# Create Image # Create Image
FROM busybox:1.36 FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY --from=alpine /etc/ssl/certs /etc/ssl/certs
COPY --from=alpine /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /opt/antholume /opt/antholume COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume WORKDIR /opt/antholume
EXPOSE 8585 EXPOSE 8585

View File

@@ -1,6 +1,6 @@
# Certificate Store # Certificates & Timezones
FROM alpine AS certs FROM alpine AS alpine
RUN apk update && apk add ca-certificates RUN apk update && apk add --no-cache ca-certificates tzdata
# Build Image # Build Image
FROM --platform=$BUILDPLATFORM golang:1.21 AS build FROM --platform=$BUILDPLATFORM golang:1.21 AS build
@@ -21,7 +21,8 @@ RUN --mount=target=. \
# Create Image # Create Image
FROM busybox:1.36 FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY --from=alpine /etc/ssl/certs /etc/ssl/certs
COPY --from=alpine /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /opt/antholume /opt/antholume COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume WORKDIR /opt/antholume
EXPOSE 8585 EXPOSE 8585

View File

@@ -27,7 +27,7 @@ docker_build_release_latest: build_tailwind
--push . --push .
build_tailwind: build_tailwind:
tailwind build -o ./assets/style.css --minify tailwindcss build -o ./assets/style.css --minify
dev: build_tailwind dev: build_tailwind
GIN_MODE=release \ GIN_MODE=release \

View File

@@ -118,7 +118,7 @@ See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reicha
## Development ## Development
SQLC Generation (v1.21.0): SQLC Generation (v1.26.0):
```bash ```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

View File

@@ -6,6 +6,7 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -15,6 +16,7 @@ import (
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/config" "reichard.io/antholume/config"
"reichard.io/antholume/database" "reichard.io/antholume/database"
@@ -37,6 +39,7 @@ func NewApi(db *database.DBManager, c *config.Config, assets fs.FS) *API {
db: db, db: db,
cfg: c, cfg: c,
assets: assets, assets: assets,
templates: make(map[string]*template.Template),
userAuthCache: make(map[string]string), userAuthCache: make(map[string]string),
} }
@@ -157,6 +160,7 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport) router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport) router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers) router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin) router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction) router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
router.POST("/login", api.appAuthLogin) router.POST("/login", api.appAuthLogin)
@@ -222,67 +226,112 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
func (api *API) generateTemplates() *multitemplate.Renderer { func (api *API) generateTemplates() *multitemplate.Renderer {
// Define templates & helper functions // Define templates & helper functions
templates := make(map[string]*template.Template)
render := multitemplate.NewRenderer() render := multitemplate.NewRenderer()
templates := make(map[string]*template.Template)
helperFuncs := template.FuncMap{ helperFuncs := template.FuncMap{
"dict": dict, "dict": dict,
"slice": slice,
"fields": fields, "fields": fields,
"getSVGGraphData": getSVGGraphData, "getSVGGraphData": getSVGGraphData,
"getUTCOffsets": getUTCOffsets, "getTimeZones": getTimeZones,
"hasPrefix": strings.HasPrefix, "hasPrefix": strings.HasPrefix,
"niceNumbers": niceNumbers, "niceNumbers": niceNumbers,
"niceSeconds": niceSeconds, "niceSeconds": niceSeconds,
} }
// Load base // Load Base
b, _ := fs.ReadFile(api.assets, "templates/base.tmpl") b, err := fs.ReadFile(api.assets, "templates/base.tmpl")
baseTemplate := template.Must(template.New("base").Funcs(helperFuncs).Parse(string(b))) if err != nil {
log.Errorf("error reading base template: %v", err)
return &render
}
// Parse Base
baseTemplate, err := template.New("base").Funcs(helperFuncs).Parse(string(b))
if err != nil {
log.Errorf("error parsing base template: %v", err)
return &render
}
// Load SVGs // Load SVGs
svgs, _ := fs.ReadDir(api.assets, "templates/svgs") err = api.loadTemplates("svg", baseTemplate, templates, false)
for _, item := range svgs { if err != nil {
basename := item.Name() log.Errorf("error loading svg templates: %v", err)
path := fmt.Sprintf("templates/svgs/%s", basename) return &render
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 // Load Components
components, _ := fs.ReadDir(api.assets, "templates/components") err = api.loadTemplates("component", baseTemplate, templates, false)
for _, item := range components { if err != nil {
basename := item.Name() log.Errorf("error loading component templates: %v", err)
path := fmt.Sprintf("templates/components/%s", basename) return &render
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 // Load Pages
pages, _ := fs.ReadDir(api.assets, "templates/pages") err = api.loadTemplates("page", baseTemplate, templates, true)
for _, item := range pages { if err != nil {
basename := item.Name() log.Errorf("error loading page templates: %v", err)
path := fmt.Sprintf("templates/pages/%s", basename) return &render
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
} }
// Populate Renderer
api.templates = templates api.templates = templates
for templateName, templateValue := range templates {
render.Add(templateName, templateValue)
}
return &render return &render
} }
func (api *API) loadTemplates(
basePath string,
baseTemplate *template.Template,
allTemplates map[string]*template.Template,
cloneBase bool,
) error {
// Load Templates (Pluralize)
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory))
}
// Generate Templates
for _, item := range allFiles {
templateFile := item.Name()
templatePath := path.Join(templateDirectory, templateFile)
templateName := fmt.Sprintf("%s/%s", basePath, strings.TrimSuffix(templateFile, filepath.Ext(templateFile)))
// Read Template
b, err := fs.ReadFile(api.assets, templatePath)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName))
}
// Clone? (Pages - Don't Stomp)
if cloneBase {
baseTemplate = template.Must(baseTemplate.Clone())
}
// Parse Template
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName))
}
allTemplates[templateName] = baseTemplate
}
return nil
}
func (api *API) templateMiddleware(router *gin.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
router.HTMLRender = *api.generateTemplates()
c.Next()
}
}
func loggingMiddleware(c *gin.Context) { func loggingMiddleware(c *gin.Context) {
// Start timer // Start timer
startTime := time.Now() startTime := time.Now()
@@ -298,7 +347,7 @@ func loggingMiddleware(c *gin.Context) {
logData := log.Fields{ logData := log.Fields{
"type": "access", "type": "access",
"ip": c.ClientIP(), "ip": c.ClientIP(),
"latency": fmt.Sprintf("%s", latency), "latency": latency.String(),
"status": c.Writer.Status(), "status": c.Writer.Status(),
"method": c.Request.Method, "method": c.Request.Method,
"path": c.Request.URL.Path, "path": c.Request.URL.Path,
@@ -318,10 +367,3 @@ func loggingMiddleware(c *gin.Context) {
// Log result // Log result
log.WithFields(logData).Info(fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path)) 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()
}
}

View File

@@ -3,6 +3,8 @@ package api
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"context"
"crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -12,14 +14,19 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
"time" "time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
) )
type adminAction string type adminAction string
@@ -54,10 +61,41 @@ type requestAdminImport struct {
Type importType `form:"type"` Type importType `form:"type"`
} }
type operationType string
const (
opUpdate operationType = "UPDATE"
opCreate operationType = "CREATE"
opDelete operationType = "DELETE"
)
type requestAdminUpdateUser struct {
User string `form:"user"`
Password *string `form:"password"`
IsAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"`
}
type requestAdminLogs struct { type requestAdminLogs struct {
Filter string `form:"filter"` Filter string `form:"filter"`
} }
type importStatus string
const (
importFailed importStatus = "FAILED"
importSuccess importStatus = "SUCCESS"
importExists importStatus = "EXISTS"
)
type importResult struct {
ID string
Name string
Path string
Status importStatus
Error error
}
func (api *API) appPerformAdminAction(c *gin.Context) { func (api *API) appPerformAdminAction(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c) templateVars, _ := api.getBaseTemplateVars("admin", c)
@@ -68,21 +106,24 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return return
} }
// TODO - Messages
switch rAdminAction.Action { switch rAdminAction.Action {
case adminMetadataMatch: case adminMetadataMatch:
// TODO // TODO
// 1. Documents xref most recent metadata table? // 1. Documents xref most recent metadata table?
// 2. Select all / deselect? // 2. Select all / deselect?
case adminCacheTables: case adminCacheTables:
go api.db.CacheTempTables() go func() {
// TODO - Message err := api.db.CacheTempTables(c)
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore: case adminRestore:
api.processRestoreFile(rAdminAction, c) api.processRestoreFile(rAdminAction, c)
return return
case adminBackup: case adminBackup:
// Vacuum // Vacuum
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;") _, err := api.db.DB.ExecContext(c, "VACUUM;")
if err != nil { if err != nil {
log.Error("Unable to vacuum DB: ", err) log.Error("Unable to vacuum DB: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database") appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
@@ -104,7 +145,7 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
} }
} }
err := api.createBackup(w, directories) err := api.createBackup(c, w, directories)
if err != nil { if err != nil {
log.Error("Backup Error: ", err) log.Error("Backup Error: ", err)
} }
@@ -134,7 +175,10 @@ func (api *API) appGetAdminLogs(c *gin.Context) {
rAdminLogs.Filter = strings.TrimSpace(rAdminLogs.Filter) rAdminLogs.Filter = strings.TrimSpace(rAdminLogs.Filter)
var jqFilter *gojq.Code var jqFilter *gojq.Code
if rAdminLogs.Filter != "" { var basicFilter string
if strings.HasPrefix(rAdminLogs.Filter, "\"") && strings.HasSuffix(rAdminLogs.Filter, "\"") {
basicFilter = rAdminLogs.Filter[1 : len(rAdminLogs.Filter)-1]
} else if rAdminLogs.Filter != "" {
parsed, err := gojq.Parse(rAdminLogs.Filter) parsed, err := gojq.Parse(rAdminLogs.Filter)
if err != nil { if err != nil {
log.Error("Unable to parse JQ filter") log.Error("Unable to parse JQ filter")
@@ -166,7 +210,7 @@ func (api *API) appGetAdminLogs(c *gin.Context) {
rawLog := scanner.Text() rawLog := scanner.Text()
// Attempt JSON Pretty // Attempt JSON Pretty
var jsonMap map[string]interface{} var jsonMap map[string]any
err := json.Unmarshal([]byte(rawLog), &jsonMap) err := json.Unmarshal([]byte(rawLog), &jsonMap)
if err != nil { if err != nil {
logLines = append(logLines, scanner.Text()) logLines = append(logLines, scanner.Text())
@@ -180,12 +224,17 @@ func (api *API) appGetAdminLogs(c *gin.Context) {
continue continue
} }
// No Filter // Basic Filter
if jqFilter == nil { if basicFilter != "" && strings.Contains(string(rawData), basicFilter) {
logLines = append(logLines, string(rawData)) logLines = append(logLines, string(rawData))
continue continue
} }
// No JQ Filter
if jqFilter == nil {
continue
}
// Error or nil // Error or nil
result, _ := jqFilter.Run(jsonMap).Next() result, _ := jqFilter.Run(jsonMap).Next()
if _, ok := result.(error); ok { if _, ok := result.(error); ok {
@@ -213,7 +262,53 @@ func (api *API) appGetAdminLogs(c *gin.Context) {
func (api *API) appGetAdminUsers(c *gin.Context) { func (api *API) appGetAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c) templateVars, _ := api.getBaseTemplateVars("admin-users", c)
users, err := api.db.Queries.GetUsers(api.db.Ctx) users, err := api.db.Queries.GetUsers(c)
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) appUpdateAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
var rUpdate requestAdminUpdateUser
if err := c.ShouldBind(&rUpdate); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid user parameters")
return
}
// Ensure Username
if rUpdate.User == "" {
appErrorPage(c, http.StatusInternalServerError, "User cannot be empty")
return
}
var err error
switch rUpdate.Operation {
case opCreate:
err = api.createUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opUpdate:
err = api.updateUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opDelete:
err = api.deleteUser(c, rUpdate.User)
default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return
}
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err))
return
}
users, err := api.db.Queries.GetUsers(c)
if err != nil { if err != nil {
log.Error("GetUsers DB Error: ", err) log.Error("GetUsers DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
@@ -285,46 +380,157 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
return return
} }
// TODO - Store results for approval? // Get import directory
// Walk import directory & copy or import files
importDirectory := filepath.Clean(rAdminImport.Directory) importDirectory := filepath.Clean(rAdminImport.Directory)
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
// Get data directory
absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
// Validate different path
if absoluteDataPath == importDirectory {
appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path")
return
}
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown error")
return
}
// Defer & Start Transaction
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Track imports
importResults := make([]importResult, 0)
// Walk Directory & Import
err = filepath.WalkDir(importDirectory, func(importPath string, f fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if f.IsDir() { if f.IsDir() {
return nil return nil
} }
// Get metadata // Get relative path
fileMeta, err := metadata.GetMetadata(currentPath) basePath := importDirectory
relFilePath, err := filepath.Rel(importDirectory, importPath)
if err != nil { if err != nil {
fmt.Printf("metadata error: %v\n", err) log.Warnf("path error: %v", err)
return nil return nil
} }
// Only needed if copying // Track imports
newName := deriveBaseFileName(fileMeta) iResult := importResult{
Path: relFilePath,
Status: importFailed,
}
defer func() {
importResults = append(importResults, iResult)
}()
// Open File on Disk // Get metadata
// file, err := os.Open(currentPath) fileMeta, err := metadata.GetMetadata(importPath)
// if err != nil { if err != nil {
// return err log.Errorf("metadata error: %v", err)
// } iResult.Error = err
// defer file.Close() return nil
}
iResult.ID = *fileMeta.PartialMD5
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// TODO - BasePath in DB // Check already exists
// TODO - Copy / Import _, err = qtx.GetDocument(c, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
return nil
}
fmt.Printf("New File Metadata: %s\n", newName) // Import Copy
if rAdminImport.Type == importCopy {
// Derive & Sanitize File Name
relFilePath = deriveBaseFileName(fileMeta)
safePath := filepath.Join(api.cfg.DataPath, "documents", relFilePath)
// Open Source File
srcFile, err := os.Open(importPath)
if err != nil {
log.Errorf("unable to open current file: %v", err)
iResult.Error = err
return nil
}
defer srcFile.Close()
// Open Destination File
destFile, err := os.Create(safePath)
if err != nil {
log.Errorf("unable to open destination file: %v", err)
iResult.Error = err
return nil
}
defer destFile.Close()
// Copy File
if _, err = io.Copy(destFile, srcFile); err != nil {
log.Errorf("unable to save file: %v", err)
iResult.Error = err
return nil
}
// Update Base & Path
basePath = filepath.Join(api.cfg.DataPath, "documents")
iResult.Path = relFilePath
}
// Upsert document
if _, err = qtx.UpsertDocument(c, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
Title: fileMeta.Title,
Author: fileMeta.Author,
Description: fileMeta.Description,
Md5: fileMeta.MD5,
Words: fileMeta.WordCount,
Filepath: &relFilePath,
Basepath: &basePath,
}); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err)
iResult.Error = err
return nil
}
iResult.Status = importSuccess
return nil return nil
}) })
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err))
return
}
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory) // Commit transaction
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "page/admin-import", templateVars) // Sort import results
sort.Slice(importResults, func(i int, j int) bool {
return importStatusPriority(importResults[i].Status) <
importStatusPriority(importResults[j].Status)
})
templateVars["Data"] = importResults
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
} }
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
@@ -420,17 +626,9 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
} }
defer backupFile.Close() 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 // Save Backup File
w := bufio.NewWriter(backupFile) w := bufio.NewWriter(backupFile)
err = api.createBackup(w, []string{"covers", "documents"}) err = api.createBackup(c, w, []string{"covers", "documents"})
if err != nil { if err != nil {
log.Error("Unable to save backup file: ", err) log.Error("Unable to save backup file: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file") appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file")
@@ -453,13 +651,13 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
} }
// Reinit DB // Reinit DB
if err := api.db.Reload(); err != nil { if err := api.db.Reload(c); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to reload DB") appErrorPage(c, http.StatusInternalServerError, "Unable to reload DB")
log.Panicf("Unable to reload DB: %v", err) log.Panicf("Unable to reload DB: %v", err)
} }
// Rotate Auth Hashes // Rotate Auth Hashes
if err := api.rotateAllAuthHashes(); err != nil { if err := api.rotateAllAuthHashes(c); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to rotate hashes") appErrorPage(c, http.StatusInternalServerError, "Unable to rotate hashes")
log.Panicf("Unable to rotate auth hashes: %v", err) log.Panicf("Unable to rotate auth hashes: %v", err)
} }
@@ -468,7 +666,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
// Restore all data
func (api *API) restoreData(zipReader *zip.Reader) error { func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories // Ensure Directories
api.cfg.EnsureDirectories() api.cfg.EnsureDirectories()
@@ -484,14 +681,14 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
destPath := filepath.Join(api.cfg.DataPath, file.Name) destPath := filepath.Join(api.cfg.DataPath, file.Name)
destFile, err := os.Create(destPath) destFile, err := os.Create(destPath)
if err != nil { if err != nil {
fmt.Println("Error creating destination file:", err) log.Errorf("error creating destination file: %v", err)
return err return err
} }
defer destFile.Close() defer destFile.Close()
// Copy the contents from the zip file to the destination file. // Copy the contents from the zip file to the destination file.
if _, err := io.Copy(destFile, rc); err != nil { if _, err := io.Copy(destFile, rc); err != nil {
fmt.Println("Error copying file contents:", err) log.Errorf("Error copying file contents: %v", err)
return err return err
} }
} }
@@ -499,7 +696,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
return nil return nil
} }
// Remove all data
func (api *API) removeData() error { func (api *API) removeData() error {
allPaths := []string{ allPaths := []string{
"covers", "covers",
@@ -522,10 +718,14 @@ func (api *API) removeData() error {
return nil return nil
} }
// Backup all data func (api *API) createBackup(ctx context.Context, w io.Writer, directories []string) error {
func (api *API) createBackup(w io.Writer, directories []string) error { // Vacuum DB
ar := zip.NewWriter(w) _, err := api.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil {
return errors.Wrap(err, "Unable to vacuum database")
}
ar := zip.NewWriter(w)
exportWalker := func(currentPath string, f fs.DirEntry, err error) error { exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -575,7 +775,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
if err != nil { if err != nil {
return err return err
} }
io.Copy(newDbFile, dbFile)
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents // Backup Covers & Documents
for _, dir := range directories { for _, dir := range directories {
@@ -588,3 +792,159 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
ar.Close() ar.Close()
return nil return nil
} }
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(ctx)
if err != nil {
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
}
hasAdmin := false
for _, user := range allUsers {
if user.Admin && user.ID != userID {
hasAdmin = true
break
}
}
return !hasAdmin, nil
}
func (api *API) createUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil || *rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
// Base Params
createParams := database.CreateUserParams{
ID: user,
}
// Handle Admin (Explicit or False)
if isAdmin != nil {
createParams.Admin = *isAdmin
} else {
createParams.Admin = false
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
createParams.Pass = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
createParams.AuthHash = &authHash
// Create user in DB
if rows, err := api.db.Queries.CreateUser(ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", createParams.ID)
return fmt.Errorf("user already exists")
}
return nil
}
func (api *API) updateUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil && isAdmin == nil {
return fmt.Errorf("nothing to update")
}
// Base Params
updateParams := database.UpdateUserParams{
UserID: user,
}
// Handle Admin (Update or Existing)
if isAdmin != nil {
updateParams.Admin = *isAdmin
} else {
user, err := api.db.Queries.GetUser(ctx, user)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
}
updateParams.Admin = user.Admin
}
// Check Admins - Disallow Demotion
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err
} else if isLast && !updateParams.Admin {
return fmt.Errorf("unable to demote %s - last admin", user)
}
// Handle Password
if rawPassword != nil {
if *rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
updateParams.Password = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
updateParams.AuthHash = &authHash
}
// Update User
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
}
return nil
}
func (api *API) deleteUser(ctx context.Context, user string) error {
// Check Admins
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err
} else if isLast {
return fmt.Errorf("unable to delete %s - last admin", user)
}
// 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 {
return err
}
defer backupFile.Close()
// Save Backup File (DB Only)
w := bufio.NewWriter(backupFile)
err = api.createBackup(ctx, w, []string{})
if err != nil {
return err
}
// Delete User
_, err = api.db.Queries.DeleteUser(ctx, user)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
}
return nil
}

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/md5" "crypto/md5"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -22,8 +23,8 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/search" "reichard.io/antholume/search"
"reichard.io/antholume/utils"
) )
type backupType string type backupType string
@@ -69,7 +70,7 @@ type requestDocumentIdentify struct {
type requestSettingsEdit struct { type requestSettingsEdit struct {
Password *string `form:"password"` Password *string `form:"password"`
NewPassword *string `form:"new_password"` NewPassword *string `form:"new_password"`
TimeOffset *string `form:"time_offset"` Timezone *string `form:"timezone"`
} }
type requestDocumentAdd struct { type requestDocumentAdd struct {
@@ -110,9 +111,10 @@ func (api *API) appGetDocuments(c *gin.Context) {
query = &search query = &search
} }
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{ documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName, UserID: auth.UserName,
Query: query, Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
@@ -122,14 +124,14 @@ func (api *API) appGetDocuments(c *gin.Context) {
return return
} }
length, err := api.db.Queries.GetDocumentsSize(api.db.Ctx, query) length, err := api.db.Queries.GetDocumentsSize(c, query)
if err != nil { if err != nil {
log.Error("GetDocumentsSize DB Error: ", err) log.Error("GetDocumentsSize DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
return return
} }
if err = api.getDocumentsWordCount(documents); err != nil { if err = api.getDocumentsWordCount(c, documents); err != nil {
log.Error("Unable to Get Word Counts: ", err) log.Error("Unable to Get Word Counts: ", err)
} }
@@ -161,13 +163,10 @@ func (api *API) appGetDocument(c *gin.Context) {
return return
} }
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{ document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil { if err != nil {
log.Error("GetDocumentWithStats DB Error: ", err) log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return return
} }
@@ -193,7 +192,7 @@ func (api *API) appGetProgress(c *gin.Context) {
progressFilter.DocumentID = *qParams.Document progressFilter.DocumentID = *qParams.Document
} }
progress, err := api.db.Queries.GetProgress(api.db.Ctx, progressFilter) progress, err := api.db.Queries.GetProgress(c, progressFilter)
if err != nil { if err != nil {
log.Error("GetProgress DB Error: ", err) log.Error("GetProgress DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
@@ -220,7 +219,7 @@ func (api *API) appGetActivity(c *gin.Context) {
activityFilter.DocumentID = *qParams.Document activityFilter.DocumentID = *qParams.Document
} }
activity, err := api.db.Queries.GetActivity(api.db.Ctx, activityFilter) activity, err := api.db.Queries.GetActivity(c, activityFilter)
if err != nil { if err != nil {
log.Error("GetActivity DB Error: ", err) log.Error("GetActivity DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
@@ -236,7 +235,7 @@ func (api *API) appGetHome(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("home", c) templateVars, auth := api.getBaseTemplateVars("home", c)
start := time.Now() start := time.Now()
graphData, err := api.db.Queries.GetDailyReadStats(api.db.Ctx, auth.UserName) graphData, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDailyReadStats DB Error: ", err) log.Error("GetDailyReadStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
@@ -245,7 +244,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start)) log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now() start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(api.db.Ctx, auth.UserName) databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDatabaseInfo DB Error: ", err) log.Error("GetDatabaseInfo DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
@@ -254,7 +253,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start)) log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
start = time.Now() start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(api.db.Ctx, auth.UserName) streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetUserStreaks DB Error: ", err) log.Error("GetUserStreaks DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
@@ -263,7 +262,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetUserStreaks DB Performance: ", time.Since(start)) log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now() start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(api.db.Ctx) userStatistics, err := api.db.Queries.GetUserStatistics(c)
if err != nil { if err != nil {
log.Error("GetUserStatistics DB Error: ", err) log.Error("GetUserStatistics DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
@@ -284,14 +283,14 @@ func (api *API) appGetHome(c *gin.Context) {
func (api *API) appGetSettings(c *gin.Context) { func (api *API) appGetSettings(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("settings", c) templateVars, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(api.db.Ctx, auth.UserName) user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetUser DB Error: ", err) log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return return
} }
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName) devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDevices DB Error: ", err) log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
@@ -299,7 +298,7 @@ func (api *API) appGetSettings(c *gin.Context) {
} }
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
"TimeOffset": *user.TimeOffset, "Timezone": *user.Timezone,
"Devices": devices, "Devices": devices,
} }
@@ -314,7 +313,11 @@ func (api *API) appGetSearch(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("search", c) templateVars, _ := api.getBaseTemplateVars("search", c)
var sParams searchParams var sParams searchParams
c.BindQuery(&sParams) err := c.BindQuery(&sParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return
}
// Only Handle Query // Only Handle Query
if sParams.Query != nil && sParams.Source != nil { if sParams.Query != nil && sParams.Source != nil {
@@ -365,24 +368,20 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
return return
} }
progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{ progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{
DocumentID: rDoc.DocumentID, DocumentID: rDoc.DocumentID,
UserID: auth.UserName, UserID: auth.UserName,
}) })
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Error("UpsertDocument DB Error: ", err) log.Error("GetDocumentProgress DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err))
return return
} }
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{ document, err := api.db.GetDocument(c, rDoc.DocumentID, auth.UserName)
UserID: auth.UserName,
DocumentID: rDoc.DocumentID,
})
if err != nil { if err != nil {
log.Error("GetDocumentWithStats DB Error: ", err) log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return return
} }
@@ -402,7 +401,7 @@ func (api *API) appGetDevices(c *gin.Context) {
auth = data.(authData) auth = data.(authData)
} }
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName) devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
log.Error("GetDevices DB Error: ", err) log.Error("GetDevices DB Error: ", err)
@@ -453,7 +452,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
} }
// Check Already Exists // Check Already Exists
_, err = api.db.Queries.GetDocument(api.db.Ctx, *metadataInfo.PartialMD5) _, err = api.db.Queries.GetDocument(c, *metadataInfo.PartialMD5)
if err == nil { if err == nil {
log.Warnf("document already exists: %s", *metadataInfo.PartialMD5) log.Warnf("document already exists: %s", *metadataInfo.PartialMD5)
c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", *metadataInfo.PartialMD5)) c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", *metadataInfo.PartialMD5))
@@ -461,7 +460,8 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
// Derive & Sanitize File Name // Derive & Sanitize File Name
fileName := deriveBaseFileName(metadataInfo) fileName := deriveBaseFileName(metadataInfo)
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Open Destination File // Open Destination File
destFile, err := os.Create(safePath) destFile, err := os.Create(safePath)
@@ -480,7 +480,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
} }
// Upsert Document // Upsert Document
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: *metadataInfo.PartialMD5, ID: *metadataInfo.PartialMD5,
Title: metadataInfo.Title, Title: metadataInfo.Title,
Author: metadataInfo.Author, Author: metadataInfo.Author,
@@ -488,9 +488,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
Md5: metadataInfo.MD5, Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount, Words: metadataInfo.WordCount,
Filepath: &fileName, Filepath: &fileName,
Basepath: &basePath,
// TODO (BasePath):
// - Should be current config directory
}); err != nil { }); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err) log.Errorf("UpsertDocument DB Error: %v", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
@@ -572,7 +570,7 @@ func (api *API) appEditDocument(c *gin.Context) {
coverFileName = &fileName coverFileName = &fileName
} else if rDocEdit.CoverGBID != nil { } else if rDocEdit.CoverGBID != nil {
var coverDir string = filepath.Join(api.cfg.DataPath, "covers") coverDir := filepath.Join(api.cfg.DataPath, "covers")
fileName, err := metadata.CacheCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true) fileName, err := metadata.CacheCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true)
if err == nil { if err == nil {
coverFileName = fileName coverFileName = fileName
@@ -580,7 +578,7 @@ func (api *API) appEditDocument(c *gin.Context) {
} }
// Update Document // Update Document
if _, err := api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err := api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: rDocID.DocumentID, ID: rDocID.DocumentID,
Title: api.sanitizeInput(rDocEdit.Title), Title: api.sanitizeInput(rDocEdit.Title),
Author: api.sanitizeInput(rDocEdit.Author), Author: api.sanitizeInput(rDocEdit.Author),
@@ -595,7 +593,6 @@ func (api *API) appEditDocument(c *gin.Context) {
} }
c.Redirect(http.StatusFound, "./") c.Redirect(http.StatusFound, "./")
return
} }
func (api *API) appDeleteDocument(c *gin.Context) { func (api *API) appDeleteDocument(c *gin.Context) {
@@ -605,7 +602,7 @@ func (api *API) appDeleteDocument(c *gin.Context) {
appErrorPage(c, http.StatusNotFound, "Invalid document") appErrorPage(c, http.StatusNotFound, "Invalid document")
return return
} }
changed, err := api.db.Queries.DeleteDocument(api.db.Ctx, rDocID.DocumentID) changed, err := api.db.Queries.DeleteDocument(c, rDocID.DocumentID)
if err != nil { if err != nil {
log.Error("DeleteDocument DB Error") log.Error("DeleteDocument DB Error")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("DeleteDocument DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("DeleteDocument DB Error: %v", err))
@@ -667,7 +664,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
firstResult := metadataResults[0] firstResult := metadataResults[0]
// Store First Metadata Result // Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{ if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: rDocID.DocumentID, DocumentID: rDocID.DocumentID,
Title: firstResult.Title, Title: firstResult.Title,
Author: firstResult.Author, Author: firstResult.Author,
@@ -686,13 +683,10 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
templateVars["MetadataError"] = "No Metadata Found" templateVars["MetadataError"] = "No Metadata Found"
} }
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{ document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil { if err != nil {
log.Error("GetDocumentWithStats DB Error: ", err) log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return return
} }
@@ -739,52 +733,50 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
} }
// Send Message // Send Message
sendDownloadMessage("Downloading document...", gin.H{"Progress": 10}) sendDownloadMessage("Downloading document...", gin.H{"Progress": 1})
// Scaled Download Function
lastTime := time.Now()
downloadFunc := func(p float32) {
nowTime := time.Now()
if nowTime.Before(lastTime.Add(time.Millisecond * 500)) {
return
}
scaledProgress := int((p * 95 / 100) + 2)
sendDownloadMessage("Downloading document...", gin.H{"Progress": scaledProgress})
lastTime = nowTime
}
// Save Book // Save Book
tempFilePath, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source) tempFilePath, metadata, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source, downloadFunc)
if err != nil { if err != nil {
log.Warn("Temp File Error: ", err) log.Warn("Save Book Error: ", err)
sendDownloadMessage("Unable to download file", gin.H{"Error": true}) sendDownloadMessage("Unable to download file", gin.H{"Error": true})
return return
} }
// Send Message // Send Message
sendDownloadMessage("Calculating partial MD5...", gin.H{"Progress": 60}) sendDownloadMessage("Saving document...", gin.H{"Progress": 98})
// Calculate Partial MD5 ID // Derive Author / Title
partialMD5, err := utils.CalculatePartialMD5(tempFilePath) docAuthor := "Unknown"
if err != nil { if *metadata.Author != "" {
log.Warn("Partial MD5 Error: ", err) docAuthor = *metadata.Author
sendDownloadMessage("Unable to calculate partial MD5", gin.H{"Error": true}) } else if *rDocAdd.Author != "" {
docAuthor = *rDocAdd.Author
} }
// Send Message docTitle := "Unknown"
sendDownloadMessage("Saving file...", gin.H{"Progress": 60}) if *metadata.Title != "" {
docTitle = *metadata.Title
// Derive Extension on MIME } else if *rDocAdd.Title != "" {
fileMime, err := mimetype.DetectFile(tempFilePath) docTitle = *rDocAdd.Title
fileExtension := fileMime.Extension()
// Derive Filename
var fileName string
if *rDocAdd.Author != "" {
fileName = fileName + *rDocAdd.Author
} else {
fileName = fileName + "Unknown"
} }
if *rDocAdd.Title != "" { // Remove Slashes & Sanitize File Name
fileName = fileName + " - " + *rDocAdd.Title fileName := fmt.Sprintf("%s - %s", docAuthor, docTitle)
} else {
fileName = fileName + " - Unknown"
}
// Remove Slashes
fileName = strings.ReplaceAll(fileName, "/", "") fileName = strings.ReplaceAll(fileName, "/", "")
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadata.PartialMD5, metadata.Type))
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *partialMD5, fileExtension))
// Open Source File // Open Source File
sourceFile, err := os.Open(tempFilePath) sourceFile, err := os.Open(tempFilePath)
@@ -797,7 +789,9 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
defer sourceFile.Close() defer sourceFile.Close()
// Generate Storage Path & Open File // Generate Storage Path & Open File
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
destFile, err := os.Create(safePath) destFile, err := os.Create(safePath)
if err != nil { if err != nil {
log.Error("Dest File Error: ", err) log.Error("Dest File Error: ", err)
@@ -814,38 +808,17 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
} }
// Send Message // Send Message
sendDownloadMessage("Calculating MD5...", gin.H{"Progress": 70}) sendDownloadMessage("Saving to database...", gin.H{"Progress": 99})
// Get MD5 Hash
fileHash, err := getFileMD5(safePath)
if err != nil {
log.Error("Hash Failure: ", err)
sendDownloadMessage("Unable to calculate MD5", gin.H{"Error": true})
return
}
// Send Message
sendDownloadMessage("Calculating word count...", gin.H{"Progress": 80})
// Get Word Count
wordCount, err := metadata.GetWordCount(safePath)
if err != nil {
log.Error("Word Count Failure: ", err)
sendDownloadMessage("Unable to calculate word count", gin.H{"Error": true})
return
}
// Send Message
sendDownloadMessage("Saving to database...", gin.H{"Progress": 90})
// Upsert Document // Upsert Document
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: *partialMD5, ID: *metadata.PartialMD5,
Title: rDocAdd.Title, Title: &docTitle,
Author: rDocAdd.Author, Author: &docAuthor,
Md5: fileHash, Md5: metadata.MD5,
Words: metadata.WordCount,
Filepath: &fileName, Filepath: &fileName,
Words: wordCount, Basepath: &basePath,
}); err != nil { }); err != nil {
log.Error("UpsertDocument DB Error: ", err) log.Error("UpsertDocument DB Error: ", err)
sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) sendDownloadMessage("Unable to save to database", gin.H{"Error": true})
@@ -856,7 +829,7 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
sendDownloadMessage("Download Success", gin.H{ sendDownloadMessage("Download Success", gin.H{
"Progress": 100, "Progress": 100,
"ButtonText": "Go to Book", "ButtonText": "Go to Book",
"ButtonHref": fmt.Sprintf("./documents/%s", *partialMD5), "ButtonHref": fmt.Sprintf("./documents/%s", *metadata.PartialMD5),
}) })
} }
@@ -869,7 +842,7 @@ func (api *API) appEditSettings(c *gin.Context) {
} }
// Validate Something Exists // Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil { if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
log.Error("Missing Form Values") log.Error("Missing Form Values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return return
@@ -879,12 +852,13 @@ func (api *API) appEditSettings(c *gin.Context) {
newUserSettings := database.UpdateUserParams{ newUserSettings := database.UpdateUserParams{
UserID: auth.UserName, UserID: auth.UserName,
Admin: auth.IsAdmin,
} }
// Set New Password // Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
data := api.authorizeCredentials(auth.UserName, password) data := api.authorizeCredentials(c, auth.UserName, password)
if data == nil { if data == nil {
templateVars["PasswordErrorMessage"] = "Invalid Password" templateVars["PasswordErrorMessage"] = "Invalid Password"
} else { } else {
@@ -900,13 +874,13 @@ func (api *API) appEditSettings(c *gin.Context) {
} }
// Set Time Offset // Set Time Offset
if rUserSettings.TimeOffset != nil { if rUserSettings.Timezone != nil {
templateVars["TimeOffsetMessage"] = "Time Offset Updated" templateVars["TimeOffsetMessage"] = "Time Offset Updated"
newUserSettings.TimeOffset = rUserSettings.TimeOffset newUserSettings.Timezone = rUserSettings.Timezone
} }
// Update User // Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, newUserSettings) _, err := api.db.Queries.UpdateUser(c, newUserSettings)
if err != nil { if err != nil {
log.Error("UpdateUser DB Error: ", err) log.Error("UpdateUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
@@ -914,7 +888,7 @@ func (api *API) appEditSettings(c *gin.Context) {
} }
// Get User // Get User
user, err := api.db.Queries.GetUser(api.db.Ctx, auth.UserName) user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetUser DB Error: ", err) log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
@@ -922,7 +896,7 @@ func (api *API) appEditSettings(c *gin.Context) {
} }
// Get Devices // Get Devices
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName) devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil { if err != nil {
log.Error("GetDevices DB Error: ", err) log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
@@ -930,7 +904,7 @@ func (api *API) appEditSettings(c *gin.Context) {
} }
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
"TimeOffset": *user.TimeOffset, "Timezone": *user.Timezone,
"Devices": devices, "Devices": devices,
} }
@@ -941,7 +915,7 @@ func (api *API) appDemoModeError(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode") appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
} }
func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStatsRow) error { func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.GetDocumentsWithStatsRow) error {
// Do Transaction // Do Transaction
tx, err := api.db.DB.Begin() tx, err := api.db.DB.Begin()
if err != nil { if err != nil {
@@ -950,7 +924,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
for _, item := range documents { for _, item := range documents {
@@ -960,7 +938,7 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
if err != nil { if err != nil {
log.Warn("Word Count Error: ", err) log.Warn("Word Count Error: ", err)
} else { } else {
if _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: item.ID, ID: item.ID,
Words: wordCount, Words: wordCount,
}); err != nil { }); err != nil {
@@ -999,7 +977,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams var qParams queryParams
c.BindQuery(&qParams) err := c.BindQuery(&qParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return qParams
}
if qParams.Limit == nil { if qParams.Limit == nil {
qParams.Limit = &defaultLimit qParams.Limit = &defaultLimit
@@ -1017,7 +999,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
} }
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) { func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
var errorHuman string = "We're not even sure what happened." errorHuman := "We're not even sure what happened."
switch errorCode { switch errorCode {
case http.StatusInternalServerError: case http.StatusInternalServerError:
@@ -1039,11 +1021,11 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H { func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
// Item Sorter // Item Sorter
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]interface{} { sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...) sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
sort.SliceStable(sortedData, less) sort.SliceStable(sortedData, less)
newData := make([]map[string]interface{}, 0) newData := make([]map[string]any, 0)
for _, item := range sortedData { for _, item := range sortedData {
v := reflect.Indirect(reflect.ValueOf(item)) v := reflect.Indirect(reflect.ValueOf(item))
@@ -1059,7 +1041,7 @@ func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H
value = niceNumbers(rawVal) value = niceNumbers(rawVal)
} }
newData = append(newData, map[string]interface{}{ newData = append(newData, map[string]any{
"UserID": item.UserID, "UserID": item.UserID,
"Value": value, "Value": value,
}) })

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"net/http" "net/http"
@@ -28,22 +29,17 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"` AuthKey string `header:"x-auth-key"`
} }
// OPDS Auth Headers func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
type authOPDSHeader struct { user, err := api.db.Queries.GetUser(ctx, username)
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil { if err != nil {
return return
} }
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return return
} }
// Update Auth Cache // Update auth cache
api.userAuthCache[user.ID] = *user.AuthHash api.userAuthCache[user.ID] = *user.AuthHash
return &authData{ return &authData{
@@ -57,7 +53,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session First // Check Session First
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(c, session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@@ -76,7 +72,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
return return
} }
authData := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey) authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
if authData == nil { if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
@@ -98,14 +94,14 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
user, rawPassword, hasAuth := c.Request.BasicAuth() user, rawPassword, hasAuth := c.Request.BasicAuth()
// Validate Auth Fields // Validate Auth Fields
if hasAuth != true || user == "" || rawPassword == "" { if !hasAuth || user == "" || rawPassword == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
// Validate Auth // Validate Auth
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(user, password) authData := api.authorizeCredentials(c, user, password)
if authData == nil { if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
@@ -120,7 +116,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session // Check Session
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(c, session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@@ -129,13 +125,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
c.Abort() c.Abort()
return
} }
func (api *API) authAdminWebAppMiddleware(c *gin.Context) { func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData) auth := data.(authData)
if auth.IsAdmin == true { if auth.IsAdmin {
c.Next() c.Next()
return return
} }
@@ -143,7 +138,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort() c.Abort()
return
} }
func (api *API) appAuthLogin(c *gin.Context) { func (api *API) appAuthLogin(c *gin.Context) {
@@ -160,7 +154,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity // MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(username, password) authData := api.authorizeCredentials(c, username, password)
if authData == nil { if authData == nil {
templateVars["Error"] = "Invalid Credentials" templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars) c.HTML(http.StatusUnauthorized, "page/login", templateVars)
@@ -205,7 +199,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
return return
} }
// Generate Auth Hash // Generate auth hash
rawAuthHash, err := utils.GenerateToken(64) rawAuthHash, err := utils.GenerateToken(64)
if err != nil { if err != nil {
log.Error("Failed to generate user token: ", err) log.Error("Failed to generate user token: ", err)
@@ -214,32 +208,42 @@ func (api *API) appAuthRegister(c *gin.Context) {
return return
} }
// Create User in DB // Get current users
authHash := fmt.Sprintf("%x", rawAuthHash) currentUsers, err := api.db.Queries.GetUsers(c)
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
})
// SQL Error
if err != nil { if err != nil {
log.Error("CreateUser DB Error:", err) log.Error("Failed to check all users: ", err)
templateVars["Error"] = "Registration Disabled or User Already Exists" templateVars["Error"] = "Failed to Create User"
c.HTML(http.StatusBadRequest, "page/login", templateVars) c.HTML(http.StatusBadRequest, "page/login", templateVars)
return return
} }
// User Already Exists // Determine if we should be admin
if rows == 0 { isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(c, 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) log.Warn("User Already Exists:", username)
templateVars["Error"] = "Registration Disabled or User Already Exists" templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars) c.HTML(http.StatusBadRequest, "page/login", templateVars)
return return
} }
// Get User // Get user
user, err := api.db.Queries.GetUser(api.db.Ctx, username) user, err := api.db.Queries.GetUser(c, username)
if err != nil { if err != nil {
log.Error("GetUser DB Error:", err) log.Error("GetUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists" templateVars["Error"] = "Registration Disabled or User Already Exists"
@@ -247,7 +251,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
return return
} }
// Set Session // Set session
auth := authData{ auth := authData{
UserName: user.ID, UserName: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
@@ -266,7 +270,10 @@ func (api *API) appAuthRegister(c *gin.Context) {
func (api *API) appAuthLogout(c *gin.Context) { func (api *API) appAuthLogout(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
session.Clear() session.Clear()
session.Save() if err := session.Save(); err != nil {
log.Error("unable to save session")
}
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
@@ -289,6 +296,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
return return
} }
// Generate password hash
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams) hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
if err != nil { if err != nil {
log.Error("Argon2 Hash Failure:", err) log.Error("Argon2 Hash Failure:", err)
@@ -296,7 +304,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
return return
} }
// Generate Auth Hash // Generate auth hash
rawAuthHash, err := utils.GenerateToken(64) rawAuthHash, err := utils.GenerateToken(64)
if err != nil { if err != nil {
log.Error("Failed to generate user token: ", err) log.Error("Failed to generate user token: ", err)
@@ -304,20 +312,32 @@ func (api *API) koAuthRegister(c *gin.Context) {
return return
} }
authHash := fmt.Sprintf("%x", rawAuthHash) // Get current users
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ currentUsers, err := api.db.Queries.GetUsers(c)
ID: rUser.Username,
Pass: &hashedPassword,
AuthHash: &authHash,
})
if err != nil { if err != nil {
log.Error("CreateUser DB Error:", err) log.Error("Failed to check all users: ", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data") apiErrorPage(c, http.StatusBadRequest, "Failed to Create User")
return return
} }
// User Exists // Determine if we should be admin
if rows == 0 { isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(c, 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) log.Error("User Already Exists:", rUser.Username)
apiErrorPage(c, http.StatusBadRequest, "User Already Exists") apiErrorPage(c, http.StatusBadRequest, "User Already Exists")
return return
@@ -328,7 +348,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
}) })
} }
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) { func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
// Get Session // Get Session
authorizedUser := session.Get("authorizedUser") authorizedUser := session.Get("authorizedUser")
isAdmin := session.Get("isAdmin") isAdmin := session.Get("isAdmin")
@@ -346,7 +366,7 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
} }
// Validate Auth Hash // Validate Auth Hash
correctAuthHash, err := api.getUserAuthHash(auth.UserName) correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash { if err != nil || correctAuthHash != auth.AuthHash {
return return
} }
@@ -354,7 +374,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Refresh // Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session") log.Info("Refreshing Session")
api.setSession(session, auth) if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
} }
// Authorized // Authorized
@@ -371,14 +394,14 @@ func (api *API) setSession(session sessions.Session, auth authData) error {
return session.Save() return session.Save()
} }
func (api *API) getUserAuthHash(username string) (string, error) { func (api *API) getUserAuthHash(ctx context.Context, username string) (string, error) {
// Return Cache // Return Cache
if api.userAuthCache[username] != "" { if api.userAuthCache[username] != "" {
return api.userAuthCache[username], nil return api.userAuthCache[username], nil
} }
// Get DB // Get DB
user, err := api.db.Queries.GetUser(api.db.Ctx, username) user, err := api.db.Queries.GetUser(ctx, username)
if err != nil { if err != nil {
log.Error("GetUser DB Error:", err) log.Error("GetUser DB Error:", err)
return "", err return "", err
@@ -390,31 +413,7 @@ func (api *API) getUserAuthHash(username string) (string, error) {
return api.userAuthCache[username], nil return api.userAuthCache[username], nil
} }
func (api *API) rotateUserAuthHash(username string) error { func (api *API) rotateAllAuthHashes(ctx context.Context) 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 // Do Transaction
tx, err := api.db.DB.Begin() tx, err := api.db.DB.Begin()
if err != nil { if err != nil {
@@ -423,15 +422,20 @@ func (api *API) rotateAllAuthHashes() error {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(api.db.Ctx) users, err := qtx.GetUsers(ctx)
if err != nil { if err != nil {
return err return err
} }
// Update users // Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users { for _, user := range users {
// Generate Auth Hash // Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64) rawAuthHash, err := utils.GenerateToken(64)
@@ -441,15 +445,16 @@ func (api *API) rotateAllAuthHashes() error {
// Update User // Update User
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
if _, err = qtx.UpdateUser(api.db.Ctx, database.UpdateUserParams{ if _, err = qtx.UpdateUser(ctx, database.UpdateUserParams{
UserID: user.ID, UserID: user.ID,
AuthHash: &authHash, AuthHash: &authHash,
Admin: user.Admin,
}); err != nil { }); err != nil {
return err return err
} }
// Update Cache // Save New Hash Cache
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
} }
// Commit Transaction // Commit Transaction
@@ -458,5 +463,10 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
// Transaction Succeeded -> Update Cache
for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
}
return nil return nil
} }

View File

@@ -2,11 +2,12 @@ package api
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
@@ -21,7 +22,7 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
} }
// Get Document // Get Document
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID) document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("GetDocument DB Error:", err) log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusBadRequest, "Unknown Document") errorFunc(c, http.StatusBadRequest, "Unknown Document")
@@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
return return
} }
// Derive Basepath
basepath := filepath.Join(api.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location // Derive Storage Location
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath) filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists // Validate File Exists
_, err = os.Stat(filePath) _, err = os.Stat(filePath)
@@ -61,7 +68,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
} }
// 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(c, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("GetDocument DB Error:", err) log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) errorFunc(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
@@ -110,7 +117,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
} }
// Store First Metadata Result // Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{ if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: document.ID, DocumentID: document.ID,
Title: firstResult.Title, Title: firstResult.Title,
Author: firstResult.Author, Author: firstResult.Author,
@@ -125,7 +132,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
} }
// Upsert Document // Upsert Document
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: document.ID, ID: document.ID,
Coverfile: &coverFile, Coverfile: &coverFile,
}); err != nil { }); err != nil {

View File

@@ -72,7 +72,7 @@ type requestDocumentID struct {
} }
func (api *API) koAuthorizeUser(c *gin.Context) { func (api *API) koAuthorizeUser(c *gin.Context) {
c.JSON(200, gin.H{ koJSON(c, 200, gin.H{
"authorized": "OK", "authorized": "OK",
}) })
} }
@@ -91,7 +91,7 @@ func (api *API) koSetProgress(c *gin.Context) {
} }
// Upsert Device // Upsert Device
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rPosition.DeviceID, ID: rPosition.DeviceID,
UserID: auth.UserName, UserID: auth.UserName,
DeviceName: rPosition.Device, DeviceName: rPosition.Device,
@@ -101,14 +101,14 @@ func (api *API) koSetProgress(c *gin.Context) {
} }
// Upsert Document // Upsert Document
if _, err := api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err := api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: rPosition.DocumentID, ID: rPosition.DocumentID,
}); err != nil { }); err != nil {
log.Error("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(c, database.UpdateProgressParams{
Percentage: rPosition.Percentage, Percentage: rPosition.Percentage,
DocumentID: rPosition.DocumentID, DocumentID: rPosition.DocumentID,
DeviceID: rPosition.DeviceID, DeviceID: rPosition.DeviceID,
@@ -121,7 +121,7 @@ func (api *API) koSetProgress(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"document": progress.DocumentID, "document": progress.DocumentID,
"timestamp": progress.CreatedAt, "timestamp": progress.CreatedAt,
}) })
@@ -140,14 +140,14 @@ func (api *API) koGetProgress(c *gin.Context) {
return return
} }
progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{ progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{
DocumentID: rDocID.DocumentID, DocumentID: rDocID.DocumentID,
UserID: auth.UserName, UserID: auth.UserName,
}) })
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// Not Found // Not Found
c.JSON(http.StatusOK, gin.H{}) koJSON(c, http.StatusOK, gin.H{})
return return
} else if err != nil { } else if err != nil {
log.Error("GetDocumentProgress DB Error:", err) log.Error("GetDocumentProgress DB Error:", err)
@@ -155,7 +155,7 @@ func (api *API) koGetProgress(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"document": progress.DocumentID, "document": progress.DocumentID,
"percentage": progress.Percentage, "percentage": progress.Percentage,
"progress": progress.Progress, "progress": progress.Progress,
@@ -193,12 +193,16 @@ func (api *API) koAddActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap) allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
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(c, database.UpsertDocumentParams{
ID: doc, ID: doc,
}); err != nil { }); err != nil {
log.Error("UpsertDocument DB Error:", err) log.Error("UpsertDocument DB Error:", err)
@@ -208,7 +212,7 @@ func (api *API) koAddActivities(c *gin.Context) {
} }
// Upsert Device // Upsert Device
if _, err = qtx.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ if _, err = qtx.UpsertDevice(c, database.UpsertDeviceParams{
ID: rActivity.DeviceID, ID: rActivity.DeviceID,
UserID: auth.UserName, UserID: auth.UserName,
DeviceName: rActivity.Device, DeviceName: rActivity.Device,
@@ -221,7 +225,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Add All Activity // Add All Activity
for _, item := range rActivity.Activity { for _, item := range rActivity.Activity {
if _, err := qtx.AddActivity(api.db.Ctx, database.AddActivityParams{ if _, err := qtx.AddActivity(c, database.AddActivityParams{
UserID: auth.UserName, UserID: auth.UserName,
DocumentID: item.DocumentID, DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID, DeviceID: rActivity.DeviceID,
@@ -243,7 +247,7 @@ func (api *API) koAddActivities(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"added": len(rActivity.Activity), "added": len(rActivity.Activity),
}) })
} }
@@ -262,7 +266,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
} }
// Upsert Device // Upsert Device
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rCheckActivity.DeviceID, ID: rCheckActivity.DeviceID,
UserID: auth.UserName, UserID: auth.UserName,
DeviceName: rCheckActivity.Device, DeviceName: rCheckActivity.Device,
@@ -274,7 +278,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
} }
// 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(c, database.GetLastActivityParams{
UserID: auth.UserName, UserID: auth.UserName,
DeviceID: rCheckActivity.DeviceID, DeviceID: rCheckActivity.DeviceID,
}) })
@@ -294,7 +298,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"last_sync": parsedTime.Unix(), "last_sync": parsedTime.Unix(),
}) })
} }
@@ -316,12 +320,16 @@ func (api *API) koAddDocuments(c *gin.Context) {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
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(c, 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),
@@ -344,7 +352,7 @@ func (api *API) koAddDocuments(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"changed": len(rNewDocs.Documents), "changed": len(rNewDocs.Documents),
}) })
} }
@@ -363,7 +371,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
} }
// Upsert Device // Upsert Device
_, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID, ID: rCheckDocs.DeviceID,
UserID: auth.UserName, UserID: auth.UserName,
DeviceName: rCheckDocs.Device, DeviceName: rCheckDocs.Device,
@@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return return
} }
missingDocs := []database.Document{}
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(c, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetMissingDocuments DB Error", err) log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
} }
// Get Deleted Documents // Get Deleted Documents
deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(c, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetDeletedDocuments DB Error", err) log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -402,7 +407,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return return
} }
wantedDocs, err := api.db.Queries.GetWantedDocuments(api.db.Ctx, string(jsonHaves)) wantedDocs, err := api.db.Queries.GetWantedDocuments(c, string(jsonHaves))
if err != nil { if err != nil {
log.Error("GetWantedDocuments DB Error", err) log.Error("GetWantedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -442,7 +447,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
rCheckDocSync.Delete = deletedDocIDs rCheckDocSync.Delete = deletedDocIDs
} }
c.JSON(http.StatusOK, rCheckDocSync) koJSON(c, http.StatusOK, rCheckDocSync)
} }
func (api *API) koUploadExistingDocument(c *gin.Context) { func (api *API) koUploadExistingDocument(c *gin.Context) {
@@ -462,7 +467,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
} }
// 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(c, rDoc.DocumentID)
if err != nil { if err != nil {
log.Error("GetDocument DB Error:", err) log.Error("GetDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Document") apiErrorPage(c, http.StatusBadRequest, "Unknown Document")
@@ -494,7 +499,8 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
}) })
// Generate Storage Path // Generate Storage Path
safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save & Prevent Overwrites // Save & Prevent Overwrites
_, err = os.Stat(safePath) _, err = os.Stat(safePath)
@@ -516,18 +522,19 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
} }
// Upsert Document // Upsert Document
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: document.ID, ID: document.ID,
Md5: metadataInfo.MD5, Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount, Words: metadataInfo.WordCount,
Filepath: &fileName, Filepath: &fileName,
Basepath: &basePath,
}); err != nil { }); err != nil {
log.Error("UpsertDocument DB Error:", err) log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Document Error") apiErrorPage(c, http.StatusBadRequest, "Document Error")
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"status": "ok", "status": "ok",
}) })
} }
@@ -582,3 +589,10 @@ func getFileMD5(filePath string) (*string, error) {
return &fileHash, nil return &fileHash, nil
} }
// koJSON forces koJSON Content-Type to only return `application/json`. This is addressing
// the following issue: https://github.com/koreader/koreader/issues/13629
func koJSON(c *gin.Context, code int, obj any) {
c.Header("Content-Type", "application/json")
c.JSON(code, obj)
}

View File

@@ -10,6 +10,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/opds" "reichard.io/antholume/opds"
"reichard.io/antholume/pkg/ptr"
) )
var mimeMapping map[string]string = map[string]string{ var mimeMapping map[string]string = map[string]string{
@@ -77,9 +78,10 @@ func (api *API) opdsDocuments(c *gin.Context) {
} }
// Get Documents // Get Documents
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{ documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName, UserID: auth.UserName,
Query: query, Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })

View File

@@ -13,56 +13,49 @@ import (
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
type UTCOffset struct { // getTimeZones returns a string slice of IANA timezones.
Name string func getTimeZones() []string {
Value string return []string{
} "Africa/Cairo",
"Africa/Johannesburg",
var UTC_OFFSETS = []UTCOffset{ "Africa/Lagos",
{Value: "-12 hours", Name: "UTC12:00"}, "Africa/Nairobi",
{Value: "-11 hours", Name: "UTC11:00"}, "America/Adak",
{Value: "-10 hours", Name: "UTC10:00"}, "America/Anchorage",
{Value: "-9.5 hours", Name: "UTC09:30"}, "America/Buenos_Aires",
{Value: "-9 hours", Name: "UTC09:00"}, "America/Chicago",
{Value: "-8 hours", Name: "UTC08:00"}, "America/Denver",
{Value: "-7 hours", Name: "UTC07:00"}, "America/Los_Angeles",
{Value: "-6 hours", Name: "UTC06:00"}, "America/Mexico_City",
{Value: "-5 hours", Name: "UTC05:00"}, "America/New_York",
{Value: "-4 hours", Name: "UTC04:00"}, "America/Nuuk",
{Value: "-3.5 hours", Name: "UTC03:30"}, "America/Phoenix",
{Value: "-3 hours", Name: "UTC03:00"}, "America/Puerto_Rico",
{Value: "-2 hours", Name: "UTC02:00"}, "America/Sao_Paulo",
{Value: "-1 hours", Name: "UTC01:00"}, "America/St_Johns",
{Value: "0 hours", Name: "UTC±00:00"}, "America/Toronto",
{Value: "+1 hours", Name: "UTC+01:00"}, "Asia/Dubai",
{Value: "+2 hours", Name: "UTC+02:00"}, "Asia/Hong_Kong",
{Value: "+3 hours", Name: "UTC+03:00"}, "Asia/Kolkata",
{Value: "+3.5 hours", Name: "UTC+03:30"}, "Asia/Seoul",
{Value: "+4 hours", Name: "UTC+04:00"}, "Asia/Shanghai",
{Value: "+4.5 hours", Name: "UTC+04:30"}, "Asia/Singapore",
{Value: "+5 hours", Name: "UTC+05:00"}, "Asia/Tokyo",
{Value: "+5.5 hours", Name: "UTC+05:30"}, "Atlantic/Azores",
{Value: "+5.75 hours", Name: "UTC+05:45"}, "Australia/Melbourne",
{Value: "+6 hours", Name: "UTC+06:00"}, "Australia/Sydney",
{Value: "+6.5 hours", Name: "UTC+06:30"}, "Europe/Berlin",
{Value: "+7 hours", Name: "UTC+07:00"}, "Europe/London",
{Value: "+8 hours", Name: "UTC+08:00"}, "Europe/Moscow",
{Value: "+8.75 hours", Name: "UTC+08:45"}, "Europe/Paris",
{Value: "+9 hours", Name: "UTC+09:00"}, "Pacific/Auckland",
{Value: "+9.5 hours", Name: "UTC+09:30"}, "Pacific/Honolulu",
{Value: "+10 hours", Name: "UTC+10:00"}, }
{Value: "+10.5 hours", Name: "UTC+10:30"},
{Value: "+11 hours", Name: "UTC+11:00"},
{Value: "+12 hours", Name: "UTC+12:00"},
{Value: "+12.75 hours", Name: "UTC+12:45"},
{Value: "+13 hours", Name: "UTC+13:00"},
{Value: "+14 hours", Name: "UTC+14:00"},
}
func getUTCOffsets() []UTCOffset {
return UTC_OFFSETS
} }
// niceSeconds takes in an int (in seconds) and returns a string readable
// representation. For example 1928371 -> "22d 7h 39m 31s".
// Deprecated: Use formatters.FormatDuration
func niceSeconds(input int64) (result string) { func niceSeconds(input int64) (result string) {
if input == 0 { if input == 0 {
return "N/A" return "N/A"
@@ -91,6 +84,9 @@ func niceSeconds(input int64) (result string) {
return return
} }
// niceNumbers takes in an int and returns a string representation. For example
// 19823 -> "19.8k".
// Deprecated: Use formatters.FormatNumber
func niceNumbers(input int64) string { func niceNumbers(input int64) string {
if input == 0 { if input == 0 {
return "0" return "0"
@@ -109,7 +105,8 @@ func niceNumbers(input int64) string {
} }
} }
// Convert Database Array -> Int64 Array // getSVGGraphData builds SVGGraphData from the provided stats, width and height.
// It is used exclusively in templates to generate the daily read stats graph.
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
for _, item := range inputData { for _, item := range inputData {
@@ -119,11 +116,13 @@ 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) { // dict returns a map[string]any dict. Each pair of two is a key & value
// respectively. It's primarily utilized in templates.
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 { if len(values)%2 != 0 {
return nil, errors.New("invalid dict call") return nil, errors.New("invalid dict call")
} }
dict := make(map[string]interface{}, len(values)/2) dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 { for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string) key, ok := values[i].(string)
if !ok { if !ok {
@@ -134,12 +133,14 @@ func dict(values ...interface{}) (map[string]interface{}, error) {
return dict, nil return dict, nil
} }
func fields(value interface{}) (map[string]interface{}, error) { // fields returns a map[string]any of the provided struct. It's primarily
// utilized in templates.
func fields(value any) (map[string]any, error) {
v := reflect.Indirect(reflect.ValueOf(value)) v := reflect.Indirect(reflect.ValueOf(value))
if v.Kind() != reflect.Struct { if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("%T is not a struct", value) return nil, fmt.Errorf("%T is not a struct", value)
} }
m := make(map[string]interface{}) m := make(map[string]any)
t := v.Type() t := v.Type()
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {
sv := t.Field(i) sv := t.Field(i)
@@ -148,6 +149,13 @@ func fields(value interface{}) (map[string]interface{}, error) {
return m, nil return m, nil
} }
// slice returns a slice of the provided arguments. It's primarily utilized in
// templates.
func slice(elements ...any) []any {
return elements
}
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName // Derive New FileName
var newFileName string var newFileName string
@@ -166,3 +174,15 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
fileName := strings.ReplaceAll(newFileName, "/", "") fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
} }
// importStatusPriority returns the order priority for import status in the UI.
func importStatusPriority(status importStatus) int {
switch status {
case importFailed:
return 1
case importExists:
return 2
default:
return 3
}
}

View File

@@ -82,7 +82,8 @@
id="top-bar" id="top-bar"
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2" class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
> >
<div class="w-full h-32 flex items-center justify-around relative"> <div class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white">
<div class="h-32">
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4"> <div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
<a href="#"> <a href="#">
<svg <svg
@@ -152,6 +153,8 @@
</div> </div>
</div> </div>
</div> </div>
<div id="toc" class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"></div>
</div>
</div> </div>
<div <div

View File

@@ -66,6 +66,56 @@ function populateMetadata(data) {
authorEl.innerText = data.author; authorEl.innerText = data.author;
} }
/**
* Populate the Table of Contents
**/
function populateTOC() {
if (!currentReader.book.navigation.toc) {
console.warn("[populateTOC] No TOC");
return;
}
let tocEl = document.querySelector("#toc");
if (!tocEl) {
console.warn("[populateTOC] No TOC Element");
return;
}
// Parse the Table of Contents
let parsedTOC = currentReader.book.navigation.toc.reduce((agg, item) => {
let sectionTitle = item.label.trim();
agg.push({ title: sectionTitle, href: item.href });
if (item.subitems.length == 0) {
return agg;
}
let allSubSections = item.subitems.map(item => {
let itemTitle = item.label.trim();
if (sectionTitle != "") {
itemTitle = sectionTitle + " - " + item.label.trim();
}
return { title: itemTitle, href: item.href };
});
agg.push(...allSubSections);
return agg;
}, [])
// Add Table of Contents to DOM
let listEl = document.createElement("ul");
listEl.classList.add("m-4")
parsedTOC.forEach(item => {
let listItem = document.createElement("li");
listItem.style.cursor = "pointer";
listItem.addEventListener("click", () => {
currentReader.rendition.display(item.href);
});
listItem.textContent = item.title;
listEl.appendChild(listItem);
});
tocEl.appendChild(listEl);
}
/** /**
* This is the main reader class. All functionality is wrapped in this class. * This is the main reader class. All functionality is wrapped in this class.
* Responsible for handling gesture / clicks, flushing progress & activity, * Responsible for handling gesture / clicks, flushing progress & activity,
@@ -439,6 +489,7 @@ class EBookReader {
// ------------------------------------------------ // // ------------------------------------------------ //
// ----------------- Swipe Helpers ---------------- // // ----------------- Swipe Helpers ---------------- //
// ------------------------------------------------ // // ------------------------------------------------ //
let disablePagination = false;
let touchStartX, let touchStartX,
touchStartY, touchStartY,
touchEndX, touchEndX,
@@ -459,25 +510,38 @@ class EBookReader {
} }
// Swipe Left // Swipe Left
if (touchEndX + drasticity < touchStartX) { if (!disablePagination && touchEndX + drasticity < touchStartX) {
nextPage(); nextPage();
} }
// Swipe Right // Swipe Right
if (touchEndX - drasticity > touchStartX) { if (!disablePagination && touchEndX - drasticity > touchStartX) {
prevPage(); prevPage();
} }
} }
function handleSwipeDown() { function handleSwipeDown() {
if (bottomBar.classList.contains("bottom-0")) if (bottomBar.classList.contains("bottom-0")) {
bottomBar.classList.remove("bottom-0"); bottomBar.classList.remove("bottom-0");
else topBar.classList.add("top-0"); disablePagination = false;
} else {
topBar.classList.add("top-0");
populateTOC()
disablePagination = true;
}
} }
function handleSwipeUp() { function handleSwipeUp() {
if (topBar.classList.contains("top-0")) topBar.classList.remove("top-0"); if (topBar.classList.contains("top-0")) {
else bottomBar.classList.add("bottom-0"); topBar.classList.remove("top-0");
disablePagination = false;
const tocEl = document.querySelector("#toc");
if (tocEl) tocEl.innerHTML = "";
} else {
bottomBar.classList.add("bottom-0");
disablePagination = true;
}
} }
this.rendition.hooks.render.register(function (doc, data) { this.rendition.hooks.render.register(function (doc, data) {
@@ -523,8 +587,8 @@ class EBookReader {
// Handle Event // Handle Event
if (yCoord < top) handleSwipeDown(); if (yCoord < top) handleSwipeDown();
else if (yCoord > bottom) handleSwipeUp(); else if (yCoord > bottom) handleSwipeUp();
else if (xCoord < left) prevPage(); else if (!disablePagination && xCoord < left) prevPage();
else if (xCoord > right) nextPage(); else if (!disablePagination && xCoord > right) nextPage();
else { else {
bottomBar.classList.remove("bottom-0"); bottomBar.classList.remove("bottom-0");
topBar.classList.remove("top-0"); topBar.classList.remove("top-0");
@@ -670,6 +734,9 @@ class EBookReader {
// Close Top Bar // Close Top Bar
document.querySelector(".close-top-bar").addEventListener("click", () => { document.querySelector(".close-top-bar").addEventListener("click", () => {
topBar.classList.remove("top-0"); topBar.classList.remove("top-0");
const tocEl = document.querySelector("#toc");
if (tocEl) tocEl.innerHTML = "";
}); });
} }
@@ -949,10 +1016,16 @@ class EBookReader {
**/ **/
async getXPathFromCFI(cfi) { async getXPathFromCFI(cfi) {
// Get DocFragment (Spine Index) // Get DocFragment (Spine Index)
let startCFI = cfi.replace("epubcfi(", ""); let cfiBaseMatch = cfi.match(/\(([^!]+)/);
if (!cfiBaseMatch) {
console.error("[getXPathFromCFI] No CFI Match");
return {};
}
let startCFI = cfiBaseMatch[1];
let docFragmentIndex = let docFragmentIndex =
this.book.spine.spineItems.find((item) => this.book.spine.spineItems.find((item) =>
startCFI.startsWith(item.cfiBase), item.cfiBase == startCFI
).index + 1; ).index + 1;
// Base Progress // Base Progress
@@ -1029,10 +1102,6 @@ class EBookReader {
return {}; return {};
} }
// Match Item Index
let indexMatch = xpath.match(/\.(\d+)$/);
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
// Get Spine Item // Get Spine Item
let spinePosition = parseInt(fragMatch[1]) - 1; let spinePosition = parseInt(fragMatch[1]) - 1;
let sectionItem = this.book.spine.get(spinePosition); let sectionItem = this.book.spine.get(spinePosition);
@@ -1124,6 +1193,11 @@ class EBookReader {
let element = docSearch.iterateNext() || derivedSelectorElement; let element = docSearch.iterateNext() || derivedSelectorElement;
let cfi = sectionItem.cfiFromElement(element); let cfi = sectionItem.cfiFromElement(element);
// Hack - epub.js crashes sometimes when its a bare section with no element
// so just return the first.
if (cfi.endsWith("!/)"))
cfi = cfi.slice(0, -1) + "0)"
return { cfi, element }; return { cfi, element };
} }
@@ -1271,14 +1345,3 @@ class EBookReader {
} }
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

View File

@@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [
// ----------------------- Helpers ----------------------- // // ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- // // ------------------------------------------------------- //
function purgeCache() { async function purgeCache() {
console.log("[purgeCache] Purging Cache"); console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) { return caches.keys().then(function (names) {
for (let name of names) caches.delete(name); for (let name of names) caches.delete(name);
@@ -136,7 +136,7 @@ async function handleFetch(event) {
const directive = ROUTES.find( const directive = ROUTES.find(
(item) => (item) =>
(item.route instanceof RegExp && url.match(item.route)) || (item.route instanceof RegExp && url.match(item.route)) ||
url == item.route url == item.route,
) || { type: CACHE_NEVER }; ) || { type: CACHE_NEVER };
// Get Fallback // Get Fallback
@@ -161,11 +161,11 @@ async function handleFetch(event) {
); );
case CACHE_UPDATE_SYNC: case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch( return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event) (e) => currentCache || fallbackFunc(event),
); );
case CACHE_UPDATE_ASYNC: case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) => let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event) fallbackFunc(event),
); );
return currentCache || newResponse; return currentCache || newResponse;
@@ -192,7 +192,7 @@ function handleMessage(event) {
.filter( .filter(
(item) => (item) =>
item.startsWith("/documents/") || item.startsWith("/documents/") ||
item.startsWith("/reader/progress/") item.startsWith("/reader/progress/"),
); );
// Derive Unique IDs // Derive Unique IDs
@@ -200,8 +200,8 @@ function handleMessage(event) {
new Set( new Set(
docResources docResources
.filter((item) => item.startsWith("/documents/")) .filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2]) .map((item) => item.split("/")[2]),
) ),
); );
/** /**
@@ -214,14 +214,14 @@ function handleMessage(event) {
.filter( .filter(
(id) => (id) =>
docResources.includes("/documents/" + id + "/file") && docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/reader/progress/" + id) docResources.includes("/reader/progress/" + id),
) )
.map(async (id) => { .map(async (id) => {
let url = "/reader/progress/" + id; 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();
}) }),
); );
event.source.postMessage({ id, data: cachedDocuments }); event.source.postMessage({ id, data: cachedDocuments });
@@ -233,7 +233,7 @@ function handleMessage(event) {
Promise.all([ Promise.all([
cache.delete("/documents/" + data.id + "/file"), cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id), cache.delete("/reader/progress/" + data.id),
]) ]),
) )
.then(() => event.source.postMessage({ id, data: "SUCCESS" })) .then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" })); .catch(() => event.source.postMessage({ id, data: "FAILURE" }));

File diff suppressed because it is too large Load Diff

View File

@@ -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.29.0
package database package database

View File

@@ -0,0 +1,151 @@
WITH grouped_activity AS (
SELECT
ga.user_id,
ga.document_id,
MAX(ga.created_at) AS created_at,
MAX(ga.start_time) AS start_time,
MIN(ga.start_percentage) AS start_percentage,
MAX(ga.end_percentage) AS end_percentage,
-- Total Duration & Percentage
SUM(ga.duration) AS total_time_seconds,
SUM(ga.end_percentage - ga.start_percentage) AS total_read_percentage,
-- Yearly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 year')
THEN ga.duration
ELSE 0
END
)
AS yearly_time_seconds,
-- Yearly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 year')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS yearly_read_percentage,
-- Monthly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 month')
THEN ga.duration
ELSE 0
END
)
AS monthly_time_seconds,
-- Monthly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 month')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS monthly_read_percentage,
-- Weekly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-7 days')
THEN ga.duration
ELSE 0
END
)
AS weekly_time_seconds,
-- Weekly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-7 days')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS weekly_read_percentage
FROM activity AS ga
GROUP BY ga.user_id, ga.document_id
),
current_progress AS (
SELECT
user_id,
document_id,
COALESCE((
SELECT dp.percentage
FROM document_progress AS dp
WHERE
dp.user_id = iga.user_id
AND dp.document_id = iga.document_id
ORDER BY dp.created_at DESC
LIMIT 1
), end_percentage) AS percentage
FROM grouped_activity AS iga
)
INSERT INTO document_user_statistics
SELECT
ga.document_id,
ga.user_id,
cp.percentage,
MAX(ga.start_time) AS last_read,
MAX(ga.created_at) AS last_seen,
SUM(ga.total_read_percentage) AS read_percentage,
-- All Time WPM
SUM(ga.total_time_seconds) AS total_time_seconds,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(ga.total_read_percentage))
AS total_words_read,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(ga.total_read_percentage))
/ (SUM(ga.total_time_seconds) / 60.0) AS total_wpm,
-- Yearly WPM
ga.yearly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.yearly_read_percentage
AS yearly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.yearly_read_percentage)
/ (ga.yearly_time_seconds / 60), 0.0)
AS yearly_wpm,
-- Monthly WPM
ga.monthly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.monthly_read_percentage
AS monthly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.monthly_read_percentage)
/ (ga.monthly_time_seconds / 60), 0.0)
AS monthly_wpm,
-- Weekly WPM
ga.weekly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.weekly_read_percentage
AS weekly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.weekly_read_percentage)
/ (ga.weekly_time_seconds / 60), 0.0)
AS weekly_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 ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC;

27
database/documents.go Normal file
View File

@@ -0,0 +1,27 @@
package database
import (
"context"
"fmt"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/sliceutils"
)
func (d *DBManager) GetDocument(ctx context.Context, docID, userID string) (*GetDocumentsWithStatsRow, error) {
documents, err := d.Queries.GetDocumentsWithStats(ctx, GetDocumentsWithStatsParams{
ID: ptr.Of(docID),
UserID: userID,
Limit: 1,
})
if err != nil {
return nil, err
}
document, found := sliceutils.First(documents)
if !found {
return nil, fmt.Errorf("document not found: %s", docID)
}
return &document, nil
}

115
database/documents_test.go Normal file
View File

@@ -0,0 +1,115 @@
package database
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
)
type DocumentsTestSuite struct {
suite.Suite
dbm *DBManager
}
func TestDocuments(t *testing.T) {
suite.Run(t, new(DocumentsTestSuite))
}
func (suite *DocumentsTestSuite) SetupTest() {
cfg := config.Config{
DBType: "memory",
}
suite.dbm = NewMgr(&cfg)
// Create Document
_, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
Words: &documentWords,
})
suite.NoError(err)
}
// DOCUMENT - TODO:
// - 󰊕 (q *Queries) GetDocumentProgress
// - 󰊕 (q *Queries) GetDocumentWithStats
// - 󰊕 (q *Queries) GetDocumentsSize
// - 󰊕 (q *Queries) GetDocumentsWithStats
// - 󰊕 (q *Queries) GetMissingDocuments
func (suite *DocumentsTestSuite) TestGetDocument() {
doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(documentID, doc.ID, "should have changed the document")
}
func (suite *DocumentsTestSuite) TestUpsertDocument() {
testDocID := "docid1"
doc, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: testDocID,
Title: &documentTitle,
Author: &documentAuthor,
})
suite.Nil(err, "should have nil err")
suite.Equal(testDocID, doc.ID, "should have document id")
suite.Equal(documentTitle, *doc.Title, "should have document title")
suite.Equal(documentAuthor, *doc.Author, "should have document author")
}
func (suite *DocumentsTestSuite) TestDeleteDocument() {
changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document")
doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.True(doc.Deleted, "should have deleted the document")
}
func (suite *DocumentsTestSuite) TestGetDeletedDocuments() {
changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document")
deletedDocs, err := suite.dbm.Queries.GetDeletedDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err")
suite.Len(deletedDocs, 1, "should have one deleted document")
}
// TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids'));
func (suite *DocumentsTestSuite) TestGetWantedDocuments() {
wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(context.Background(), fmt.Sprintf("[\"%s\"]", documentID))
suite.Nil(err, "should have nil err")
suite.Len(wantedDocs, 1, "should have one wanted document")
}
func (suite *DocumentsTestSuite) TestGetMissingDocuments() {
// Create Document
_, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Filepath: &documentFilepath,
})
suite.NoError(err)
missingDocs, err := suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 0, "should have no wanted document")
missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{"other"})
suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 1, "should have one missing document")
suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")
// TODO - https://github.com/sqlc-dev/sqlc/issues/3451
// missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{})
// suite.Nil(err, "should have nil err")
// suite.Len(missingDocs, 1, "should have one missing document")
// suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")
}

View File

@@ -3,22 +3,22 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"database/sql/driver"
"embed" "embed"
_ "embed" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"time" "time"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
_ "modernc.org/sqlite" sqlite "modernc.org/sqlite"
"reichard.io/antholume/config" "reichard.io/antholume/config"
_ "reichard.io/antholume/database/migrations" _ "reichard.io/antholume/database/migrations"
) )
type DBManager struct { type DBManager struct {
DB *sql.DB DB *sql.DB
Ctx context.Context
Queries *Queries Queries *Queries
cfg *config.Config cfg *config.Config
} }
@@ -26,26 +26,43 @@ type DBManager struct {
//go:embed schema.sql //go:embed schema.sql
var ddl string var ddl string
//go:embed user_streaks.sql
var user_streaks string
//go:embed document_user_statistics.sql
var document_user_statistics string
//go:embed migrations/* //go:embed migrations/*
var migrations embed.FS var migrations embed.FS
// Returns an initialized manager // Register scalar sqlite function on init
func init() {
sqlite.MustRegisterFunction("LOCAL_TIME", &sqlite.FunctionImpl{
NArgs: 2,
Deterministic: true,
Scalar: localTime,
})
sqlite.MustRegisterFunction("LOCAL_DATE", &sqlite.FunctionImpl{
NArgs: 2,
Deterministic: true,
Scalar: localDate,
})
}
// NewMgr Returns an initialized manager
func NewMgr(c *config.Config) *DBManager { func NewMgr(c *config.Config) *DBManager {
// Create Manager // Create Manager
dbm := &DBManager{ dbm := &DBManager{cfg: c}
Ctx: context.Background(),
cfg: c,
}
if err := dbm.init(); err != nil { if err := dbm.init(context.Background()); err != nil {
log.Panic("Unable to init DB") log.Panic("Unable to init DB")
} }
return dbm return dbm
} }
// Init manager // init loads the DB manager
func (dbm *DBManager) init() error { func (dbm *DBManager) init(ctx context.Context) error {
// Build DB Location // Build DB Location
var dbLocation string var dbLocation string
switch dbm.cfg.DBType { switch dbm.cfg.DBType {
@@ -91,20 +108,22 @@ func (dbm *DBManager) init() error {
} }
// Update settings // Update settings
err = dbm.updateSettings() err = dbm.updateSettings(ctx)
if err != nil { if err != nil {
log.Panicf("Error running DB settings update: %v", err) log.Panicf("Error running DB settings update: %v", err)
return err return err
} }
// Cache tables // Cache tables
go dbm.CacheTempTables() if err := dbm.CacheTempTables(ctx); err != nil {
log.Warn("Refreshing temp table cache failed: ", err)
}
return nil return nil
} }
// Reload manager (close DB & reinit) // Reload closes the DB & reinits
func (dbm *DBManager) Reload() error { func (dbm *DBManager) Reload(ctx context.Context) error {
// Close handle // Close handle
err := dbm.DB.Close() err := dbm.DB.Close()
if err != nil { if err != nil {
@@ -112,30 +131,23 @@ func (dbm *DBManager) Reload() error {
} }
// Reinit DB // Reinit DB
if err := dbm.init(); err != nil { if err := dbm.init(ctx); err != nil {
return err return err
} }
return nil return nil
} }
func (dbm *DBManager) CacheTempTables() error { // CacheTempTables clears existing statistics and recalculates
func (dbm *DBManager) CacheTempTables(ctx context.Context) error {
start := time.Now() start := time.Now()
user_streaks_sql := ` if _, err := dbm.DB.ExecContext(ctx, user_streaks); err != nil {
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)) log.Debug("Cached 'user_streaks' in: ", time.Since(start))
start = time.Now() start = time.Now()
document_statistics_sql := ` if _, err := dbm.DB.ExecContext(ctx, document_user_statistics); err != nil {
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 return err
} }
log.Debug("Cached 'document_user_statistics' in: ", time.Since(start)) log.Debug("Cached 'document_user_statistics' in: ", time.Since(start))
@@ -143,7 +155,9 @@ func (dbm *DBManager) CacheTempTables() error {
return nil return nil
} }
func (dbm *DBManager) updateSettings() error { // updateSettings ensures that we're enforcing foreign keys and enable journal
// mode.
func (dbm *DBManager) updateSettings(ctx context.Context) error {
// Set SQLite PRAGMA Settings // Set SQLite PRAGMA Settings
pragmaQuery := ` pragmaQuery := `
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
@@ -155,7 +169,7 @@ func (dbm *DBManager) updateSettings() error {
} }
// Update Antholume Version in DB // Update Antholume Version in DB
if _, err := dbm.Queries.UpdateSettings(dbm.Ctx, UpdateSettingsParams{ if _, err := dbm.Queries.UpdateSettings(ctx, UpdateSettingsParams{
Name: "version", Name: "version",
Value: dbm.cfg.Version, Value: dbm.cfg.Version,
}); err != nil { }); err != nil {
@@ -166,9 +180,10 @@ func (dbm *DBManager) updateSettings() error {
return nil return nil
} }
// performMigrations runs all migrations
func (dbm *DBManager) performMigrations(isNew bool) error { func (dbm *DBManager) performMigrations(isNew bool) error {
// Create context // Create context
ctx := context.WithValue(context.Background(), "isNew", isNew) ctx := context.WithValue(context.Background(), "isNew", isNew) // nolint
// Set DB migration // Set DB migration
goose.SetBaseFS(migrations) goose.SetBaseFS(migrations)
@@ -182,6 +197,7 @@ func (dbm *DBManager) performMigrations(isNew bool) error {
return goose.UpContext(ctx, dbm.DB, "migrations") return goose.UpContext(ctx, dbm.DB, "migrations")
} }
// isEmpty determines whether the database is empty
func isEmpty(db *sql.DB) (bool, error) { func isEmpty(db *sql.DB) (bool, error) {
var tableCount int var tableCount int
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table';").Scan(&tableCount) err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table';").Scan(&tableCount)
@@ -190,3 +206,53 @@ func isEmpty(db *sql.DB) (bool, error) {
} }
return tableCount == 0, nil return tableCount == 0, nil
} }
// localTime is a custom SQL function that is registered as LOCAL_TIME in the init function
func localTime(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
timeStr, ok := args[0].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZoneStr, ok := args[1].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZone, err := time.LoadLocation(timeZoneStr)
if err != nil {
return nil, errors.New("unable to parse timezone")
}
formattedTime, err := time.ParseInLocation(time.RFC3339, timeStr, time.UTC)
if err != nil {
return nil, errors.New("unable to parse time")
}
return formattedTime.In(timeZone).Format(time.RFC3339), nil
}
// localDate is a custom SQL function that is registered as LOCAL_DATE in the init function
func localDate(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
timeStr, ok := args[0].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZoneStr, ok := args[1].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZone, err := time.LoadLocation(timeZoneStr)
if err != nil {
return nil, errors.New("unable to parse timezone")
}
formattedTime, err := time.ParseInLocation(time.RFC3339, timeStr, time.UTC)
if err != nil {
return nil, errors.New("unable to parse time")
}
return formattedTime.In(timeZone).Format("2006-01-02"), nil
}

View File

@@ -1,102 +1,78 @@
package database package database
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite"
"reichard.io/antholume/config" "reichard.io/antholume/config"
"reichard.io/antholume/utils" "reichard.io/antholume/utils"
) )
type databaseTest struct { var (
*testing.T userID string = "testUser"
userPass string = "testPass"
deviceID string = "testDevice"
deviceName string = "testDeviceName"
documentID string = "testDocument"
documentTitle string = "testTitle"
documentAuthor string = "testAuthor"
documentFilepath string = "./testPath.epub"
documentWords int64 = 5000
)
type DatabaseTestSuite struct {
suite.Suite
dbm *DBManager dbm *DBManager
} }
var userID string = "testUser" func TestDatabase(t *testing.T) {
var userPass string = "testPass" suite.Run(t, new(DatabaseTestSuite))
var deviceID string = "testDevice" }
var deviceName string = "testDeviceName"
var documentID string = "testDocument"
var documentTitle string = "testTitle"
var documentAuthor string = "testAuthor"
func TestNewMgr(t *testing.T) { // PROGRESS - TODO:
// - 󰊕 (q *Queries) GetProgress
// - 󰊕 (q *Queries) UpdateProgress
func (suite *DatabaseTestSuite) SetupTest() {
cfg := config.Config{ cfg := config.Config{
DBType: "memory", DBType: "memory",
} }
dbm := NewMgr(&cfg) suite.dbm = NewMgr(&cfg)
assert.NotNil(t, dbm, "should not have nil dbm")
t.Run("Database", func(t *testing.T) {
dt := databaseTest{t, dbm}
dt.TestUser()
dt.TestDocument()
dt.TestDevice()
dt.TestActivity()
dt.TestDailyReadStats()
})
}
func (dt *databaseTest) TestUser() {
dt.Run("User", func(t *testing.T) {
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
assert.Nil(t, err, "should have nil err")
// Create User
rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{ _, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: userID, ID: userID,
Pass: &userPass, Pass: &userPass,
AuthHash: &authHash, AuthHash: &authHash,
}) })
suite.NoError(err)
assert.Nil(t, err, "should have nil err") // Create Document
assert.Equal(t, int64(1), changed) _, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID)
assert.Nil(t, err, "should have nil err")
assert.Equal(t, userPass, *user.Pass)
})
}
func (dt *databaseTest) TestDocument() {
dt.Run("Document", func(t *testing.T) {
doc, err := dt.dbm.Queries.UpsertDocument(dt.dbm.Ctx, UpsertDocumentParams{
ID: documentID, ID: documentID,
Title: &documentTitle, Title: &documentTitle,
Author: &documentAuthor, Author: &documentAuthor,
Filepath: &documentFilepath,
Words: &documentWords,
}) })
suite.NoError(err)
assert.Nil(t, err, "should have nil err") // Create Device
assert.Equal(t, documentID, doc.ID, "should have document id") _, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
assert.Equal(t, documentTitle, *doc.Title, "should have document title")
assert.Equal(t, documentAuthor, *doc.Author, "should have document author")
})
}
func (dt *databaseTest) TestDevice() {
dt.Run("Device", func(t *testing.T) {
device, err := dt.dbm.Queries.UpsertDevice(dt.dbm.Ctx, UpsertDeviceParams{
ID: deviceID, ID: deviceID,
UserID: userID, UserID: userID,
DeviceName: deviceName, DeviceName: deviceName,
}) })
suite.NoError(err)
assert.Nil(t, err, "should have nil err") // Create Activity
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")
})
}
func (dt *databaseTest) TestActivity() {
dt.Run("Progress", func(t *testing.T) {
// 10 Activities, 10 Days
end := time.Now() end := time.Now()
start := end.AddDate(0, 0, -9) start := end.AddDate(0, 0, -9)
var counter int64 = 0 var counter int64 = 0
@@ -105,7 +81,7 @@ func (dt *databaseTest) TestActivity() {
counter += 1 counter += 1
// Add Item // Add Item
activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{ activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID, DocumentID: documentID,
DeviceID: deviceID, DeviceID: deviceID,
UserID: userID, UserID: userID,
@@ -115,25 +91,50 @@ func (dt *databaseTest) TestActivity() {
EndPercentage: float64(counter+1) / 100.0, EndPercentage: float64(counter+1) / 100.0,
}) })
assert.Nil(t, err, fmt.Sprintf("[%d] should have nil err for add activity", counter)) suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
assert.Equal(t, counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter)) suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
} }
// Initiate Cache // Initiate Cache
dt.dbm.CacheTempTables() err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
}
// DEVICES - TODO:
// - 󰊕 (q *Queries) GetDevice
// - 󰊕 (q *Queries) GetDevices
// - 󰊕 (q *Queries) UpsertDevice
func (suite *DatabaseTestSuite) TestDevice() {
testDevice := "dev123"
device, err := suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: testDevice,
UserID: userID,
DeviceName: deviceName,
})
suite.Nil(err, "should have nil err")
suite.Equal(testDevice, device.ID, "should have device id")
suite.Equal(userID, device.UserID, "should have user id")
suite.Equal(deviceName, device.DeviceName, "should have device name")
}
// ACTIVITY - TODO:
// - 󰊕 (q *Queries) AddActivity
// - 󰊕 (q *Queries) GetActivity
// - 󰊕 (q *Queries) GetLastActivity
func (suite *DatabaseTestSuite) TestActivity() {
// Validate Exists // Validate Exists
existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ existsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID, UserID: userID,
Offset: 0, Offset: 0,
Limit: 50, Limit: 50,
}) })
assert.Nil(t, err, "should have nil err for get activity") suite.Nil(err, "should have nil err for get activity")
assert.Len(t, existsRows, 10, "should have correct number of rows get activity") suite.Len(existsRows, 10, "should have correct number of rows get activity")
// Validate Doesn't Exist // Validate Doesn't Exist
doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ doesntExistsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID, UserID: userID,
DocumentID: "unknownDoc", DocumentID: "unknownDoc",
DocFilter: true, DocFilter: true,
@@ -141,28 +142,30 @@ func (dt *databaseTest) TestActivity() {
Limit: 50, Limit: 50,
}) })
assert.Nil(t, err, "should have nil err for get activity") suite.Nil(err, "should have nil err for get activity")
assert.Len(t, doesntExistsRows, 0, "should have no rows") suite.Len(doesntExistsRows, 0, "should have no rows")
})
} }
func (dt *databaseTest) TestDailyReadStats() { // MISC - TODO:
dt.Run("DailyReadStats", func(t *testing.T) { // - 󰊕 (q *Queries) AddMetadata
readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID) // - 󰊕 (q *Queries) GetDailyReadStats
// - 󰊕 (q *Queries) GetDatabaseInfo
// - 󰊕 (q *Queries) UpdateSettings
func (suite *DatabaseTestSuite) TestGetDailyReadStats() {
readStats, err := suite.dbm.Queries.GetDailyReadStats(context.Background(), userID)
assert.Nil(t, err, "should have nil err") suite.Nil(err, "should have nil err")
assert.Len(t, readStats, 30, "should have length of 30") suite.Len(readStats, 30, "should have length of 30")
// 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]
assert.Equal(t, int64(1), stat.MinutesRead, "should have one minute read") suite.Equal(int64(1), stat.MinutesRead, "should have one minute read")
} }
// 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]
assert.Equal(t, int64(0), stat.MinutesRead, "should have zero minutes read") suite.Equal(int64(0), stat.MinutesRead, "should have zero minutes read")
} }
})
} }

View File

@@ -0,0 +1,58 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upUserTimezone, downUserTimezone)
}
func upUserTimezone(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(`
-- Copy Table
CREATE TABLE temp_users AS SELECT * FROM users;
ALTER TABLE temp_users DROP COLUMN time_offset;
ALTER TABLE temp_users ADD COLUMN timezone TEXT;
UPDATE temp_users SET timezone = 'Europe/London';
-- Clean Table
DELETE FROM users;
ALTER TABLE users DROP COLUMN time_offset;
ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'Europe/London';
-- Copy Temp Table -> Clean Table
INSERT INTO users SELECT * FROM temp_users;
-- Drop Temp Table
DROP TABLE temp_users;
`)
if err != nil {
return err
}
return nil
}
func downUserTimezone(ctx context.Context, tx *sql.Tx) error {
// Update column name & value
_, err := tx.Exec(`
ALTER TABLE users RENAME COLUMN timezone TO time_offset;
UPDATE users SET time_offset = '0 hours';
`)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,38 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upImportBasepath, downImportBasepath)
}
func upImportBasepath(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
}
// Add basepath column
_, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`)
if err != nil {
return err
}
// This code is executed when the migration is applied.
return nil
}
func downImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Drop basepath column
_, err := tx.Exec("ALTER documents DROP COLUMN basepath;")
if err != nil {
return err
}
return nil
}

View File

@@ -1,13 +1,9 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.21.0 // sqlc v1.29.0
package database package database
import (
"database/sql"
)
type Activity struct { type Activity struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -32,6 +28,7 @@ type Device struct {
type Document struct { type Document struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`
@@ -65,6 +62,7 @@ type DocumentUserStatistic struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
LastRead string `json:"last_read"` LastRead string `json:"last_read"`
LastSeen string `json:"last_seen"`
ReadPercentage float64 `json:"read_percentage"` ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"` TotalTimeSeconds int64 `json:"total_time_seconds"`
TotalWordsRead int64 `json:"total_words_read"` TotalWordsRead int64 `json:"total_words_read"`
@@ -80,7 +78,7 @@ type DocumentUserStatistic struct {
WeeklyWpm float64 `json:"weekly_wpm"` WeeklyWpm float64 `json:"weekly_wpm"`
} }
type Metadatum struct { type Metadata struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
Title *string `json:"title"` Title *string `json:"title"`
@@ -105,7 +103,7 @@ type User struct {
Pass *string `json:"-"` Pass *string `json:"-"`
AuthHash *string `json:"auth_hash"` AuthHash *string `json:"auth_hash"`
Admin bool `json:"-"` Admin bool `json:"-"`
TimeOffset *string `json:"time_offset"` Timezone *string `json:"timezone"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -118,35 +116,8 @@ type UserStreak struct {
CurrentStreak int64 `json:"current_streak"` CurrentStreak int64 `json:"current_streak"`
CurrentStreakStartDate string `json:"current_streak_start_date"` CurrentStreakStartDate string `json:"current_streak_start_date"`
CurrentStreakEndDate string `json:"current_streak_end_date"` CurrentStreakEndDate string `json:"current_streak_end_date"`
} LastTimezone string `json:"last_timezone"`
LastSeen string `json:"last_seen"`
type ViewDocumentUserStatistic struct { LastRecord string `json:"last_record"`
DocumentID string `json:"document_id"` LastCalculated string `json:"last_calculated"`
UserID string `json:"user_id"`
Percentage float64 `json:"percentage"`
LastRead interface{} `json:"last_read"`
ReadPercentage sql.NullFloat64 `json:"read_percentage"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
TotalWordsRead interface{} `json:"total_words_read"`
TotalWpm int64 `json:"total_wpm"`
YearlyTimeSeconds sql.NullFloat64 `json:"yearly_time_seconds"`
YearlyWordsRead interface{} `json:"yearly_words_read"`
YearlyWpm interface{} `json:"yearly_wpm"`
MonthlyTimeSeconds sql.NullFloat64 `json:"monthly_time_seconds"`
MonthlyWordsRead interface{} `json:"monthly_words_read"`
MonthlyWpm interface{} `json:"monthly_wpm"`
WeeklyTimeSeconds sql.NullFloat64 `json:"weekly_time_seconds"`
WeeklyWordsRead interface{} `json:"weekly_words_read"`
WeeklyWpm interface{} `json:"weekly_wpm"`
}
type ViewUserStreak struct {
UserID string `json:"user_id"`
Window string `json:"window"`
MaxStreak interface{} `json:"max_streak"`
MaxStreakStartDate interface{} `json:"max_streak_start_date"`
MaxStreakEndDate interface{} `json:"max_streak_end_date"`
CurrentStreak interface{} `json:"current_streak"`
CurrentStreakStartDate interface{} `json:"current_streak_start_date"`
CurrentStreakEndDate interface{} `json:"current_streak_end_date"`
} }

View File

@@ -26,10 +26,13 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
-- name: CreateUser :execrows -- name: CreateUser :execrows
INSERT INTO users (id, pass, auth_hash) INSERT INTO users (id, pass, auth_hash, admin)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: DeleteUser :execrows
DELETE FROM users WHERE id = $id;
-- name: DeleteDocument :execrows -- name: DeleteDocument :execrows
UPDATE documents UPDATE documents
SET SET
@@ -64,7 +67,7 @@ WITH filtered_activity AS (
SELECT SELECT
document_id, document_id,
device_id, device_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time, LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
title, title,
author, author,
duration, duration,
@@ -77,7 +80,7 @@ LEFT JOIN users ON users.id = activity.user_id;
-- name: GetDailyReadStats :many -- name: GetDailyReadStats :many
WITH RECURSIVE last_30_days AS ( WITH RECURSIVE last_30_days AS (
SELECT DATE('now', time_offset) AS date SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date
FROM users WHERE users.id = $user_id FROM users WHERE users.id = $user_id
UNION ALL UNION ALL
SELECT DATE(date, '-1 days') SELECT DATE(date, '-1 days')
@@ -96,7 +99,7 @@ filtered_activity AS (
activity_days AS ( activity_days AS (
SELECT SELECT
SUM(duration) AS seconds_read, SUM(duration) AS seconds_read,
DATE(start_time, time_offset) AS day LOCAL_DATE(start_time, timezone) AS day
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
@@ -135,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
SELECT SELECT
devices.id, 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, LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
FROM devices FROM devices
JOIN users ON users.id = devices.user_id JOIN users ON users.id = devices.user_id
WHERE users.id = $user_id WHERE users.id = $user_id
@@ -160,42 +163,6 @@ ORDER BY
DESC DESC
LIMIT 1; LIMIT 1;
-- name: GetDocumentWithStats :one
SELECT
docs.id,
docs.title,
docs.author,
docs.description,
docs.isbn10,
docs.isbn13,
docs.filepath,
docs.words,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read,
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE users.id = $user_id
AND docs.id = $document_id
LIMIT 1;
-- name: GetDocuments :many -- name: GetDocuments :many
SELECT * FROM documents SELECT * FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -226,33 +193,32 @@ SELECT
CAST(COALESCE(dus.total_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', LOCAL_TIME(COALESCE(dus.last_read, STRFTIME('%Y-%m-%dT%H:%M:%SZ', 0, 'unixepoch')), users.timezone))
AS last_read, AS last_read,
ROUND(CAST(CASE ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0 WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0 ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage, END AS REAL), 2) AS percentage,
CAST(CASE
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
ROUND(
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0) / (dus.read_percentage * 100.0)
) END AS INTEGER) AS seconds_per_percent
END AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = $user_id LEFT JOIN users ON users.id = $user_id
LEFT JOIN LEFT JOIN
document_user_statistics AS dus document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE WHERE
docs.deleted = false AND ( (docs.id = sqlc.narg('id') OR $id IS NULL)
$query IS NULL OR ( AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL)
docs.title LIKE $query OR AND (
(
docs.title LIKE sqlc.narg('query') OR
docs.author LIKE $query docs.author LIKE $query
) ) OR $query IS NULL
) )
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT $limit LIMIT $limit
@@ -280,7 +246,7 @@ SELECT
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage, ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
progress.document_id, progress.document_id,
progress.user_id, progress.user_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at LOCAL_TIME(progress.created_at, users.timezone) AS created_at
FROM document_progress AS progress FROM document_progress AS progress
LEFT JOIN users ON progress.user_id = users.id LEFT JOIN users ON progress.user_id = users.id
LEFT JOIN devices ON progress.device_id = devices.id LEFT JOIN devices ON progress.device_id = devices.id
@@ -369,7 +335,8 @@ UPDATE users
SET SET
pass = COALESCE($password, pass), pass = COALESCE($password, pass),
auth_hash = COALESCE($auth_hash, auth_hash), auth_hash = COALESCE($auth_hash, auth_hash),
time_offset = COALESCE($time_offset, time_offset) timezone = COALESCE($timezone, timezone),
admin = COALESCE($admin, admin)
WHERE id = $user_id WHERE id = $user_id
RETURNING *; RETURNING *;
@@ -395,6 +362,7 @@ RETURNING *;
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@@ -409,10 +377,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),

View File

@@ -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.29.0
// source: query.sql // source: query.sql
package database package database
@@ -85,7 +85,7 @@ type AddMetadataParams struct {
Isbn13 *string `json:"isbn13"` Isbn13 *string `json:"isbn13"`
} }
func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metadatum, error) { func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metadata, error) {
row := q.db.QueryRowContext(ctx, addMetadata, row := q.db.QueryRowContext(ctx, addMetadata,
arg.DocumentID, arg.DocumentID,
arg.Title, arg.Title,
@@ -96,7 +96,7 @@ func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metad
arg.Isbn10, arg.Isbn10,
arg.Isbn13, arg.Isbn13,
) )
var i Metadatum var i Metadata
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.DocumentID, &i.DocumentID,
@@ -113,8 +113,8 @@ 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, auth_hash) INSERT INTO users (id, pass, auth_hash, admin)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
` `
@@ -122,10 +122,16 @@ type CreateUserParams struct {
ID string `json:"id"` ID string `json:"id"`
Pass *string `json:"-"` Pass *string `json:"-"`
AuthHash *string `json:"auth_hash"` 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, arg.AuthHash) 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
} }
@@ -147,6 +153,18 @@ func (q *Queries) DeleteDocument(ctx context.Context, id string) (int64, error)
return result.RowsAffected() return result.RowsAffected()
} }
const deleteUser = `-- name: DeleteUser :execrows
DELETE FROM users WHERE id = ?1
`
func (q *Queries) DeleteUser(ctx context.Context, id string) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteUser, id)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getActivity = `-- name: GetActivity :many const getActivity = `-- name: GetActivity :many
WITH filtered_activity AS ( WITH filtered_activity AS (
SELECT SELECT
@@ -175,7 +193,7 @@ WITH filtered_activity AS (
SELECT SELECT
document_id, document_id,
device_id, device_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time, LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
title, title,
author, author,
duration, duration,
@@ -198,7 +216,7 @@ type GetActivityParams struct {
type GetActivityRow struct { type GetActivityRow struct {
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
StartTime string `json:"start_time"` StartTime interface{} `json:"start_time"`
Title *string `json:"title"` Title *string `json:"title"`
Author *string `json:"author"` Author *string `json:"author"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
@@ -248,7 +266,7 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
const getDailyReadStats = `-- name: GetDailyReadStats :many const getDailyReadStats = `-- name: GetDailyReadStats :many
WITH RECURSIVE last_30_days AS ( WITH RECURSIVE last_30_days AS (
SELECT DATE('now', time_offset) AS date SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date
FROM users WHERE users.id = ?1 FROM users WHERE users.id = ?1
UNION ALL UNION ALL
SELECT DATE(date, '-1 days') SELECT DATE(date, '-1 days')
@@ -267,7 +285,7 @@ filtered_activity AS (
activity_days AS ( activity_days AS (
SELECT SELECT
SUM(duration) AS seconds_read, SUM(duration) AS seconds_read,
DATE(start_time, time_offset) AS day LOCAL_DATE(start_time, timezone) AS day
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
@@ -404,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
SELECT SELECT
devices.id, 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, LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
FROM devices FROM devices
JOIN users ON users.id = devices.user_id JOIN users ON users.id = devices.user_id
WHERE users.id = ?1 WHERE users.id = ?1
@@ -415,8 +433,8 @@ ORDER BY devices.last_synced DESC
type GetDevicesRow struct { type GetDevicesRow struct {
ID string `json:"id"` ID string `json:"id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
CreatedAt string `json:"created_at"` CreatedAt interface{} `json:"created_at"`
LastSynced string `json:"last_synced"` LastSynced interface{} `json:"last_synced"`
} }
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) { func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
@@ -448,7 +466,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
} }
const getDocument = `-- name: GetDocument :one const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
` `
@@ -458,6 +476,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@@ -524,89 +543,8 @@ func (q *Queries) GetDocumentProgress(ctx context.Context, arg GetDocumentProgre
return i, err return i, err
} }
const getDocumentWithStats = `-- name: GetDocumentWithStats :one
SELECT
docs.id,
docs.title,
docs.author,
docs.description,
docs.isbn10,
docs.isbn13,
docs.filepath,
docs.words,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read,
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = ?1
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = ?1
WHERE users.id = ?1
AND docs.id = ?2
LIMIT 1
`
type GetDocumentWithStatsParams struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
}
type GetDocumentWithStatsRow struct {
ID string `json:"id"`
Title *string `json:"title"`
Author *string `json:"author"`
Description *string `json:"description"`
Isbn10 *string `json:"isbn10"`
Isbn13 *string `json:"isbn13"`
Filepath *string `json:"filepath"`
Words *int64 `json:"words"`
Wpm int64 `json:"wpm"`
ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead interface{} `json:"last_read"`
Percentage float64 `json:"percentage"`
SecondsPerPercent int64 `json:"seconds_per_percent"`
}
func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithStatsParams) (GetDocumentWithStatsRow, error) {
row := q.db.QueryRowContext(ctx, getDocumentWithStats, arg.UserID, arg.DocumentID)
var i GetDocumentWithStatsRow
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Description,
&i.Isbn10,
&i.Isbn13,
&i.Filepath,
&i.Words,
&i.Wpm,
&i.ReadPercentage,
&i.TotalTimeSeconds,
&i.LastRead,
&i.Percentage,
&i.SecondsPerPercent,
)
return i, err
}
const getDocuments = `-- name: GetDocuments :many const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?2 LIMIT ?2
OFFSET ?1 OFFSET ?1
@@ -629,6 +567,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@@ -692,42 +631,43 @@ SELECT
CAST(COALESCE(dus.total_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', LOCAL_TIME(COALESCE(dus.last_read, STRFTIME('%Y-%m-%dT%H:%M:%SZ', 0, 'unixepoch')), users.timezone))
AS last_read, AS last_read,
ROUND(CAST(CASE ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0 WHEN dus.percentage IS NULL THEN 0.0
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0 WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0 ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage, END AS REAL), 2) AS percentage,
CAST(CASE
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0 WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE ELSE
ROUND(
CAST(dus.total_time_seconds AS REAL) CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0) / (dus.read_percentage * 100.0)
) END AS INTEGER) AS seconds_per_percent
END AS seconds_per_percent
FROM documents AS docs FROM documents AS docs
LEFT JOIN users ON users.id = ?1 LEFT JOIN users ON users.id = ?1
LEFT JOIN LEFT JOIN
document_user_statistics AS dus document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = ?1 ON dus.document_id = docs.id AND dus.user_id = ?1
WHERE WHERE
docs.deleted = false AND ( (docs.id = ?2 OR ?2 IS NULL)
?2 IS NULL OR ( AND (docs.deleted = ?3 OR ?3 IS NULL)
docs.title LIKE ?2 OR AND (
docs.author LIKE ?2 (
) docs.title LIKE ?4 OR
docs.author LIKE ?4
) OR ?4 IS NULL
) )
ORDER BY dus.last_read DESC, docs.created_at DESC ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT ?4 LIMIT ?6
OFFSET ?3 OFFSET ?5
` `
type GetDocumentsWithStatsParams struct { type GetDocumentsWithStatsParams struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Query interface{} `json:"query"` ID *string `json:"id"`
Deleted *bool `json:"-"`
Query *string `json:"query"`
Offset int64 `json:"offset"` Offset int64 `json:"offset"`
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
} }
@@ -746,12 +686,14 @@ type GetDocumentsWithStatsRow struct {
TotalTimeSeconds int64 `json:"total_time_seconds"` TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead interface{} `json:"last_read"` LastRead interface{} `json:"last_read"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
SecondsPerPercent interface{} `json:"seconds_per_percent"` SecondsPerPercent int64 `json:"seconds_per_percent"`
} }
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) { func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats, rows, err := q.db.QueryContext(ctx, getDocumentsWithStats,
arg.UserID, arg.UserID,
arg.ID,
arg.Deleted,
arg.Query, arg.Query,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
@@ -813,7 +755,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
} }
const getMissingDocuments = `-- name: GetMissingDocuments :many const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE WHERE
documents.filepath IS NOT NULL documents.filepath IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
@@ -842,6 +784,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@@ -881,7 +824,7 @@ SELECT
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage, ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
progress.document_id, progress.document_id,
progress.user_id, progress.user_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', progress.created_at, users.time_offset) AS TEXT) AS created_at LOCAL_TIME(progress.created_at, users.timezone) AS created_at
FROM document_progress AS progress FROM document_progress AS progress
LEFT JOIN users ON progress.user_id = users.id LEFT JOIN users ON progress.user_id = users.id
LEFT JOIN devices ON progress.device_id = devices.id LEFT JOIN devices ON progress.device_id = devices.id
@@ -914,7 +857,7 @@ type GetProgressRow struct {
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
DocumentID string `json:"document_id"` DocumentID string `json:"document_id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
CreatedAt string `json:"created_at"` CreatedAt interface{} `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) {
@@ -955,7 +898,7 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get
} }
const getUser = `-- name: GetUser :one const getUser = `-- name: GetUser :one
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
` `
@@ -967,7 +910,7 @@ func (q *Queries) GetUser(ctx context.Context, userID string) (User, error) {
&i.Pass, &i.Pass,
&i.AuthHash, &i.AuthHash,
&i.Admin, &i.Admin,
&i.TimeOffset, &i.Timezone,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -1057,7 +1000,7 @@ func (q *Queries) GetUserStatistics(ctx context.Context) ([]GetUserStatisticsRow
} }
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, last_timezone, last_seen, last_record, last_calculated FROM user_streaks
WHERE user_id = ?1 WHERE user_id = ?1
` `
@@ -1079,6 +1022,10 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
&i.CurrentStreak, &i.CurrentStreak,
&i.CurrentStreakStartDate, &i.CurrentStreakStartDate,
&i.CurrentStreakEndDate, &i.CurrentStreakEndDate,
&i.LastTimezone,
&i.LastSeen,
&i.LastRecord,
&i.LastCalculated,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -1094,7 +1041,7 @@ func (q *Queries) GetUserStreaks(ctx context.Context, userID string) ([]UserStre
} }
const getUsers = `-- name: GetUsers :many const getUsers = `-- name: GetUsers :many
SELECT id, pass, auth_hash, admin, time_offset, created_at FROM users SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
` `
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) { func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
@@ -1111,7 +1058,7 @@ func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
&i.Pass, &i.Pass,
&i.AuthHash, &i.AuthHash,
&i.Admin, &i.Admin,
&i.TimeOffset, &i.Timezone,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -1245,15 +1192,17 @@ UPDATE users
SET SET
pass = COALESCE(?1, pass), pass = COALESCE(?1, pass),
auth_hash = COALESCE(?2, auth_hash), auth_hash = COALESCE(?2, auth_hash),
time_offset = COALESCE(?3, time_offset) timezone = COALESCE(?3, timezone),
WHERE id = ?4 admin = COALESCE(?4, admin)
RETURNING id, pass, auth_hash, admin, time_offset, created_at WHERE id = ?5
RETURNING id, pass, auth_hash, admin, timezone, created_at
` `
type UpdateUserParams struct { type UpdateUserParams struct {
Password *string `json:"-"` Password *string `json:"-"`
AuthHash *string `json:"auth_hash"` AuthHash *string `json:"auth_hash"`
TimeOffset *string `json:"time_offset"` Timezone *string `json:"timezone"`
Admin bool `json:"-"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
} }
@@ -1261,7 +1210,8 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e
row := q.db.QueryRowContext(ctx, updateUser, row := q.db.QueryRowContext(ctx, updateUser,
arg.Password, arg.Password,
arg.AuthHash, arg.AuthHash,
arg.TimeOffset, arg.Timezone,
arg.Admin,
arg.UserID, arg.UserID,
) )
var i User var i User
@@ -1270,7 +1220,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e
&i.Pass, &i.Pass,
&i.AuthHash, &i.AuthHash,
&i.Admin, &i.Admin,
&i.TimeOffset, &i.Timezone,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -1316,6 +1266,7 @@ const upsertDocument = `-- name: UpsertDocument :one
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@@ -1330,10 +1281,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),
@@ -1347,12 +1299,13 @@ SET
gbid = COALESCE(excluded.gbid, gbid), gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13) isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpsertDocumentParams struct { type UpsertDocumentParams struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`
@@ -1372,6 +1325,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
row := q.db.QueryRowContext(ctx, upsertDocument, row := q.db.QueryRowContext(ctx, upsertDocument,
arg.ID, arg.ID,
arg.Md5, arg.Md5,
arg.Basepath,
arg.Filepath, arg.Filepath,
arg.Coverfile, arg.Coverfile,
arg.Title, arg.Title,
@@ -1390,6 +1344,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,

View File

@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
pass TEXT NOT NULL, pass TEXT NOT NULL,
auth_hash 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', timezone TEXT NOT NULL DEFAULT 'Europe/London',
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')) created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'))
); );
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
md5 TEXT, md5 TEXT,
basepath TEXT,
filepath TEXT, filepath TEXT,
coverfile TEXT, coverfile TEXT,
title TEXT, title TEXT,
@@ -117,30 +118,13 @@ CREATE TABLE IF NOT EXISTS settings (
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')) created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'))
); );
--------------------------------------------------------------- -- Document User Statistics Table
----------------------- Temporary Tables ---------------------- CREATE TABLE IF NOT EXISTS document_user_statistics (
---------------------------------------------------------------
-- Temporary User Streaks Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL,
window TEXT NOT NULL,
max_streak INTEGER NOT NULL,
max_streak_start_date TEXT NOT NULL,
max_streak_end_date TEXT NOT NULL,
current_streak INTEGER NOT NULL,
current_streak_start_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 (
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
percentage REAL NOT NULL, percentage REAL NOT NULL,
last_read TEXT NOT NULL, last_read DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
read_percentage REAL NOT NULL, read_percentage REAL NOT NULL,
total_time_seconds INTEGER NOT NULL, total_time_seconds INTEGER NOT NULL,
@@ -162,321 +146,39 @@ CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
UNIQUE(document_id, user_id) ON CONFLICT REPLACE UNIQUE(document_id, user_id) ON CONFLICT REPLACE
); );
-- User Streaks Table
CREATE TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL,
window TEXT NOT NULL,
max_streak INTEGER NOT NULL,
max_streak_start_date TEXT NOT NULL,
max_streak_end_date TEXT NOT NULL,
current_streak INTEGER NOT NULL,
current_streak_start_date TEXT NOT NULL,
current_streak_end_date TEXT NOT NULL,
last_timezone TEXT NOT NULL,
last_seen TEXT NOT NULL,
last_record TEXT NOT NULL,
last_calculated TEXT NOT NULL,
UNIQUE(user_id, window) ON CONFLICT REPLACE
);
--------------------------------------------------------------- ---------------------------------------------------------------
--------------------------- Indexes --------------------------- --------------------------- Indexes ---------------------------
--------------------------------------------------------------- ---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time); CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS activity_created_at ON activity (created_at);
CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id); CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity ( CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
user_id, user_id,
document_id document_id
); );
---------------------------------------------------------------
---------------------------- Views ----------------------------
---------------------------------------------------------------
DROP VIEW IF EXISTS view_user_streaks;
DROP VIEW IF EXISTS view_document_user_statistics;
--------------------------------
--------- User Streaks ---------
--------------------------------
CREATE VIEW view_user_streaks AS
WITH document_windows AS (
SELECT
activity.user_id,
users.time_offset,
DATE(
activity.start_time,
users.time_offset,
'weekday 0', '-7 day'
) AS weekly_read,
DATE(activity.start_time, users.time_offset) AS daily_read
FROM activity
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
time_offset,
'WEEK' AS "window",
weekly_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY weekly_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
time_offset,
'DAY' AS "window",
daily_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY daily_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM daily_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || seqnum || ' day')
UNION ALL
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM weekly_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || (seqnum * 7) || ' day')
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date,
window,
user_id
FROM streaks
GROUP BY user_id, window
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date,
window,
user_id
FROM streaks
WHERE CASE
WHEN window = "WEEK" THEN
DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date
OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN window = "DAY" THEN
DATE('now', time_offset, '-1 day') = current_streak_end_date
OR DATE('now', time_offset) = current_streak_end_date
END
GROUP BY user_id, window
)
SELECT
max_streak.user_id,
max_streak.window,
IFNULL(max_streak, 0) AS max_streak,
IFNULL(max_streak_start_date, "N/A") AS max_streak_start_date,
IFNULL(max_streak_end_date, "N/A") AS max_streak_end_date,
IFNULL(current_streak, 0) AS current_streak,
IFNULL(current_streak_start_date, "N/A") AS current_streak_start_date,
IFNULL(current_streak_end_date, "N/A") AS current_streak_end_date
FROM max_streak
LEFT JOIN current_streak ON
current_streak.user_id = max_streak.user_id
AND current_streak.window = max_streak.window;
--------------------------------
------- Document Stats ---------
--------------------------------
CREATE VIEW view_document_user_statistics AS
WITH intermediate_ga AS (
SELECT
ga1.id AS row_id,
ga1.user_id,
ga1.document_id,
ga1.duration,
ga1.start_time,
ga1.start_percentage,
ga1.end_percentage,
-- Find Overlapping Events (Assign Unique ID)
(
SELECT MIN(id)
FROM activity AS ga2
WHERE
ga1.document_id = ga2.document_id
AND ga1.user_id = ga2.user_id
AND ga1.start_percentage <= ga2.end_percentage
AND ga1.end_percentage >= ga2.start_percentage
) AS group_leader
FROM activity AS ga1
),
grouped_activity AS (
SELECT
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,
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))
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
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 ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC;
--------------------------------------------------------------- ---------------------------------------------------------------
--------------------------- Triggers -------------------------- --------------------------- Triggers --------------------------
--------------------------------------------------------------- ---------------------------------------------------------------
@@ -488,3 +190,11 @@ UPDATE documents
SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now') SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = old.id; WHERE id = old.id;
END; END;
-- Delete User
CREATE TRIGGER IF NOT EXISTS user_deleted
BEFORE DELETE ON users BEGIN
DELETE FROM activity WHERE activity.user_id=OLD.id;
DELETE FROM devices WHERE devices.user_id=OLD.id;
DELETE FROM document_progress WHERE document_progress.user_id=OLD.id;
END;

154
database/user_streaks.sql Normal file
View File

@@ -0,0 +1,154 @@
WITH updated_users AS (
SELECT a.user_id
FROM activity AS a
LEFT JOIN users AS u ON u.id = a.user_id
LEFT JOIN user_streaks AS s ON a.user_id = s.user_id AND s.window = 'DAY'
WHERE
a.created_at > COALESCE(s.last_seen, '1970-01-01')
AND LOCAL_DATE(s.last_record, u.timezone) != LOCAL_DATE(a.start_time, u.timezone)
GROUP BY a.user_id
),
outdated_users AS (
SELECT
a.user_id,
u.timezone AS last_timezone,
MAX(a.created_at) AS last_seen,
MAX(a.start_time) AS last_record,
STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now') AS last_calculated
FROM activity AS a
LEFT JOIN users AS u ON u.id = a.user_id
LEFT JOIN user_streaks AS s ON a.user_id = s.user_id AND s.window = 'DAY'
GROUP BY a.user_id
HAVING
-- User Changed Timezones
s.last_timezone != u.timezone
-- Users Date Changed
OR LOCAL_DATE(COALESCE(s.last_calculated, '1970-01-01T00:00:00Z'), u.timezone) !=
LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), u.timezone)
-- User Added New Data
OR a.user_id IN updated_users
),
document_windows AS (
SELECT
activity.user_id,
users.timezone,
DATE(
LOCAL_DATE(activity.start_time, users.timezone),
'weekday 0', '-7 day'
) AS weekly_read,
LOCAL_DATE(activity.start_time, users.timezone) AS daily_read
FROM activity
INNER JOIN outdated_users ON outdated_users.user_id = activity.user_id
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
timezone,
'WEEK' AS "window",
weekly_read AS read_window,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY weekly_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
timezone,
'DAY' AS "window",
daily_read AS read_window,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY daily_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
timezone
FROM daily_partitions
GROUP BY
timezone,
user_id,
DATE(read_window, '+' || seqnum || ' day')
UNION ALL
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
timezone
FROM weekly_partitions
GROUP BY
timezone,
user_id,
DATE(read_window, '+' || (seqnum * 7) || ' day')
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date,
window,
user_id
FROM streaks
GROUP BY user_id, window
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date,
window,
user_id
FROM streaks
WHERE CASE
WHEN window = "WEEK" THEN
DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), 'weekday 0', '-14 day') = current_streak_end_date
OR DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), 'weekday 0', '-7 day') = current_streak_end_date
WHEN window = "DAY" THEN
DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), '-1 day') = current_streak_end_date
OR DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone)) = current_streak_end_date
END
GROUP BY user_id, window
)
INSERT INTO user_streaks
SELECT
max_streak.user_id,
max_streak.window,
IFNULL(max_streak, 0) AS max_streak,
IFNULL(max_streak_start_date, "N/A") AS max_streak_start_date,
IFNULL(max_streak_end_date, "N/A") AS max_streak_end_date,
IFNULL(current_streak.current_streak, 0) AS current_streak,
IFNULL(current_streak.current_streak_start_date, "N/A") AS current_streak_start_date,
IFNULL(current_streak.current_streak_end_date, "N/A") AS current_streak_end_date,
outdated_users.last_timezone AS last_timezone,
outdated_users.last_seen AS last_seen,
outdated_users.last_record AS last_record,
outdated_users.last_calculated AS last_calculated
FROM max_streak
JOIN outdated_users ON max_streak.user_id = outdated_users.user_id
LEFT JOIN current_streak ON
current_streak.user_id = max_streak.user_id
AND current_streak.window = max_streak.window;

205
database/users_test.go Normal file
View File

@@ -0,0 +1,205 @@
package database
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/utils"
)
var (
testUserID string = "testUser"
testUserPass string = "testPass"
)
type UsersTestSuite struct {
suite.Suite
dbm *DBManager
}
func TestUsers(t *testing.T) {
suite.Run(t, new(UsersTestSuite))
}
func (suite *UsersTestSuite) SetupTest() {
cfg := config.Config{
DBType: "memory",
}
suite.dbm = NewMgr(&cfg)
// Create User
rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash)
_, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUserID,
Pass: &testUserPass,
AuthHash: &authHash,
})
suite.NoError(err)
// Create Document
_, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
Words: &documentWords,
})
suite.NoError(err)
// Create Device
_, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID,
UserID: testUserID,
DeviceName: deviceName,
})
suite.NoError(err)
}
func (suite *UsersTestSuite) TestGetUser() {
user, err := suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Equal(testUserPass, *user.Pass)
}
func (suite *UsersTestSuite) TestCreateUser() {
testUser := "user1"
testPass := "pass1"
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
suite.Nil(err, "should have nil err")
authHash := fmt.Sprintf("%x", rawAuthHash)
changed, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUser,
Pass: &testPass,
AuthHash: &authHash,
})
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed)
user, err := suite.dbm.Queries.GetUser(context.Background(), testUser)
suite.Nil(err, "should have nil err")
suite.Equal(testPass, *user.Pass)
}
func (suite *UsersTestSuite) TestDeleteUser() {
changed, err := suite.dbm.Queries.DeleteUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have one changed row")
_, err = suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.ErrorIs(err, sql.ErrNoRows, "should have no rows error")
}
func (suite *UsersTestSuite) TestGetUsers() {
users, err := suite.dbm.Queries.GetUsers(context.Background())
suite.Nil(err, "should have nil err")
suite.Len(users, 1, "should have single user")
}
func (suite *UsersTestSuite) TestUpdateUser() {
newPassword := "newPass123"
user, err := suite.dbm.Queries.UpdateUser(context.Background(), UpdateUserParams{
UserID: testUserID,
Password: &newPassword,
})
suite.Nil(err, "should have nil err")
suite.Equal(newPassword, *user.Pass, "should have new password")
}
func (suite *UsersTestSuite) TestGetUserStatistics() {
err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty")
// Create Activity
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: testUserID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: float64(counter) / 100.0,
EndPercentage: float64(counter+1) / 100.0,
})
suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
}
err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure One Item
userStats, err = suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err")
suite.Len(userStats, 1, "should have length of one")
}
func (suite *UsersTestSuite) TestGetUsersStreaks() {
err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty")
// Create Activity
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: testUserID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: float64(counter) / 100.0,
EndPercentage: float64(counter+1) / 100.0,
})
suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
}
err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Two Item
userStats, err = suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Len(userStats, 2, "should have length of two")
// Ensure Streak Stats
dayStats := userStats[0]
weekStats := userStats[1]
suite.Equal(int64(10), dayStats.CurrentStreak, "should be 10 days")
suite.Greater(weekStats.CurrentStreak, int64(1), "should be 2 or 3")
}

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1754292888,
"narHash": "sha256-1ziydHSiDuSnaiPzCQh1mRFBsM2d2yRX9I+5OPGEmIE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ce01daebf8489ba97bd1609d185ea276efdeb121",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

29
flake.nix Normal file
View File

@@ -0,0 +1,29 @@
{
description = "Development Environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go
golangci-lint
nodejs
tailwindcss
python311Packages.grip
];
shellHook = ''
export PATH=$PATH:~/go/bin
'';
};
}
);
}

93
go.mod
View File

@@ -1,80 +1,85 @@
module reichard.io/antholume module reichard.io/antholume
go 1.21 go 1.24
require ( require (
github.com/PuerkitoBio/goquery v1.8.1 github.com/PuerkitoBio/goquery v1.10.3
github.com/alexedwards/argon2id v1.0.0 github.com/alexedwards/argon2id v1.0.0
github.com/gabriel-vasile/mimetype v1.4.3 github.com/gabriel-vasile/mimetype v1.4.9
github.com/gin-contrib/multitemplate v0.0.0-20231230012943-32b233489a81 github.com/gin-contrib/multitemplate v1.1.1
github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.10.1
github.com/itchyny/gojq v0.12.14 github.com/itchyny/gojq v0.12.17
github.com/microcosm-cc/bluemonday v1.0.26 github.com/jarcoal/httpmock v1.3.1
github.com/pressly/goose/v3 v3.17.0 github.com/microcosm-cc/bluemonday v1.0.27
github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.24.3
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/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.1 github.com/taylorskalyo/goreader v1.0.1
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a github.com/urfave/cli/v2 v2.27.7
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.28.0 modernc.org/sqlite v1.38.2
) )
require ( require (
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/sonic v1.10.2 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // 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.1 // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // 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 v1.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.17.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/itchyny/timefmt-go v0.1.6 // 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.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // 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.1 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/sethvargo/go-retry v0.3.0 // 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.12 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.18.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.20.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.17.0 // indirect golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.3.0 // indirect lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect modernc.org/ccgo/v3 v3.17.0 // indirect
modernc.org/libc v1.40.7 // indirect modernc.org/libc v1.66.6 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/opt v0.1.3 // indirect modernc.org/opt v0.1.4 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

153
go.sum
View File

@@ -2,27 +2,38 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 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 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UTS0=
github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw=
github.com/ClickHouse/ch-go v0.65.1 h1:SLuxmLl5Mjj44/XbINsK2HFvzqup0s6rwKLFH347ZhU=
github.com/ClickHouse/clickhouse-go/v2 v2.16.0 h1:rhMfnPewXPnY4Q4lQRGdYuTLRBRKJEIEYHtbUMrzmvI= 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/ClickHouse/clickhouse-go/v2 v2.16.0/go.mod h1:J7SPfIxwR+x4mQ+o8MLSe0oY50NNntEqCIjFe/T1VPM=
github.com/ClickHouse/clickhouse-go/v2 v2.34.0 h1:Y4rqkdrRHgExvC4o/NTbLdY5LFQ3LHS77/RNFxFX3Co=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 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/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 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= 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/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
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/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 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/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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/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.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/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/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 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/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=
@@ -32,10 +43,15 @@ github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpV
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/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= 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/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 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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=
@@ -52,23 +68,36 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
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/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4= github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4=
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0=
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
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 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/multitemplate v0.0.0-20231230012943-32b233489a81/go.mod h1:XLLtIXoP9+9zGcEDc7gAGV3AksGPO+vzv4kXHMJSdU0=
github.com/gin-contrib/multitemplate v1.1.1 h1:uzhT/ZWS9nBd1h6P+AaxWaVSVAJRAcKH4yafrBU8sPc=
github.com/gin-contrib/multitemplate v1.1.1/go.mod h1:1Sa4984P8+x87U0cg5yWxK4jpbK1cXMYegUCZK6XT/M=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= 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/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
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-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
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/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= 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/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 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI=
github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
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/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -81,26 +110,36 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
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.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 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/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
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/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 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/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/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -113,35 +152,48 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 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/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 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= 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/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= 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/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 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/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 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 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4=
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= 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 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
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/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= 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/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
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.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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=
@@ -154,13 +206,22 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 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/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 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -170,6 +231,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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 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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 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/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 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
@@ -180,11 +243,15 @@ github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4a
github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= 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 h1:guVYVqzxHE/CQ1KpfGO077TR0ATHSNjp4s6XGLn3W9s=
github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
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.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= 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/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
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 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=
@@ -192,21 +259,28 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/pressly/goose/v3 v3.17.0 h1:fT4CL3LRm4kfyLuPWzDFAoxjR5ZHjeJ6uQhibQtBaIs= github.com/pressly/goose/v3 v3.17.0 h1:fT4CL3LRm4kfyLuPWzDFAoxjR5ZHjeJ6uQhibQtBaIs=
github.com/pressly/goose/v3 v3.17.0/go.mod h1:22aw7NpnCPlS86oqkO/+3+o9FuCaJg4ZVWRUO3oGzHQ= github.com/pressly/goose/v3 v3.17.0/go.mod h1:22aw7NpnCPlS86oqkO/+3+o9FuCaJg4ZVWRUO3oGzHQ=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
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=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
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 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec=
github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 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/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
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=
@@ -221,16 +295,24 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115 h1:OEAIMYp5l9kJ2kT9UPL5QSUriKIIDhnLmpJTy69sltA= github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115 h1:OEAIMYp5l9kJ2kT9UPL5QSUriKIIDhnLmpJTy69sltA=
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/taylorskalyo/goreader v1.0.1 h1:eS9SYiHai2aAHhm+YMGRTqrvNt2aoRMTd7p6ftm0crY=
github.com/taylorskalyo/goreader v1.0.1/go.mod h1:JrUsWCgnk4C3P5Jsr7Pf2mFrMpsR0ls/0bjR5aorYTI=
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.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.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.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw=
github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= 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 h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
@@ -241,32 +323,53 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 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 h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
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 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-genproto v0.0.0-20231012155159-f85a672542fd/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 h1:LY6cI8cP4B9rrpTleZk95+08kl2gF4rixG7+V/dwL6Q=
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 h1:E0yUuuX7UmPxXm92+yQCjMveLFO3zfvYFIJVuAqsVRA=
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2/go.mod h1:fjBLQ2TdQNl4bMjuWl9adoTGBypwUTPoGC+EqYqiIcU= github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2/go.mod h1:fjBLQ2TdQNl4bMjuWl9adoTGBypwUTPoGC+EqYqiIcU=
github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 h1:ixAiqjj2S/dNuJqrz4AxSqgw2P5OBMXp68hB5nNriUk=
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 h1:vsb/ggIY+hUjD/zCAQHpzTmndPqv/ml2ArbsbfBYTAc=
go.opentelemetry.io/otel v1.20.0/go.mod h1:oUIGj3D77RwJdM6PPZImDpSZGDvkD9fhesHny69JFrs= go.opentelemetry.io/otel v1.20.0/go.mod h1:oUIGj3D77RwJdM6PPZImDpSZGDvkD9fhesHny69JFrs=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel/trace v1.20.0 h1:+yxVAPZPbQhbC3OfAkeIVTky6iTFpcr4SiY9om7mXSQ= go.opentelemetry.io/otel/trace v1.20.0 h1:+yxVAPZPbQhbC3OfAkeIVTky6iTFpcr4SiY9om7mXSQ=
go.opentelemetry.io/otel/trace v1.20.0/go.mod h1:HJSK7F/hA5RlzpZ0zKDCHCDHm556LCDtKaAo6JmBFUU= go.opentelemetry.io/otel/trace v1.20.0/go.mod h1:HJSK7F/hA5RlzpZ0zKDCHCDHm556LCDtKaAo6JmBFUU=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
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-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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
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/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
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=
@@ -275,13 +378,24 @@ 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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
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/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 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/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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-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=
@@ -295,15 +409,26 @@ 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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.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 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.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=
@@ -313,22 +438,35 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 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/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
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.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
@@ -344,28 +482,43 @@ 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=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= 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 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccgo/v3 v3.17.0 h1:o3OmOqx4/OFnl4Vm3G8Bgmqxnvxnh0nbxeT5p/dWChA=
modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I=
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/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/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg= modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg=
modernc.org/libc v1.40.7/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/libc v1.40.7/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U=
modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -115,7 +115,7 @@ func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPo
// Modifiers // Modifiers
var smoothingRatio float64 = 0.2 var smoothingRatio float64 = 0.2
var directionModifier float64 = 0 var directionModifier float64 = 0
if isReverse == true { if isReverse {
directionModifier = math.Pi directionModifier = math.Pi
} }

View File

@@ -53,10 +53,12 @@ func countEPUBWords(filepath string) (int64, error) {
rf := rc.Rootfiles[0] rf := rc.Rootfiles[0]
var completeCount int64 var completeCount int64
for _, item := range rf.Spine.Itemrefs { for _, item := range rf.Itemrefs {
f, _ := item.Open() f, _ := item.Open()
doc, _ := goquery.NewDocumentFromReader(f) doc, _ := goquery.NewDocumentFromReader(f)
completeCount = completeCount + int64(len(strings.Fields(doc.Text()))) doc.Find("script, style, noscript, iframe").Remove()
words := len(strings.Fields(doc.Text()))
completeCount = completeCount + int64(words)
} }
return completeCount, nil return completeCount, nil

View File

@@ -121,7 +121,7 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error { 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 {
log.Warn("File Alreads Exists") log.Warn("File Alreads Exists")
return nil return nil
} }

View File

@@ -49,8 +49,8 @@ func hookAPI() *details {
} }
// Convert to JSON Response // Convert to JSON Response
var responseData map[string]interface{} var responseData map[string]any
json.Unmarshal([]byte(rawResp), &responseData) _ = json.Unmarshal([]byte(rawResp), &responseData)
// Return Response // Return Response
return httpmock.NewJsonResponse(200, responseData) return httpmock.NewJsonResponse(200, responseData)

View File

@@ -65,9 +65,9 @@ func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, erro
case SOURCE_GBOOK: case SOURCE_GBOOK:
return getGBooksMetadata(metadataSearch) return getGBooksMetadata(metadataSearch)
case SOURCE_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")
} }
} }
@@ -87,7 +87,7 @@ func GetWordCount(filepath string) (*int64, error) {
} }
return &totalWords, nil return &totalWords, nil
} else { } else {
return nil, fmt.Errorf("Invalid extension") return nil, fmt.Errorf("invalid extension: %s", fileExtension)
} }
} }
@@ -109,6 +109,9 @@ func GetMetadata(filepath string) (*MetadataInfo, error) {
// Acquire Metadata // Acquire Metadata
metadataInfo, err := handler(filepath) metadataInfo, err := handler(filepath)
if err != nil {
return nil, fmt.Errorf("unable to acquire metadata")
}
// Calculate MD5 & Partial MD5 // Calculate MD5 & Partial MD5
partialMD5, err := utils.CalculatePartialMD5(filepath) partialMD5, err := utils.CalculatePartialMD5(filepath)

View File

@@ -8,7 +8,7 @@ import (
) )
func TestGetWordCount(t *testing.T) { func TestGetWordCount(t *testing.T) {
var desiredCount int64 = 30080 var desiredCount int64 = 30070
actualCount, err := countEPUBWords("../_test_files/alice.epub") actualCount, err := countEPUBWords("../_test_files/alice.epub")
assert.Nil(t, err, "should have no error") assert.Nil(t, err, "should have no error")

55
package-lock.json generated Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "antholume",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antholume",
"version": "1.0.0",
"devDependencies": {
"prettier-plugin-go-template": "^0.0.15"
}
},
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-go-template": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/prettier-plugin-go-template/-/prettier-plugin-go-template-0.0.15.tgz",
"integrity": "sha512-WqU92E1NokWYNZ9mLE6ijoRg6LtIGdLMePt2C7UBDjXeDH9okcRI3zRqtnWR4s5AloiqyvZ66jNBAa9tmRY5EQ==",
"dev": true,
"dependencies": {
"ulid": "^2.3.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/ulid": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz",
"integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==",
"dev": true,
"bin": {
"ulid": "bin/cli.js"
}
}
}
}

7
package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "antholume",
"version": "1.0.0",
"devDependencies": {
"prettier-plugin-go-template": "^0.0.15"
}
}

View File

@@ -0,0 +1,37 @@
package formatters
import (
"fmt"
"strings"
"time"
)
// FormatDuration takes a duration and returns a human-readable duration string.
// For example: 1928371 seconds -> "22d 7h 39m 31s"
func FormatDuration(d time.Duration) string {
if d == 0 {
return "N/A"
}
var parts []string
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
if days > 0 {
parts = append(parts, fmt.Sprintf("%dd", days))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%dh", hours))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%dm", minutes))
}
if seconds > 0 {
parts = append(parts, fmt.Sprintf("%ds", seconds))
}
return strings.Join(parts, " ")
}

45
pkg/formatters/numbers.go Normal file
View File

@@ -0,0 +1,45 @@
package formatters
import (
"fmt"
"math"
)
// FormatNumber takes an int64 and returns a human-readable string.
// For example: 19823 -> "19.8k", 1500000 -> "1.5M"
func FormatNumber(input int64) string {
if input == 0 {
return "0"
}
// Handle Negative
negative := input < 0
if negative {
input = -input
}
abbreviations := []string{"", "k", "M", "B", "T"}
abbrevIndex := int(math.Log10(float64(input)) / 3)
// Bounds Check
if abbrevIndex >= len(abbreviations) {
abbrevIndex = len(abbreviations) - 1
}
scaledNumber := float64(input) / math.Pow(10, float64(abbrevIndex*3))
var result string
if scaledNumber >= 100 {
result = fmt.Sprintf("%.0f%s", scaledNumber, abbreviations[abbrevIndex])
} else if scaledNumber >= 10 {
result = fmt.Sprintf("%.1f%s", scaledNumber, abbreviations[abbrevIndex])
} else {
result = fmt.Sprintf("%.2f%s", scaledNumber, abbreviations[abbrevIndex])
}
if negative {
result = "-" + result
}
return result
}

13
pkg/ptr/ptr.go Normal file
View File

@@ -0,0 +1,13 @@
package ptr
func Of[T any](v T) *T {
return &v
}
func Deref[T any](v *T) T {
var zeroT T
if v == nil {
return zeroT
}
return *v
}

View File

@@ -0,0 +1,17 @@
package sliceutils
func First[T any](s []T) (T, bool) {
if len(s) == 0 {
var zeroT T
return zeroT, false
}
return s[0], true
}
func Map[R, I any](s []I, f func(I) R) []R {
r := make([]R, 0, len(s))
for _, v := range s {
r = append(r, f(v))
}
return r
}

18
pkg/utils/utils.go Normal file
View File

@@ -0,0 +1,18 @@
package utils
func Ternary[T any](cond bool, tVal, fVal T) T {
if cond {
return tVal
}
return fVal
}
func FirstNonZero[T comparable](v ...T) T {
var zero T
for _, val := range v {
if val != zero {
return val
}
}
return zero
}

View File

@@ -3,26 +3,20 @@ package search
import ( import (
"fmt" "fmt"
"io" "io"
"net/url"
"strings" "strings"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func parseAnnasArchiveDownloadURL(body io.ReadCloser) (string, error) { func searchAnnasArchive(query string) ([]SearchItem, error) {
// Parse searchURL := "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en"
defer body.Close() url := fmt.Sprintf(searchURL, url.QueryEscape(query))
doc, _ := goquery.NewDocumentFromReader(body) body, err := getPage(url)
if err != nil {
// Return Download URL return nil, err
downloadURL, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
if exists == false {
return "", fmt.Errorf("Download URL not found")
} }
return parseAnnasArchive(body)
// Possible Funky URL
downloadURL = strings.ReplaceAll(downloadURL, "\\", "/")
return downloadURL, nil
} }
func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) { func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) {
@@ -35,39 +29,32 @@ func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) {
// Normalize Results // Normalize Results
var allEntries []SearchItem var allEntries []SearchItem
doc.Find("form > div.w-full > div.w-full > div > div.justify-center").Each(func(ix int, rawBook *goquery.Selection) { doc.Find(".js-aarecord-list-outer > div > div").Each(func(ix int, rawBook *goquery.Selection) {
// Parse Details // Parse Details
details := rawBook.Find("div:nth-child(2) > div:nth-child(1)").Text() details := rawBook.Find("div:nth-child(3)").Text()
detailsSplit := strings.Split(details, ", ") detailsSplit := strings.Split(details, " · ")
// Invalid Details // Invalid Details
if len(detailsSplit) < 3 { if len(detailsSplit) < 3 {
return 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 // Parse MD5
itemHref, _ := rawBook.Find("a").Attr("href") titleAuthorDetails := rawBook.Find("div a")
titleEl := titleAuthorDetails.Eq(0)
itemHref, _ := titleEl.Attr("href")
hrefArray := strings.Split(itemHref, "/") hrefArray := strings.Split(itemHref, "/")
id := hrefArray[len(hrefArray)-1] id := hrefArray[len(hrefArray)-1]
item := SearchItem{ allEntries = append(allEntries, SearchItem{
ID: id, ID: id,
Title: title, Title: titleEl.Text(),
Author: author, Author: titleAuthorDetails.Eq(1).Text(),
Language: language, Language: detailsSplit[0],
FileType: fileType, FileType: detailsSplit[1],
FileSize: fileSize, FileSize: detailsSplit[2],
} })
allEntries = append(allEntries, item)
}) })
// Return Results // Return Results

69
search/downloaders.go Normal file
View File

@@ -0,0 +1,69 @@
package search
import (
"errors"
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
)
func getLibGenDownloadURL(md5 string, _ Source) ([]string, error) {
// Get Page
body, err := getPage("http://libgen.li/ads.php?md5=" + md5)
if err != nil {
return nil, err
}
defer body.Close()
// Parse
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Return Download URL
downloadPath, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
if !exists {
return nil, fmt.Errorf("download URL not found")
}
// Possible Funky URL
downloadPath = strings.ReplaceAll(downloadPath, "\\", "/")
return []string{fmt.Sprintf("http://libgen.li/%s", downloadPath)}, nil
}
func getLibraryDownloadURL(md5 string, source Source) ([]string, error) {
// Derive Info URL
var infoURL string
switch source {
case SOURCE_LIBGEN, SOURCE_ANNAS_ARCHIVE:
infoURL = "http://library.lol/fiction/" + md5
// case SOURCE_LIBGEN_NON_FICTION:
// infoURL = "http://library.lol/main/" + md5
default:
return nil, errors.New("invalid source")
}
// Get Page
body, err := getPage(infoURL)
if err != nil {
return nil, err
}
defer body.Close()
// Parse
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Return Download URL
// downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href")
downloadURL, exists := doc.Find("#download h2 a").Attr("href")
if !exists {
return nil, errors.New("download URL not found")
}
return []string{downloadURL}, nil
}

View File

@@ -1,68 +1,44 @@
package search package search
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"net/url"
"strings" "strings"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func parseLibGenFiction(body io.ReadCloser) ([]SearchItem, error) { const LIBGEN_SEARCH_URL = "https://%s/index.php?req=ext:epub+%s&gmode=on"
// Parse
defer body.Close()
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results var libgenDomains []string = []string{
var allEntries []SearchItem "libgen.vg",
doc.Find("table.catalog tbody > tr").Each(func(ix int, rawBook *goquery.Selection) { "libgen.is",
// 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) { func searchLibGen(query string) ([]SearchItem, error) {
var allErrors []error
for _, domain := range libgenDomains {
url := fmt.Sprintf(LIBGEN_SEARCH_URL, domain, url.QueryEscape(query))
body, err := getPage(url)
if err != nil {
allErrors = append(allErrors, err)
continue
}
results, err := parseLibGen(body)
if err != nil {
allErrors = append(allErrors, err)
continue
}
return results, nil
}
return nil, fmt.Errorf("could not query libgen: %w", errors.Join(allErrors...))
}
func parseLibGen(body io.ReadCloser) ([]SearchItem, error) {
// Parse // Parse
defer body.Close() defer body.Close()
doc, err := goquery.NewDocumentFromReader(body) doc, err := goquery.NewDocumentFromReader(body)
@@ -72,52 +48,27 @@ func parseLibGenNonFiction(body io.ReadCloser) ([]SearchItem, error) {
// Normalize Results // Normalize Results
var allEntries []SearchItem var allEntries []SearchItem
doc.Find("table.c tbody > tr:nth-child(n + 2)").Each(func(ix int, rawBook *goquery.Selection) { doc.Find("#tablelibgen tbody > tr").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 // Parse MD5
titleRaw := rawBook.Find("td:nth-child(3) [id]") linksRaw := rawBook.Find("td:nth-child(9) a")
editHref, _ := titleRaw.Attr("href") linksHref, _ := linksRaw.Attr("href")
hrefArray := strings.Split(editHref, "?md5=") hrefArray := strings.Split(linksHref, "?md5=")
if len(hrefArray) == 0 {
return
}
id := hrefArray[1] id := hrefArray[1]
// Parse Other Details allEntries = append(allEntries, SearchItem{
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, ID: id,
Title: title, Title: rawBook.Find("td:nth-child(1) > a").First().Text(),
Author: author, Author: rawBook.Find("td:nth-child(2)").Text(),
Series: series, Series: rawBook.Find("td:nth-child(1) > b").Text(),
Language: language, Language: rawBook.Find("td:nth-child(5)").Text(),
FileType: fileType, FileType: strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(8)").Text())),
FileSize: fileSize, FileSize: strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(7)").Text())),
} })
allEntries = append(allEntries, item)
}) })
// Return Results // Return Results
return allEntries, nil 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
}

22
search/progress.go Normal file
View File

@@ -0,0 +1,22 @@
package search
type writeCounter struct {
Total int64
Current int64
ProgressFunction func(float32)
}
func (wc *writeCounter) Write(p []byte) (int, error) {
n := len(p)
wc.Current += int64(n)
wc.flushProgress()
return n, nil
}
func (wc *writeCounter) flushProgress() {
if wc.ProgressFunction == nil || wc.Total < 100000 {
return
}
percentage := float32(wc.Current) * 100 / float32(wc.Total)
wc.ProgressFunction(percentage)
}

View File

@@ -2,17 +2,18 @@ package search
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/metadata"
) )
const userAgent string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0" const userAgent string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
type Cadence string type Cadence string
@@ -21,19 +22,11 @@ const (
CADENCE_TOP_MONTH Cadence = "m" CADENCE_TOP_MONTH Cadence = "m"
) )
type BookType int
const (
BOOK_FICTION BookType = iota
BOOK_NON_FICTION
)
type Source string type Source string
const ( const (
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive" SOURCE_ANNAS_ARCHIVE Source = "Annas Archive"
SOURCE_LIBGEN_FICTION Source = "LibGen Fiction" SOURCE_LIBGEN Source = "LibGen"
SOURCE_LIBGEN_NON_FICTION Source = "LibGen Non-fiction"
) )
type SearchItem struct { type SearchItem struct {
@@ -47,120 +40,74 @@ type SearchItem struct {
UploadDate string UploadDate string
} }
type sourceDef struct { type searchFunc func(query string) (searchResults []SearchItem, err error)
searchURL string type downloadFunc func(md5 string, source Source) (downloadURL []string, err error)
downloadURL string
parseSearchFunc func(io.ReadCloser) ([]SearchItem, error) var searchDefs = map[Source]searchFunc{
parseDownloadFunc func(io.ReadCloser) (string, error) SOURCE_ANNAS_ARCHIVE: searchAnnasArchive,
SOURCE_LIBGEN: searchLibGen,
} }
var sourceDefs = map[Source]sourceDef{ var downloadFuncs = []downloadFunc{
SOURCE_ANNAS_ARCHIVE: { getLibGenDownloadURL,
searchURL: "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en", getLibraryDownloadURL,
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) { func SearchBook(query string, source Source) ([]SearchItem, error) {
def := sourceDefs[source] searchFunc, found := searchDefs[source]
log.Debug("Source: ", def) if !found {
url := fmt.Sprintf(def.searchURL, url.QueryEscape(query)) return nil, fmt.Errorf("invalid source: %s", source)
body, err := getPage(url)
if err != nil {
return nil, err
} }
return def.parseSearchFunc(body) log.Debug("Source: ", source)
return searchFunc(query)
} }
func SaveBook(id string, source Source) (string, error) { func SaveBook(md5 string, source Source, progressFunc func(float32)) (string, *metadata.MetadataInfo, error) {
def := sourceDefs[source] for _, f := range downloadFuncs {
log.Debug("Source: ", def) downloadURLs, err := f(md5, source)
url := fmt.Sprintf(def.downloadURL, id)
body, err := getPage(url)
if err != nil { if err != nil {
return "", err log.Error("failed to acquire download urls")
continue
} }
bookURL, err := def.parseDownloadFunc(body) for _, bookURL := range downloadURLs {
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 // Download File
log.Info("Downloading Book: ", bookURL) log.Info("Downloading Book: ", bookURL)
resp, err := downloadBook(bookURL) fileName, err := downloadBook(bookURL, progressFunc)
if err != nil { if err != nil {
os.Remove(tempFile.Name())
log.Error("Book URL API Failure: ", err) log.Error("Book URL API Failure: ", err)
return "", fmt.Errorf("API Failure") continue
} }
defer resp.Body.Close()
// Copy File to Disk // Get Metadata
log.Info("Saving Book") metadata, err := metadata.GetMetadata(fileName)
_, err = io.Copy(tempFile, resp.Body)
if err != nil { if err != nil {
os.Remove(tempFile.Name()) log.Error("Book Metadata Failure: ", err)
log.Error("File Copy Error: ", err) continue
return "", fmt.Errorf("File Failure")
} }
return tempFile.Name(), nil return fileName, metadata, nil
} }
func GetBookURL(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 return "", nil, errors.New("failed to download book")
body, err := getPage(infoURL)
if err != nil {
return "", err
}
// downloadURL := parseLibGenDownloadURL(body)
return parseLibGenDownloadURL(body)
} }
func getPage(page string) (io.ReadCloser, error) { func getPage(page string) (io.ReadCloser, error) {
log.Debug("URL: ", page) log.Debug("URL: ", page)
// Set 10s Timeout // Set 10s Timeout
client := http.Client{ client := http.Client{Timeout: 10 * time.Second}
Timeout: 10 * time.Second,
}
// Get Page // Start Request
resp, err := client.Get(page) req, err := http.NewRequest("GET", page, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
// Do Request
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -169,20 +116,46 @@ func getPage(page string) (io.ReadCloser, error) {
return resp.Body, err return resp.Body, err
} }
func downloadBook(bookURL string) (*http.Response, error) { func downloadBook(bookURL string, progressFunc func(float32)) (string, error) {
log.Debug("URL: ", bookURL)
// Allow Insecure // Allow Insecure
client := &http.Client{Transport: &http.Transport{ client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}} },
}
// Start Request // Start Request
req, err := http.NewRequest("GET", bookURL, nil) req, err := http.NewRequest("GET", bookURL, nil)
if err != nil { if err != nil {
return nil, err return "", err
} }
// Set UserAgent
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
return client.Do(req) // Perform API Request
resp, err := client.Do(req)
if err != nil {
return "", err
}
// Create File
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Error("File Create Error: ", err)
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tempFile.Close()
// Copy File to Disk
log.Info("Saving Book")
counter := &writeCounter{Total: resp.ContentLength, ProgressFunction: progressFunc}
_, err = io.Copy(tempFile, io.TeeReader(resp.Body, counter))
if err != nil {
os.Remove(tempFile.Name())
log.Error("File Copy Error: ", err)
return "", fmt.Errorf("failed to copy response to temp file: %w", err)
}
return tempFile.Name(), nil
} }

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"io/fs" "io/fs"
"net/http" "net/http"
"sync" "sync"
@@ -52,12 +53,14 @@ func (s *server) Start() {
ticker := time.NewTicker(15 * time.Minute) ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Minute))
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
s.runScheduledTasks() s.runScheduledTasks(ctx)
case <-s.done: case <-s.done:
log.Info("Stopping task runner...") log.Info("Stopping task runner...")
cancel()
return return
} }
} }
@@ -81,9 +84,9 @@ func (s *server) Stop() {
} }
// Run normal scheduled tasks // Run normal scheduled tasks
func (s *server) runScheduledTasks() { func (s *server) runScheduledTasks(ctx context.Context) {
start := time.Now() start := time.Now()
if err := s.db.CacheTempTables(); err != nil { if err := s.db.CacheTempTables(ctx); err != nil {
log.Warn("Refreshing temp table cache failed: ", err) log.Warn("Refreshing temp table cache failed: ", err)
} }
log.Debug("Completed in: ", time.Since(start)) log.Debug("Completed in: ", time.Since(start))

View File

@@ -1,12 +0,0 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
packages = with pkgs; [
go
nodePackages.tailwindcss
python311Packages.grip
];
shellHook = ''
export PATH=$PATH:~/go/bin
'';
}

View File

@@ -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"
@@ -18,6 +19,10 @@ sql:
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true
- column: "documents.basepath"
go_type:
type: "string"
pointer: true
- column: "documents.coverfile" - column: "documents.coverfile"
go_type: go_type:
type: "string" type: "string"
@@ -119,7 +124,7 @@ sql:
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true
- column: "users.time_offset" - column: "users.timezone"
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true

View File

@@ -1,18 +1,26 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" <meta
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover" /> 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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" <meta
content="black-translucent" /> name="apple-mobile-web-app-status-bar-style"
<meta name="theme-color" content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6" content="#F3F4F6"
media="(prefers-color-scheme: light)" /> media="(prefers-color-scheme: light)"
<meta name="theme-color" />
<meta
name="theme-color"
content="#1F2937" content="#1F2937"
media="(prefers-color-scheme: dark)" /> media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume - {{ block "title" . }}{{ end }}</title> <title>AnthoLume - {{ block "title" . }}{{ end }}</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
@@ -32,7 +40,8 @@
html { html {
height: calc(100% + env(safe-area-inset-bottom)); 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); padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
} }
main { main {
@@ -54,10 +63,23 @@
display: none; display: none;
} }
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked + div {
visibility: visible;
opacity: 1;
}
.css-button + div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */ /* ----------------------------- */
/* ------- User Dropdown ------- */ /* ------- User Dropdown ------- */
/* ----------------------------- */ /* ----------------------------- */
#user-dropdown-button:checked+#user-dropdown { #user-dropdown-button:checked + #user-dropdown {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
@@ -72,8 +94,9 @@
/* ----------------------------- */ /* ----------------------------- */
#mobile-nav-button span { #mobile-nav-button span {
transform-origin: 5px 0px; transform-origin: 5px 0px;
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), transition:
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
opacity 0.55s ease; opacity 0.55s ease;
} }
@@ -85,26 +108,26 @@
transform-origin: 0% 100%; transform-origin: 0% 100%;
} }
#mobile-nav-button input:checked~span { #mobile-nav-button input:checked ~ span {
opacity: 1; opacity: 1;
transform: rotate(45deg) translate(2px, -2px); transform: rotate(45deg) translate(2px, -2px);
} }
#mobile-nav-button input:checked~span:nth-last-child(3) { #mobile-nav-button input:checked ~ span:nth-last-child(3) {
opacity: 0; opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2); transform: rotate(0deg) scale(0.2, 0.2);
} }
#mobile-nav-button input:checked~span:nth-last-child(2) { #mobile-nav-button input:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, 6px); transform: rotate(-45deg) translate(0, 6px);
} }
#mobile-nav-button input:checked~div { #mobile-nav-button input:checked ~ div {
transform: none; transform: none;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
#mobile-nav-button input~div { #mobile-nav-button input ~ div {
transform: none; transform: none;
} }
} }
@@ -114,12 +137,15 @@
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
transform-origin: 0% 0%; transform-origin: 0% 0%;
transform: translate(-100%, 0); transform: translate(-100%, 0);
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0); transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
} }
@media (orientation: landscape) { @media (orientation: landscape) {
#menu { #menu {
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0); transform: translate(
calc(-1 * (env(safe-area-inset-left) + 100%)),
0
);
} }
} }
</style> </style>
@@ -127,87 +153,161 @@
<body class="bg-gray-100 dark:bg-gray-800"> <body class="bg-gray-100 dark:bg-gray-800">
<div class="flex items-center justify-between w-full h-16"> <div class="flex items-center justify-between w-full h-16">
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6"> <div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
<input type="checkbox" <input
class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0" /> type="checkbox"
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span> 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-1 dark:bg-white"></span> />
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span> <span
<div id="menu" class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"
class="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"> ></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"> <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> <p
class="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0"
>
AnthoLume
</p>
</div> </div>
<div> <div>
{{ $default := "flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4" }} {{ $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"}} {{ $inactive := "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" }}
{{ $active := "border-purple-500 dark:text-white"}} {{ $active := "border-purple-500 dark:text-white" }}
<a class="{{ $default }} {{ if eq .RouteName "home" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}" <a
href="/"> class="{{ $default }} {{ if eq .RouteName "home" }}
{{ $active }}
{{ else if true }}
{{ $inactive }}
{{ end }}"
href="/"
>
{{ template "svg/home" (dict "Size" 20) }} {{ template "svg/home" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Home</span> <span class="mx-4 text-sm font-normal">Home</span>
</a> </a>
<a class="{{ $default }} {{ if eq .RouteName "documents" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}" <a
href="/documents"> class="{{ $default }} {{ if eq .RouteName "documents" }}
{{ $active }}
{{ else if true }}
{{ $inactive }}
{{ end }}"
href="/documents"
>
{{ template "svg/documents" (dict "Size" 20) }} {{ template "svg/documents" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Documents</span> <span class="mx-4 text-sm font-normal">Documents</span>
</a> </a>
<a class="{{ $default }} {{ if eq .RouteName "progress" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}" <a
href="/progress"> class="{{ $default }} {{ if eq .RouteName "progress" }}
{{ $active }}
{{ else if true }}
{{ $inactive }}
{{ end }}"
href="/progress"
>
{{ template "svg/activity" (dict "Size" 20) }} {{ template "svg/activity" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Progress</span> <span class="mx-4 text-sm font-normal">Progress</span>
</a> </a>
<a class="{{ $default }} {{ if eq .RouteName "activity" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}" <a
href="/activity"> class="{{ $default }} {{ if eq .RouteName "activity" }}
{{ $active }}
{{ else if true }}
{{ $inactive }}
{{ end }}"
href="/activity"
>
{{ template "svg/activity" (dict "Size" 20) }} {{ template "svg/activity" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Activity</span> <span class="mx-4 text-sm font-normal">Activity</span>
</a> </a>
{{ if .Config.SearchEnabled }} {{ if .Config.SearchEnabled }}
<a class="{{ $default }} {{ if eq .RouteName "search" }}{{ $active }}{{ else if true }}{{ $inactive }}{{ end }}" <a
href="/search"> class="{{ $default }} {{ if eq .RouteName "search" }}
{{ $active }}
{{ else if true }}
{{ $inactive }}
{{ end }}"
href="/search"
>
{{ template "svg/search" (dict "Size" 20) }} {{ template "svg/search" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Search</span> <span class="mx-4 text-sm font-normal">Search</span>
</a> </a>
{{ end }} {{ end }}
{{ if .Authorization.IsAdmin }} {{ 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 }}"> <div
<a href="/admin" class="flex flex-col gap-4 p-2 pl-6 my-2 transition-colors duration-200 border-l-4 {{ if hasPrefix .RouteName "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 }}"> 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) }} {{ template "svg/settings" (dict "Size" 20) }}
<span class="mx-4 text-sm font-normal">Admin</span> <span class="mx-4 text-sm font-normal">Admin</span>
</a> </a>
{{ if hasPrefix .RouteName "admin" }} {{ if hasPrefix .RouteName "admin" }}
<a href="/admin" <a
href="/admin"
style="padding-left: 1.75em" 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 }}"> 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> <span class="mx-4 text-sm font-normal">General</span>
</a> </a>
<a href="/admin/import" <a
href="/admin/import"
style="padding-left: 1.75em" 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 }}"> 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> <span class="mx-4 text-sm font-normal">Import</span>
</a> </a>
<a href="/admin/users" <a
href="/admin/users"
style="padding-left: 1.75em" 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 }}"> 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> <span class="mx-4 text-sm font-normal">Users</span>
</a> </a>
<a href="/admin/logs" <a
href="/admin/logs"
style="padding-left: 1.75em" 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 }}"> 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> <span class="mx-4 text-sm font-normal">Logs</span>
</a> </a>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
</div> </div>
<a class="flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white" <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" target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume"> href="https://gitea.va.reichard.io/evan/AnthoLume"
<svg xmlns="http://www.w3.org/2000/svg" >
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-black dark:text-white" class="text-black dark:text-white"
height="20" height="20"
viewBox="0 0 219 92" viewBox="0 0 219 92"
fill="currentColor"> fill="currentColor"
>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M159 .79h25V69h-25Zm0 0" /> <path d="M159 .79h25V69h-25Zm0 0" />
@@ -219,49 +319,77 @@
<path d="M0 .79h92V92H0Zm0 0" /> <path d="M0 .79h92V92H0Zm0 0" />
</clipPath> </clipPath>
</defs> </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" /> <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)"> <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" /> <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>
<g clip-path="url(#b)"> <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" /> <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>
<g clip-path="url(#c)"> <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" /> <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> </g>
</svg> </svg>
<span class="text-xs">{{ .Config.Version }}</span> <span class="text-xs">{{ .Config.Version }}</span>
</a> </a>
</div> </div>
</div> </div>
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{ block "header" . }}{{ end }}</h1> <h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">
{{ block "header" . }}{{ end }}
</h1>
<div class="relative flex items-center justify-end w-full p-4 space-x-4"> <div class="relative flex items-center justify-end w-full p-4 space-x-4">
<a href="#" class="relative block text-gray-800 dark:text-gray-200">{{ template "svg/user" (dict "Size" 20) }}</a> <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" /> <input type="checkbox" id="user-dropdown-button" class="hidden" />
<div id="user-dropdown" <div
class="transition duration-200 z-20 absolute right-4 top-16 pt-4"> id="user-dropdown"
<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"> class="transition duration-200 z-20 absolute right-4 top-16 pt-4"
<div class="py-1" >
<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" role="menu"
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="options-menu"> aria-labelledby="options-menu"
<a href="/settings" >
<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" class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"> role="menuitem"
>
<span class="flex flex-col"> <span class="flex flex-col">
<span>Settings</span> <span>Settings</span>
</span> </span>
</a> </a>
<a href="/local" <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" class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"> role="menuitem"
>
<span class="flex flex-col"> <span class="flex flex-col">
<span>Offline</span> <span>Offline</span>
</span> </span>
</a> </a>
<a href="/logout" <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" class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"> role="menuitem"
>
<span class="flex flex-col"> <span class="flex flex-col">
<span>Logout</span> <span>Logout</span>
</span> </span>
@@ -270,15 +398,32 @@
</div> </div>
</div> </div>
<label for="user-dropdown-button"> <label for="user-dropdown-button">
<div class="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer"> <div
class="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer"
>
<span>{{ .Authorization.UserName }}</span> <span>{{ .Authorization.UserName }}</span>
<span class="text-gray-800 dark:text-gray-200">{{ template "svg/dropdown" (dict "Size" 20) }}</span> <span class="text-gray-800 dark:text-gray-200"
>{{ template "svg/dropdown" (dict "Size" 20) }}</span
>
</div> </div>
</label> </label>
</div> </div>
</div> </div>
<main class="relative overflow-hidden"> <main class="relative overflow-hidden">
<div id="container" class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">{{ block "content" . }}{{ end }}</div> <div
id="container"
class="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"
>
{{ block "content" . }}{{ end }}
</div>
</main> </main>
<div class="absolute right-4 bottom-4">
{{ block "notifications" . }}{{ end }}
<!--
<div class="w-72 p-4 bg-red-500 rounded-xl">
<span>User Deleted</span>
</div>
-->
</div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,28 @@
{{ 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 Component - Utilizes Template "table-cell" -->
{{ template "component/table" (dict
"Columns" (slice "Document" "Time" "Duration" "Percent")
"Keys" (slice "Document" "StartTime" "Duration" "EndPercentage")
"Rows" .Data
)
}}
</div>
</div>
{{ end }}
<!-- Table Cell Definition -->
{{ define "table-cell" }}
{{ if eq .Name "Document" }}
<a href="./documents/{{ .Data.DocumentID }}"
>{{ .Data.Author }} - {{ .Data.Title }}</a
>
{{ else if eq .Name "EndPercentage" }}
{{ index (fields .Data) .Name }}%
{{ else }}
{{ index (fields .Data) .Name }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,21 @@
<!-- Variant -->
{{ $baseClass := "transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white" }}
{{ if eq .Variant "Secondary" }}
{{ $baseClass = printf "bg-black shadow-md hover:text-black hover:bg-white %s" $baseClass }}
{{ else }}
{{ $baseClass = printf "bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 %s" $baseClass }}
{{ end }}
<!-- Type -->
{{ if eq .Type "Link" }}
<a href="{{ .URL }}" class="text-center {{ $baseClass }}" type="submit"
>{{ .Title }}</a
>
{{ else }}
<button
class="{{ $baseClass }}"
type="submit"
{{ if .FormName }}form="{{ .FormName }}"{{ end }}
>
{{ .Title }}
</button>
{{ end }}

View File

@@ -0,0 +1,50 @@
<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/{{ .ID }}">
<img
class="rounded object-cover h-full"
src="./documents/{{ .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 .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 .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">{{ .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 .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={{ .ID }}">{{ template "svg/activity" }}</a>
{{ if .Filepath }}
<a href="./documents/{{ .ID }}/file">{{ template "svg/download" }}</a>
{{ else }}
{{ template "svg/download" (dict "Disabled" true) }}
{{ end }}
</div>
</div>
</div>

View File

@@ -1,5 +1,13 @@
<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"> <div
<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 }}"> 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) }} {{ if and (ne .Progress 100) (not .Error) }}
{{ template "svg/loading" (dict "Size" 16) }} {{ template "svg/loading" (dict "Size" 16) }}
{{ end }} {{ end }}
@@ -8,15 +16,27 @@
<div class="flex flex-col gap-2 mt-2"> <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"> <div class="relative w-full h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
{{ if .Error }} {{ if .Error }}
<div class="absolute h-full bg-red-500 rounded-full" style="width: 100%"></div> <div
<p class="absolute w-full h-full font-bold text-center text-xs">ERROR</p> 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 }} {{ else }}
<div class="absolute h-full bg-green-600 rounded-full" <div
style="width: {{ .Progress }}%"></div> class="absolute h-full bg-green-600 rounded-full"
<p class="absolute w-full h-full font-bold text-center text-xs">{{ .Progress }}%</p> style="width: {{ .Progress }}%"
></div>
<p class="absolute w-full h-full font-bold text-center text-xs">
{{ .Progress }}%
</p>
{{ end }} {{ end }}
</div> </div>
<a href="{{ .ButtonHref }}" <a
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> 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>
</div> </div>

View File

@@ -0,0 +1,12 @@
{{ if .Link }}<a href="{{ .Link }}" {{ else }} <div {{ end }}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">{{ .Size }}</p>
<p class="text-sm text-gray-400">{{ .Title }}</p>
</div>
</div>
{{ if .Link }}
</a>
{{ else }}
</div>
{{ end }}

View File

@@ -0,0 +1,32 @@
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>{{ .Title }}</p>
<label class="my-auto cursor-pointer" for="edit-{{ .FormValue }}-button">
{{ template "svg/edit" (dict "Size" 18) }}
</label>
<input
type="checkbox"
id="edit-{{ .FormValue }}-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="{{ .URL }}"
class="flex flex-col gap-2 text-black dark:text-white text-sm"
>
<input
type="text"
id="{{ .FormValue }}"
name="{{ .FormValue }}"
value="{{ or .Value "N/A" }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
{{ template "component/button" (dict "Title" "Save") }}
</form>
</div>
</div>
<p class="font-medium text-lg">{{ or .Value "N/A" }}</p>
</div>

View File

@@ -0,0 +1,147 @@
{{ if .Error }}
<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>
{{ template "component/button" (dict
"Title" "Back to Document"
"Type" "Link"
"URL" (printf "/documents/%s" .ID)
)
}}
</div>
</div>
{{ end }}
{{ 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/{{ .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">
<div class="flex gap-4 m-4 w-48">
{{ template "component/button" (dict
"Title" "Cancel"
"Type" "Link"
"URL" (printf "/documents/%s" .ID)
)
}}
{{ template "component/button" (dict
"Title" "Save"
"FormName" "metadata-save"
)
}}
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,54 @@
<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 .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">
{{ .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 .Window "WEEK" }}
Current Weekly Streak
{{ else }}
Current Daily Streak
{{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">
{{ .CurrentStreakStartDate }} ➞ {{ .CurrentStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">{{ .CurrentStreak }}</div>
</div>
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
<div>
<p>
{{ if eq .Window "WEEK" }}
Best Weekly Streak
{{ else }}
Best Daily Streak
{{ end }}
</p>
<div class="flex items-end text-sm text-gray-400">
{{ .MaxStreakStartDate }} ➞ {{ .MaxStreakEndDate }}
</div>
</div>
<div class="flex items-end font-bold">{{ .MaxStreak }}</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,32 @@
{{ $rows := .Rows }}
{{ $cols := .Columns }}
{{ $keys := .Keys }}
<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>
{{ range $col := $cols }}
<th
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
{{ $col }}
</th>
{{ end }}
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not $rows }}
<tr>
<td class="text-center p-3" colspan="4">No Results</td>
</tr>
{{ end }}
{{ range $row := $rows }}
<tr>
{{ range $key := $keys }}
<td class="p-3 border-b border-gray-200">
{{ template "table-cell" (dict "Data" $row "Name" $key ) }}
</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>

View File

@@ -2,43 +2,27 @@
{{ define "title" }}Activity{{ end }} {{ define "title" }}Activity{{ end }}
{{ define "header" }}<a href="./activity">Activity</a>{{ end }} {{ define "header" }}<a href="./activity">Activity</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> <!-- Table Component - Utilizes Template "table-cell" -->
<thead class="text-gray-800 dark:text-gray-400"> {{ template "component/table" (dict
<tr> "Columns" (slice "Document" "Time" "Duration" "Percent")
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th> "Keys" (slice "Document" "StartTime" "Duration" "EndPercentage")
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Time</th> "Rows" .Data
<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> </div>
</thead> </div>
<tbody class="text-black dark:text-white"> {{ end }}
{{ if not .Data }} <!-- Table Cell Definition -->
<tr> {{ define "table-cell" }}
<td class="text-center p-3" colspan="4">No Results</td> {{ if eq .Name "Document" }}
</tr> <a href="./documents/{{ .Data.DocumentID }}"
{{ end }} >{{ .Data.Author }} - {{ .Data.Title }}</a
{{ range $activity := .Data }} >
<tr> {{ else if eq .Name "EndPercentage" }}
<td class="p-3 border-b border-gray-200"> {{ index (fields .Data) .Name }}%
<a href="./documents/{{ $activity.DocumentID }}">{{ $activity.Author }} - {{ $activity.Title }} {{ else }}
</p> {{ index (fields .Data) .Name }}
</a> {{ end }}
</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 }} {{ end }}

View File

@@ -0,0 +1,64 @@
{{ template "base" . }}
{{ define "title" }}Admin - Import Results{{ end }}
{{ define "header" }}
<a class="whitespace-pre" href="../admin">Admin - Import Results</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"
>
Status
</th>
<th
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Error
</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 $result := .Data }}
<tr>
<td
class="p-3 border-b border-gray-200 grid"
style="grid-template-columns: 4rem auto"
>
<span class="text-gray-800 dark:text-gray-400">Name:</span>
{{ if (eq $result.ID "") }}
<span>N/A</span>
{{ else }}
<a href="../documents/{{ $result.ID }}">{{ $result.Name }}</a>
{{ end }}
<span class="text-gray-800 dark:text-gray-400">File:</span>
<span>{{ $result.Path }}</span>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Status }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Error }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

View File

@@ -1,54 +1,82 @@
{{ template "base" . }} {{ template "base" . }}
{{ define "title" }}Admin - Import{{ end }} {{ define "title" }}Admin - Import{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import</a>{{ end }} {{ define "header" }}
<a class="whitespace-pre" href="../admin">Admin - Import</a>
{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <div class="inline-block min-w-full overflow-hidden rounded shadow">
{{ if .SelectedDirectory }} {{ 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"> <div
<p class="text-lg font-semibold text-gray-500">Selected Import Directory</p> 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"> <form class="flex gap-4 flex-col" action="./import" method="POST">
<input type="text" <input
type="text"
name="directory" name="directory"
value="{{ .SelectedDirectory }}" value="{{ .SelectedDirectory }}"
class="hidden" /> class="hidden"
/>
<div class="flex justify-between gap-4 w-full"> <div class="flex justify-between gap-4 w-full">
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span>{{ template "svg/import" }}</span> <span>{{ template "svg/import" }}</span>
<p class="font-medium text-lg break-all">{{ .SelectedDirectory }}</p> <p class="font-medium text-lg break-all">
{{ .SelectedDirectory }}
</p>
</div> </div>
<div class="flex flex-col justify-around gap-2 mr-4"> <div class="flex flex-col justify-around gap-2 mr-4">
<div class="inline-flex gap-2 items-center"> <div class="inline-flex gap-2 items-center">
<input checked type="radio" id="copy" name="type" value="COPY" /> <input
<label for="copy">Copy</label> checked
</div> type="radio"
<div class="inline-flex gap-2 items-center"> id="direct"
<input type="radio" id="direct" name="type" value="DIRECT" /> name="type"
value="DIRECT"
/>
<label for="direct">Direct</label> <label for="direct">Direct</label>
</div> </div>
<div class="inline-flex gap-2 items-center">
<input type="radio" id="copy" name="type" value="COPY" />
<label for="copy">Copy</label>
</div> </div>
</div> </div>
<button type="submit" </div>
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"> <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> <span class="w-full">Import Directory</span>
</button> </button>
</form> </form>
</div> </div>
{{ end }} {{ end }}
{{ if not .SelectedDirectory }} {{ if not .SelectedDirectory }}
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> <table
class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"
>
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 w-12"></th> <th
<th class="p-3 font-normal text-left border-b border-gray-200 dark:border-gray-800 break-all">{{ .CurrentPath }}</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> </tr>
</thead> </thead>
<tbody class="text-black dark:text-white"> <tbody class="text-black dark:text-white">
{{ if not (eq .CurrentPath "/") }} {{ if not (eq .CurrentPath "/") }}
<tr> <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 text-gray-800 dark:text-gray-400"
></td>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<a href="./import?directory={{$.CurrentPath}}/../"> <a href="./import?directory={{ $.CurrentPath }}/../">
<p>../</p> <p>../</p>
</a> </a>
</td> </td>
@@ -61,8 +89,12 @@
{{ end }} {{ end }}
{{ range $item := .Data }} {{ range $item := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400"> <td
<a href="./import?select={{ $.CurrentPath }}/{{ $item }}">{{ template "svg/import" }}</a> 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>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<a href="./import?directory={{ $.CurrentPath }}/{{ $item }}"> <a href="./import?directory={{ $.CurrentPath }}/{{ $item }}">
@@ -75,5 +107,5 @@
</table> </table>
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -1,34 +1,47 @@
{{ template "base" . }} {{ template "base" . }}
{{ define "title" }}Admin - Logs{{ end }} {{ define "title" }}Admin - Logs{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Logs</a>{{ end }} {{ define "header" }}
<a class="whitespace-pre" href="../admin">Admin - Logs</a>
{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"> <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"> <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 flex-col w-full grow">
<div class="flex relative"> <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"> <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) }} {{ template "svg/search2" (dict "Size" 15) }}
</span> </span>
<input type="text" <input
type="text"
id="filter" id="filter"
name="filter" name="filter"
value="{{ .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" 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" /> placeholder="JQ Filter"
/>
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
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"> {{ template "component/button" (dict
<span class="w-full">Filter</span> "Title" "Filter"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
</div> </div>
<!-- Required for iOS "Hover" Events (onclick) --> <!-- Required for iOS "Hover" Events (onclick) -->
<div onclick <div
onclick
class="flex flex-col-reverse text-black dark:text-white w-full overflow-scroll" class="flex flex-col-reverse text-black dark:text-white w-full overflow-scroll"
style="font-family: monospace"> style="font-family: monospace"
>
{{ range $log := .Data }} {{ range $log := .Data }}
<span class="whitespace-nowrap hover:whitespace-pre">{{ $log }}</span> <span class="whitespace-nowrap hover:whitespace-pre">{{ $log }}</span>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}

View File

@@ -2,15 +2,40 @@
{{ define "title" }}Admin - Users{{ end }} {{ define "title" }}Admin - Users{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Users</a>{{ end }} {{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Users</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="overflow-x-auto"> <div class="relative h-full overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <input type="checkbox" id="add-button" class="hidden peer/add" />
<div class="absolute top-10 left-10 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600 hidden peer-checked/add:block">
<form method="POST"
action="./users"
class="flex flex-col gap-2 text-black dark:text-white text-sm">
<input type="text"
id="operation"
name="operation"
value="CREATE"
class="hidden" />
<input type="text"
id="user"
name="user"
placeholder="User"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
<input type="password"
id="password"
name="password"
placeholder="Password"
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">Create</button>
</form>
</div>
<div class="min-w-full overflow-scroll rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> <table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-12"> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 w-12">
{{ template "svg/add" }} <label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
</th> </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">User</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center"> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
Permissions Permissions
</th> </th>
@@ -25,13 +50,75 @@
{{ end }} {{ end }}
{{ range $user := .Data }} {{ range $user := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400">{{ template "svg/delete" }}</td> <!-- User Deletion -->
<td class="p-3 border-b border-gray-200 text-gray-800 dark:text-gray-400 cursor-pointer relative">
<label for="delete-{{ $user.ID }}-button" class="cursor-pointer">{{ template "svg/delete" }}</label>
<input type="checkbox"
id="delete-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 top-1.5 left-10 p-1.5 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="./users"
class="text-black dark:text-white text-sm w-40">
<input type="hidden" id="operation" name="operation" value="DELETE" />
<input type="hidden" id="user" name="user" value="{{ $user.ID }}" />
{{ template "component/button" (dict "Title" (printf "Delete (%s)" $user.ID )) }}
</form>
</div>
</td>
<!-- User ID -->
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p> <p>{{ $user.ID }}</p>
</td> </td>
<td class="p-3 border-b border-gray-200 text-center min-w-40"> <!-- User Password Change -->
<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> <td class="border-b border-gray-200 relative px-3">
<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> <label for="edit-{{ $user.ID }}-button" class="cursor-pointer">
<span 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">Reset</span>
</label>
<input type="checkbox"
id="edit-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 top-1 left-16 ml-2 p-1.5 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="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="hidden" id="operation" name="operation" value="UPDATE" />
<input type="hidden" id="user" name="user" value="{{ $user.ID }}" />
<input type="password"
id="password"
name="password"
placeholder="{{ printf "Password (%s)" $user.ID }}"
class="p-1.5 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">Change</button>
</form>
</div>
</td>
<!-- User Role -->
<td class="flex gap-2 justify-center p-3 border-b border-gray-200 text-center min-w-40">
<!-- Set Admin & User Styles -->
{{ $adminStyle := "bg-gray-400 dark:bg-gray-600 cursor-pointer" }}
{{ $userStyle := "bg-gray-400 dark:bg-gray-600 cursor-pointer" }}
{{ if $user.Admin }}{{ $adminStyle = "bg-gray-800 dark:bg-gray-100 cursor-default" }}{{ end }}
{{ if not $user.Admin }}{{ $userStyle = "bg-gray-800 dark:bg-gray-100 cursor-default" }}{{ end }}
<form method="POST"
action="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="hidden" id="operation" name="operation" value="UPDATE" />
<input type="hidden" id="user" name="user" value="{{ $user.ID }}" />
<input type="hidden" id="is_admin" name="is_admin" value="true" />
<button {{ if $user.Admin }}type="button"{{ else }}type="submit"{{ end }} class="px-2 py-1 rounded-md text-white dark:text-black {{ $adminStyle }}">admin
</button>
</form>
<form method="POST"
action="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="hidden" id="operation" name="operation" value="UPDATE" />
<input type="hidden" id="user" name="user" value="{{ $user.ID }}" />
<input type="hidden" id="is_admin" name="is_admin" value="false" />
<button {{ if $user.Admin }}type="submit"{{ else }}type="button"{{ end }} class="px-2 py-1 rounded-md text-white dark:text-black {{ $userStyle }}">user
</form>
</td> </td>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $user.CreatedAt }}</p> <p>{{ $user.CreatedAt }}</p>
@@ -40,6 +127,6 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -1,43 +1,67 @@
{{ template "base" . }} {{ template "base" . }}
{{ define "title" }}Admin - General{{ end }} {{ define "title" }}Admin - General{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="./admin">Admin - General</a>{{ end }} {{ define "header" }}
<a class="whitespace-pre" href="./admin">Admin - General</a>
{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="w-full flex flex-col gap-4 grow"> <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"> <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> <p class="text-lg font-semibold mb-2">Backup & Restore</p>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<form class="flex justify-between" action="./admin" method="POST"> <form class="flex justify-between" action="./admin" method="POST">
<input type="text" name="action" value="BACKUP" class="hidden" /> <input type="text" name="action" value="BACKUP" class="hidden" />
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<div> <div>
<input type="checkbox" id="backup_covers" name="backup_types" value="COVERS" /> <input
type="checkbox"
id="backup_covers"
name="backup_types"
value="COVERS"
/>
<label for="backup_covers">Covers</label> <label for="backup_covers">Covers</label>
</div> </div>
<div> <div>
<input type="checkbox" <input
type="checkbox"
id="backup_documents" id="backup_documents"
name="backup_types" name="backup_types"
value="DOCUMENTS" /> value="DOCUMENTS"
/>
<label for="backup_documents">Documents</label> <label for="backup_documents">Documents</label>
</div> </div>
</div> </div>
<button type="submit" <div class="w-40 h-10">
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"> {{ template "component/button" (dict
<span class="w-full">Backup</span> "Title" "Backup"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
<form method="POST" <form
method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
action="./admin" action="./admin"
class="flex justify-between grow"> class="flex justify-between grow"
>
<input type="text" name="action" value="RESTORE" class="hidden" /> <input type="text" name="action" value="RESTORE" class="hidden" />
<div class="flex items-center w-1/2"> <div class="flex items-center w-1/2">
<input type="file" accept=".zip" name="restore_file" class="w-full" /> <input
type="file"
accept=".zip"
name="restore_file"
class="w-full"
/>
</div>
<div class="w-40 h-10">
{{ template "component/button" (dict
"Title" "Restore"
"Variant" "Secondary"
)
}}
</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">Restore</span>
</button>
</form> </form>
</div> </div>
{{ if .PasswordErrorMessage }} {{ if .PasswordErrorMessage }}
@@ -46,7 +70,9 @@
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span> <span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
{{ end }} {{ end }}
</div> </div>
<div class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"> <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> <p class="text-lg font-semibold">Tasks</p>
<table class="min-w-full bg-white dark:bg-gray-700 text-sm"> <table class="min-w-full bg-white dark:bg-gray-700 text-sm">
<tbody class="text-black dark:text-white"> <tbody class="text-black dark:text-white">
@@ -56,11 +82,19 @@
</td> </td>
<td class="py-2 float-right"> <td class="py-2 float-right">
<form action="./admin" method="POST"> <form action="./admin" method="POST">
<input type="text" name="action" value="METADATA_MATCH" class="hidden" /> <input
<button type="submit" type="text"
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"> name="action"
<span class="w-full">Run</span> value="METADATA_MATCH"
</button> class="hidden"
/>
<div class="w-40 h-10 text-base">
{{ template "component/button" (dict
"Title" "Run"
"Variant" "Secondary"
)
}}
</div>
</form> </form>
</td> </td>
</tr> </tr>
@@ -70,16 +104,24 @@
</td> </td>
<td class="py-2 float-right"> <td class="py-2 float-right">
<form action="./admin" method="POST"> <form action="./admin" method="POST">
<input type="text" name="action" value="CACHE_TABLES" class="hidden" /> <input
<button type="submit" type="text"
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"> name="action"
<span class="w-full">Run</span> value="CACHE_TABLES"
</button> class="hidden"
/>
<div class="w-40 h-10 text-base">
{{ template "component/button" (dict
"Title" "Run"
"Variant" "Secondary"
)
}}
</div>
</form> </form>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -2,17 +2,26 @@
{{ define "title" }}Documents{{ end }} {{ define "title" }}Documents{{ end }}
{{ define "header" }}<a href="/documents">Documents</a>{{ end }} {{ define "header" }}<a href="/documents">Documents</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="h-full w-full relative"> <div class="h-full w-full relative">
<!-- Document Info --> <!-- 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
<div class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"> 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"> <label class="z-10 cursor-pointer" for="edit-cover-button">
<img class="rounded object-fill w-full" <img
src="/documents/{{.Data.ID}}/cover" /> class="rounded object-fill w-full"
src="/documents/{{ .Data.ID }}/cover"
/>
</label> </label>
{{ if .Data.Filepath }} {{ if .Data.Filepath }}
<a href="/reader#id={{ .Data.ID }}&type=REMOTE" <a
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> 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 }} {{ end }}
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative"> <div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
<div class="min-w-[50%] md:mr-2"> <div class="min-w-[50%] md:mr-2">
@@ -25,74 +34,114 @@
<p class="font-medium">{{ or .Data.Isbn13 "N/A" }}</p> <p class="font-medium">{{ or .Data.Isbn13 "N/A" }}</p>
</div> </div>
</div> </div>
<div class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500"> <div
<input type="checkbox" id="edit-cover-button" class="hidden css-button" /> class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500"
<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" <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" enctype="multipart/form-data"
action="./{{ .Data.ID }}/edit" action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> 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" <input type="file" id="cover_file" name="cover_file" />
type="submit">Upload Cover</button> {{ template "component/button" (dict "Title" "Upload Cover") }}
</form> </form>
<form method="POST" <form
method="POST"
action="./{{ .Data.ID }}/edit" action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"> class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
<input type="checkbox" >
<input
type="checkbox"
checked checked
id="remove_cover" id="remove_cover"
name="remove_cover" name="remove_cover"
class="hidden" /> 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> {{ template "component/button" (dict "Title" "Remove Cover") }}
</form> </form>
</div> </div>
<div class="relative"> <div class="relative">
<label for="delete-button" class="cursor-pointer">{{ template "svg/delete" (dict "Size" 28) }}</label> <label for="delete-button" class="cursor-pointer"
<input type="checkbox" id="delete-button" class="hidden css-button" /> >{{ template "svg/delete" (dict "Size" 28) }}</label
<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" <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" action="./{{ .Data.ID }}/delete"
class="text-black dark:text-white text-sm"> class="text-black dark:text-white text-sm w-24"
<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> {{ template "component/button" (dict "Title" "Delete") }}
</form> </form>
</div> </div>
</div> </div>
<a href="../activity?document={{ .Data.ID }}">{{ template "svg/activity" (dict "Size" 28) }}</a> <a href="../activity?document={{ .Data.ID }}"
>{{ template "svg/activity" (dict "Size" 28) }}</a
>
<div class="relative"> <div class="relative">
<label for="search-button">{{ template "svg/search" (dict "Size" 28) }}</label> <label for="search-button"
<input type="checkbox" id="search-button" class="hidden css-button" /> >{{ template "svg/search" (dict "Size" 28) }}</label
<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" <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" action="./{{ .Data.ID }}/identify"
class="flex flex-col gap-2 text-black dark:text-white text-sm"> class="flex flex-col gap-2 text-black dark:text-white text-sm"
<input type="text" >
<input
type="text"
id="title" id="title"
name="title" name="title"
placeholder="Title" placeholder="Title"
value="{{ or .Data.Title nil }}" value="{{ or .Data.Title nil }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
<input type="text" />
<input
type="text"
id="author" id="author"
name="author" name="author"
placeholder="Author" placeholder="Author"
value="{{ or .Data.Author nil }}" value="{{ or .Data.Author nil }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
<input type="text" />
<input
type="text"
id="isbn" id="isbn"
name="isbn" name="isbn"
placeholder="ISBN 10 / ISBN 13" placeholder="ISBN 10 / ISBN 13"
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}" value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"> 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> {{ template "component/button" (dict "Title" "Identify") }}
</form> </form>
</div> </div>
</div> </div>
{{ if .Data.Filepath }} {{ if .Data.Filepath }}
<a href="./{{.Data.ID}}/file">{{ template "svg/download" (dict "Size" 28) }}</a> <a href="./{{ .Data.ID }}/file"
>{{ template "svg/download" (dict "Size" 28) }}</a
>
{{ else }} {{ else }}
{{ template "svg/download" (dict "Size" 28 "Disabled" true) }} {{ template "svg/download" (dict "Size" 28 "Disabled" true) }}
{{ end }} {{ end }}
@@ -100,49 +149,39 @@
</div> </div>
</div> </div>
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4"> <div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
<div class="relative"> {{ template "component/key-val-edit" (dict
<div class="text-gray-500 inline-flex gap-2 relative"> "Title" "Title"
<p>Title</p> "Value" .Data.Title
<label class="my-auto" for="edit-title-button">{{ template "svg/edit" (dict "Size" 18) }}</label> "URL" (printf "./%s/edit" .Data.ID)
<input type="checkbox" id="edit-title-button" class="hidden css-button" /> "FormValue" "title"
<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" {{ template "component/key-val-edit" (dict
class="flex flex-col gap-2 text-black dark:text-white text-sm"> "Title" "Author"
<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"> "Value" .Data.Author
<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" "URL" (printf "./%s/edit" .Data.ID)
type="submit">Save</button> "FormValue" "author"
</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="relative">
<div class="text-gray-500 inline-flex gap-2 relative"> <div class="text-gray-500 inline-flex gap-2 relative">
<p>Time Read</p> <p>Time Read</p>
<label class="my-auto" for="progress-info-button">{{ template "svg/info" (dict "Size" 18) }}</label> <label class="my-auto" for="progress-info-button"
<input type="checkbox" id="progress-info-button" class="hidden css-button" /> >{{ template "svg/info" (dict "Size" 18) }}</label
<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"> >
<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"> <div class="text-xs flex">
<p class="text-gray-400 w-32">Seconds / Percent</p> <p class="text-gray-400 w-32">Seconds / Percent</p>
<p class="font-medium dark:text-white">{{ .Data.SecondsPerPercent }}</p> <p class="font-medium dark:text-white">
{{ .Data.SecondsPerPercent }}
</p>
</div> </div>
<div class="text-xs flex"> <div class="text-xs flex">
<p class="text-gray-400 w-32">Words / Minute</p> <p class="text-gray-400 w-32">Words / Minute</p>
@@ -150,11 +189,15 @@
</div> </div>
<div class="text-xs flex"> <div class="text-xs flex">
<p class="text-gray-400 w-32">Est. Time Left</p> <p class="text-gray-400 w-32">Est. Time Left</p>
<p class="font-medium dark:text-white whitespace-nowrap">{{ niceSeconds .TotalTimeLeftSeconds }}</p> <p class="font-medium dark:text-white whitespace-nowrap">
{{ niceSeconds .TotalTimeLeftSeconds }}
</p>
</div> </div>
</div> </div>
</div> </div>
<p class="font-medium text-lg">{{ niceSeconds .Data.TotalTimeSeconds }}</p> <p class="font-medium text-lg">
{{ niceSeconds .Data.TotalTimeSeconds }}
</p>
</div> </div>
<div> <div>
<p class="text-gray-500">Progress</p> <p class="text-gray-500">Progress</p>
@@ -164,136 +207,48 @@
<div class="relative"> <div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative"> <div class="text-gray-500 inline-flex gap-2 relative">
<p>Description</p> <p>Description</p>
<label class="my-auto" for="edit-description-button">{{ template "svg/edit" (dict "Size" 18) }}</label> <label class="my-auto" for="edit-description-button"
>{{ template "svg/edit" (dict "Size" 18) }}</label
>
</div> </div>
</div> </div>
<div class="relative font-medium text-justify hyphens-auto"> <div class="relative font-medium text-justify hyphens-auto">
<input type="checkbox" <input
type="checkbox"
id="edit-description-button" id="edit-description-button"
class="hidden css-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" <div
src="/documents/{{.Data.ID}}/cover" /> class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200"
<form method="POST" >
<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" 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"> 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" >
<textarea
type="text"
id="description" id="description"
name="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> class="h-full w-full 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> {{ or .Data.Description "N/A" }}</textarea
>
{{ template "component/button" (dict "Title" "Save") }}
</form> </form>
</div> </div>
<p>{{ or .Data.Description "N/A" }}</p> <p>{{ or .Data.Description "N/A" }}</p>
</div> </div>
</div> </div>
{{ if .MetadataError }} {{ template "component/metadata" (dict
<div class="absolute top-0 left-0 w-full h-full z-50"> "ID" .Data.ID
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> "Metadata" .Metadata
<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"> "Error" .MetadataError
<div class="text-center"> )
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">No Metadata Results Found</h3> }}
</div> </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 }} {{ end }}

View File

@@ -2,119 +2,98 @@
{{ define "title" }}Documents{{ end }} {{ define "title" }}Documents{{ end }}
{{ define "header" }}<a href="./documents">Documents</a>{{ end }} {{ define "header" }}<a href="./documents">Documents</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"> <div
<form class="flex gap-4 flex-col lg:flex-row" 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" action="./documents"
method="GET"> method="GET"
>
<div class="flex flex-col w-full grow"> <div class="flex flex-col w-full grow">
<div class="flex relative"> <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"> <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) }} {{ template "svg/search2" (dict "Size" 15) }}
</span> </span>
<input type="text" <input
type="text"
id="search" id="search"
name="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" 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" /> placeholder="Search Author / Title"
/>
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
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"> {{ template "component/button" (dict
<span class="w-full">Search</span> "Title" "Search"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ range $doc := .Data }} {{ range $doc := .Data }}
<div class="w-full relative"> {{ template "component/document-card" $doc }}
<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 }} {{ end }}
</div> </div>
</div> <div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
</div>
{{ end }}
</div>
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
{{ if .PreviousPage }} {{ if .PreviousPage }}
<a href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}" <a
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> 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 }} {{ end }}
{{ if .NextPage }} {{ if .NextPage }}
<a href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}" <a
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> 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 }} {{ end }}
</div> </div>
<div class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"> <div
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
>
<input type="checkbox" id="upload-file-button" class="hidden css-button" /> <input type="checkbox" id="upload-file-button" class="hidden css-button" />
<div class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"> <div
<form method="POST" class="absolute right-0 z-10 bottom-0 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" enctype="multipart/form-data"
action="./documents" action="./documents"
class="flex flex-col gap-2"> 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" <input
type="submit">Upload File</button> 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> </form>
<label for="upload-file-button"> <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"> <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 Cancel Upload
</div> </div>
</label> </label>
</div> </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" <label
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</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"
</div> for="upload-file-button"
<style> >{{ template "svg/upload" (dict "Size" 34) }}</label
.css-button:checked+div { >
display: block; </div>
opacity: 1;
}
.css-button+div {
display: none;
opacity: 0;
}
.css-button:checked+div+label {
display: none;
}
</style>
{{ end }} {{ end }}

View File

@@ -1,30 +1,53 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" <meta
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover" /> 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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" <meta
content="black-translucent" /> name="apple-mobile-web-app-status-bar-style"
<meta name="theme-color" content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6" content="#F3F4F6"
media="(prefers-color-scheme: light)" /> media="(prefers-color-scheme: light)"
<meta name="theme-color" />
<meta
name="theme-color"
content="#1F2937" content="#1F2937"
media="(prefers-color-scheme: dark)" /> media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume - Error</title> <title>AnthoLume - Error</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
</head> </head>
<body class="bg-gray-100 dark:bg-gray-800 flex flex-col justify-center h-screen"> <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="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center"> <div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500">{{ .Status }}</h1> <h1
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">{{ .Error }}</p> class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-gray-600 dark:text-gray-500"
<p class="mb-8 text-lg font-light text-gray-500 dark:text-gray-400">{{ .Message }}</p> >
<a href="/" {{ .Status }}
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> </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>
</div> </div>
</body> </body>

View File

@@ -2,37 +2,55 @@
{{ define "title" }}Home{{ end }} {{ define "title" }}Home{{ end }}
{{ define "header" }}<a href="./">Home</a>{{ end }} {{ define "header" }}<a href="./">Home</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="w-full"> <div class="w-full">
<div class="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded"> <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"> <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 Daily Read Totals
</p> </p>
{{ $data := (getSVGGraphData .Data.GraphData 800 70 )}} {{ $data := (getSVGGraphData .Data.GraphData 800 70 ) }}
<div class="relative"> <div class="relative">
<svg viewBox="26 0 755 {{ $data.Height }}" <svg
viewBox="26 0 755 {{ $data.Height }}"
preserveAspectRatio="none" preserveAspectRatio="none"
width="100%" width="100%"
height="6em"> height="6em"
>
<!-- Bezier Line Graph --> <!-- Bezier Line Graph -->
<path fill="#316BBE" fill-opacity="0.5" stroke="none" d="{{ $data.BezierPath }} {{ $data.BezierFill }}" /> <path
fill="#316BBE"
fill-opacity="0.5"
stroke="none"
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
/>
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" /> <path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
</svg> </svg>
<div class="flex absolute w-full h-full top-0" <div
class="flex absolute w-full h-full top-0"
style="width: calc(100%*31/30); style="width: calc(100%*31/30);
transform: translateX(-50%); transform: translateX(-50%);
left: 50%"> left: 50%"
>
{{ range $index, $item := $data.LinePoints }} {{ range $index, $item := $data.LinePoints }}
<!-- Required for iOS "Hover" Events (onclick) --> <!-- Required for iOS "Hover" Events (onclick) -->
<div onclick <div
onclick
class="opacity-0 hover:opacity-100 w-full" 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%"> 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" >
<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%); style="transform: translateX(-50%);
background-color: rgba(128, 128, 128, 0.2); background-color: rgba(128, 128, 128, 0.2);
left: 50%"> left: 50%"
>
<span>{{ (index $.Data.GraphData $index).Date }}</span> <span>{{ (index $.Data.GraphData $index).Date }}</span>
<span>{{ (index $.Data.GraphData $index).MinutesRead }} minutes</span> <span
>{{ (index $.Data.GraphData $index).MinutesRead }}
minutes</span
>
</div> </div>
</div> </div>
{{ end }} {{ end }}
@@ -41,97 +59,51 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4"> <div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<a href="./documents" class="w-full"> {{ template "component/info-card" (dict
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> "Title" "Documents"
<div class="flex flex-col justify-around dark:text-white w-full text-sm"> "Size" .Data.DatabaseInfo.DocumentsSize
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.DocumentsSize }}</p> "Link" "./documents"
<p class="text-sm text-gray-400">Documents</p> )
</div> }}
</div> {{ template "component/info-card" (dict
</a> "Title" "Activity Records"
<a href="./activity" class="w-full"> "Size" .Data.DatabaseInfo.ActivitySize
<div class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> "Link" "./activity"
<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> {{ template "component/info-card" (dict
</div> "Title" "Progress Records"
</div> "Size" .Data.DatabaseInfo.ProgressSize
</a> "Link" "./progress"
<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"> {{ template "component/info-card" (dict
<p class="text-2xl font-bold text-black dark:text-white">{{ .Data.DatabaseInfo.ProgressSize }}</p> "Title" "Devices"
<p class="text-sm text-gray-400">Progress Records</p> "Size" .Data.DatabaseInfo.DevicesSize
</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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{{ range $item := .Data.Streaks }} {{ range $item := .Data.Streaks }}
<div class="w-full"> {{ template "component/streak-card" $item }}
<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 }} {{ end }}
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ template "component/leaderboard-card" (dict {{ template "component/leaderboard-card" (dict
"Name" "WPM" "Name" "WPM"
"Data" .Data.UserStatistics.WPM "Data" .Data.UserStatistics.WPM
)}} )
}}
{{ template "component/leaderboard-card" (dict {{ template "component/leaderboard-card" (dict
"Name" "Duration" "Name" "Duration"
"Data" .Data.UserStatistics.Duration "Data" .Data.UserStatistics.Duration
)}} )
}}
{{ template "component/leaderboard-card" (dict {{ template "component/leaderboard-card" (dict
"Name" "Words" "Name" "Words"
"Data" .Data.UserStatistics.Words "Data" .Data.UserStatistics.Words
)}} )
}}
</div>
</div> </div>
</div>
{{ end }} {{ end }}

View File

@@ -1,19 +1,29 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" <meta
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover" /> 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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" <meta
content="black-translucent" /> name="apple-mobile-web-app-status-bar-style"
<meta name="theme-color" content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6" content="#F3F4F6"
media="(prefers-color-scheme: light)" /> media="(prefers-color-scheme: light)"
<meta name="theme-color" />
<meta
name="theme-color"
content="#1F2937" content="#1F2937"
media="(prefers-color-scheme: dark)" /> media="(prefers-color-scheme: dark)"
<title>AnthoLume - {{ if .Register }}Register{{ else }}Login{{ end }}</title> />
<title>
AnthoLume - {{ if .Register }}Register{{ else }}Login{{ end }}
</title>
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./assets/style.css" /> <link rel="stylesheet" href="./assets/style.css" />
<!-- Service Worker / Offline Cache Flush --> <!-- Service Worker / Offline Cache Flush -->
@@ -32,7 +42,8 @@
html { html {
height: calc(100% + env(safe-area-inset-bottom)); 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); padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
} }
/* No Scrollbar - IE, Edge, Firefox */ /* No Scrollbar - IE, Edge, Firefox */
@@ -50,43 +61,60 @@
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white"> <body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<div class="flex flex-wrap w-full"> <div class="flex flex-wrap w-full">
<div class="flex flex-col w-full md:w-1/2"> <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"> <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> <p class="text-3xl text-center">Welcome.</p>
<form <form
class="flex flex-col pt-3 md:pt-8" class="flex flex-col pt-3 md:pt-8"
{{if {{ if
.Register}}action="./register" .Register
{{ else }}action="./login" }}
action="./register"
{{ else }}
action="./login"
{{ end }} {{ end }}
method="POST" method="POST"
> >
<div class="flex flex-col pt-4"> <div class="flex flex-col pt-4">
<div class="flex relative"> <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"> <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) }} {{ template "svg/user" (dict "Size" 15) }}
</span> </span>
<input type="text" <input
type="text"
id="username" id="username"
name="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" 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" /> placeholder="Username"
/>
</div> </div>
</div> </div>
<div class="flex flex-col pt-4 mb-12"> <div class="flex flex-col pt-4 mb-12">
<div class="flex relative"> <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"> <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) }} {{ template "svg/password" (dict "Size" 15) }}
</span> </span>
<input type="password" <input
type="password"
id="password" id="password"
name="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" 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" /> placeholder="Password"
<span class="absolute -bottom-5 text-red-400 text-xs">{{ .Error }}</span> />
<span class="absolute -bottom-5 text-red-400 text-xs"
>{{ .Error }}</span
>
</div> </div>
</div> </div>
<button type="submit" <button
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"> 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 }} {{ if .Register }}
<span class="w-full">Register</span> <span class="w-full">Register</span>
{{ else }} {{ else }}
@@ -95,32 +123,50 @@
</button> </button>
</form> </form>
<div class="pt-12 pb-12 text-center"> <div class="pt-12 pb-12 text-center">
{{ if .Config.RegistrationEnabled }} {{ if .Register }} {{ if .Config.RegistrationEnabled }}
{{ if .Register }}
<p> <p>
Trying to login? Trying to login?
<a href="./login" class="font-semibold underline">Login here.</a> <a href="./login" class="font-semibold underline"
>Login here.</a
>
</p> </p>
{{ else }} {{ else }}
<p> <p>
Don&#x27;t have an account? Don&#x27;t have an account?
<a href="./register" class="font-semibold underline">Register here.</a> <a href="./register" class="font-semibold underline"
>Register here.</a
>
</p> </p>
{{ end }} {{ end }} {{ end }}
{{ end }}
<p class="mt-4"> <p class="mt-4">
<a href="./local" class="font-semibold underline">Offline / Local Mode</a> <a href="./local" class="font-semibold underline"
>Offline / Local Mode</a
>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block"> <div
<img class="w-full h-screen object-cover ease-in-out top-0 left-0" class="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block"
src="/assets/images/book1.jpg" /> >
<img class="w-full h-screen object-cover ease-in-out top-0 left-0" <img
src="/assets/images/book2.jpg" /> class="w-full h-screen object-cover ease-in-out top-0 left-0"
<img class="w-full h-screen object-cover ease-in-out top-0 left-0" src="/assets/images/book1.jpg"
src="/assets/images/book3.jpg" /> />
<img class="w-full h-screen object-cover ease-in-out top-0 left-0" <img
src="/assets/images/book4.jpg" /> 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>
</div> </div>
<style> <style>

View File

@@ -2,43 +2,27 @@
{{ define "title" }}Progress{{ end }} {{ define "title" }}Progress{{ end }}
{{ define "header" }}<a href="./progress">Progress</a>{{ end }} {{ define "header" }}<a href="./progress">Progress</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"> <!-- Table Component - Utilizes Template "table-cell" -->
<thead class="text-gray-800 dark:text-gray-400"> {{ template "component/table" (dict
<tr> "Columns" (slice "Document" "Device Name" "Percentage" "Created At")
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th> "Keys" (slice "Document" "DeviceName" "Percentage" "CreatedAt")
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Device</th> "Rows" .Data
<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> </div>
</thead> </div>
<tbody class="text-black dark:text-white"> {{ end }}
{{ if not .Data }} <!-- Table Cell Definition -->
<tr> {{ define "table-cell" }}
<td class="text-center p-3" colspan="4">No Results</td> {{ if eq .Name "Document" }}
</tr> <a href="./documents/{{ .Data.DocumentID }}"
{{ end }} >{{ .Data.Author }} - {{ .Data.Title }}</a
{{ range $progress := .Data }} >
<tr> {{ else if eq .Name "Percentage" }}
<td class="p-3 border-b border-gray-200"> {{ index (fields .Data) .Name }}%
<a href="./documents/{{ $progress.DocumentID }}">{{ $progress.Author }} - {{ $progress.Title }} {{ else }}
</p> {{ index (fields .Data) .Name }}
</a> {{ end }}
</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 }} {{ end }}

View File

@@ -2,59 +2,93 @@
{{ define "title" }}Search{{ end }} {{ define "title" }}Search{{ end }}
{{ define "header" }}<a href="./search">Search</a>{{ end }} {{ define "header" }}<a href="./search">Search</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="w-full flex flex-col md:flex-row gap-4"> <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-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"> <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"> <form class="flex gap-4 flex-col lg:flex-row" action="./search">
<div class="flex flex-col w-full grow"> <div class="flex flex-col w-full grow">
<div class="flex relative"> <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"> <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) }} {{ template "svg/search2" (dict "Size" 15) }}
</span> </span>
<input type="text" <input
type="text"
id="query" id="query"
name="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" 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" /> placeholder="Query"
/>
</div> </div>
</div> </div>
<div class="flex relative min-w-[12em]"> <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"> <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) }} {{ template "svg/documents" (dict "Size" 15) }}
</span> </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" <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" id="source"
name="source"> name="source"
>
<option value="LibGen">Library Genesis</option>
<option value="Annas Archive">Annas Archive</option> <option value="Annas Archive">Annas Archive</option>
<option value="LibGen Fiction">LibGen Fiction</option>
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
</select> </select>
</div> </div>
<button type="submit" <div class="lg:w-60">
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"> {{ template "component/button" (dict
<span class="w-full">Search</span> "Title" "Search"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
{{ if .SearchErrorMessage }} {{ if .SearchErrorMessage }}
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span> <span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
{{ end }} {{ end }}
</div> </div>
<div class="inline-block min-w-full overflow-hidden rounded shadow"> <div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"> <table
class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
>
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th scope="col" <th
class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"></th> scope="col"
<th scope="col" class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th> ></th>
<th scope="col" <th
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Series</th> scope="col"
<th scope="col" class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Type</th> >
<th scope="col" Document
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Size</th> </th>
<th scope="col" <th
class="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"> scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
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 Date
</th> </th>
</tr> </tr>
@@ -64,29 +98,43 @@
<tr> <tr>
<td class="text-center p-3" colspan="6">No Results</td> <td class="text-center p-3" colspan="6">No Results</td>
</tr> </tr>
{{ end }} {{ range $item := .Data }} {{ end }}
{{ range $item := .Data }}
<tr> <tr>
<td class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"> <td
class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
>
<form action="./search" method="POST"> <form action="./search" method="POST">
<input class="hidden" <input
class="hidden"
type="text" type="text"
id="source" id="source"
name="source" name="source"
value="{{ $.Source }}" /> value="{{ $.Source }}"
<input class="hidden" />
<input
class="hidden"
type="text" type="text"
id="title" id="title"
name="title" name="title"
value="{{ $item.Title }}" /> value="{{ $item.Title }}"
<input class="hidden" />
<input
class="hidden"
type="text" type="text"
id="author" id="author"
name="author" name="author"
value="{{ $item.Author }}" /> value="{{ $item.Author }}"
<button name="id" value="{{ $item.ID }}">{{ template "svg/download" }}</button> />
<button name="id" value="{{ $item.ID }}">
{{ template "svg/download" }}
</button>
</form> </form>
</td> </td>
<td class="p-3 border-b border-gray-200">{{ $item.Author }} - {{ $item.Title }}</td> <td class="p-3 border-b border-gray-200">
{{ $item.Author }} -
{{ $item.Title }}
</td>
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ or $item.Series "N/A" }}</p> <p>{{ or $item.Series "N/A" }}</p>
</td> </td>
@@ -105,5 +153,5 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -2,47 +2,64 @@
{{ define "title" }}Settings{{ end }} {{ define "title" }}Settings{{ end }}
{{ define "header" }}<a href="./settings">Settings</a>{{ end }} {{ define "header" }}<a href="./settings">Settings</a>{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="w-full flex flex-col md:flex-row gap-4"> <div class="w-full flex flex-col md:flex-row gap-4">
<div> <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"> <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) }} {{ template "svg/user" (dict "Size" 60) }}
<p class="text-lg">{{ .Authorization.UserName }}</p> <p class="text-lg">{{ .Authorization.UserName }}</p>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 grow"> <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"> <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> <p class="text-lg font-semibold mb-2">Change Password</p>
<form class="flex gap-4 flex-col lg:flex-row" <form
class="flex gap-4 flex-col lg:flex-row"
action="./settings" action="./settings"
method="POST"> method="POST"
>
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="flex relative"> <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"> <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) }} {{ template "svg/password" (dict "Size" 15) }}
</span> </span>
<input type="password" <input
type="password"
id="password" id="password"
name="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" 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" /> placeholder="Password"
/>
</div> </div>
</div> </div>
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="flex relative"> <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"> <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) }} {{ template "svg/password" (dict "Size" 15) }}
</span> </span>
<input type="password" <input
type="password"
id="new_password" id="new_password"
name="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" 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" /> placeholder="New Password"
/>
</div> </div>
</div> </div>
<button type="submit" <div class="lg:w-60">
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"> {{ template "component/button" (dict
<span class="w-full">Submit</span> "Title" "Submit"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
{{ if .PasswordErrorMessage }} {{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span> <span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
@@ -50,51 +67,77 @@
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span> <span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
{{ end }} {{ end }}
</div> </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"> <div
<p class="text-lg font-semibold mb-2">Change Time Offset</p> class="flex flex-col grow gap-2 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" >
<p class="text-lg font-semibold mb-2">Change Timezone</p>
<form
class="flex gap-4 flex-col lg:flex-row"
action="./settings" action="./settings"
method="POST"> method="POST"
>
<div class="flex relative grow"> <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"> <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) }} {{ template "svg/clock" (dict "Size" 15) }}
</span> </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" <select
id="time_offset" 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"
name="time_offset"> id="timezone"
{{ range $item := getUTCOffsets }} name="timezone"
<option {{ if (eq $item.Value $.Data.TimeOffset) }}selected{{ end }} value="{{ $item.Value }}"> >
{{ $item.Name }} {{ range $item := getTimeZones }}
<option
{{ if (eq $item $.Data.Timezone) }}selected{{ end }}
value="{{ $item }}"
>
{{ $item }}
</option> </option>
{{ end }} {{ end }}
</select> </select>
</div> </div>
<button type="submit" <div class="lg:w-60">
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"> {{ template "component/button" (dict
<span class="w-full">Submit</span> "Title" "Submit"
</button> "Variant" "Secondary"
)
}}
</div>
</form> </form>
{{ if .TimeOffsetErrorMessage }} {{ if .TimeOffsetErrorMessage }}
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span> <span class="text-red-400 text-xs"
>{{ .TimeOffsetErrorMessage }}</span
>
{{ else if .TimeOffsetMessage }} {{ else if .TimeOffsetMessage }}
<span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span> <span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span>
{{ end }} {{ end }}
</div> </div>
<div class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"> <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> <p class="text-lg font-semibold">Devices</p>
<table class="min-w-full bg-white dark:bg-gray-700 text-sm"> <table class="min-w-full bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400"> <thead class="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th scope="col" <th
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"> scope="col"
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Name Name
</th> </th>
<th scope="col" <th
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"> scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Last Sync Last Sync
</th> </th>
<th scope="col" <th
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Created</th> scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Created
</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-black dark:text-white"> <tbody class="text-black dark:text-white">
@@ -120,5 +163,5 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{{ end }} {{ end }}