16 Commits

Author SHA1 Message Date
43af4d0a01 chore: migrate admin general
Some checks failed
continuous-integration/drone/pr Build is failing
2025-11-12 19:13:16 -05:00
f53959b38f refactor 2025-11-12 19:13:16 -05:00
e7ebccd4a9 cleanup 1 2025-11-12 19:13:16 -05:00
2eed0d9021 wip 2025-11-12 19:13:16 -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
120 changed files with 4256 additions and 2368 deletions

View File

@@ -1,5 +1,5 @@
kind: pipeline kind: pipeline
type: kubernetes type: docker
name: default name: default
trigger: trigger:
@@ -27,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

6
.golangci.toml Normal file
View File

@@ -0,0 +1,6 @@
#:schema https://golangci-lint.run/jsonschema/golangci.jsonschema.json
version = "2"
[[linters.exclusions.rules]]
linters = [ "errcheck" ]
source = "^\\s*defer\\s+"

View File

@@ -3,7 +3,7 @@ FROM alpine AS alpine
RUN apk update && apk add --no-cache ca-certificates tzdata 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

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/tailwind.css --minify
dev: build_tailwind dev: build_tailwind
GIN_MODE=release \ GIN_MODE=release \

View File

@@ -136,35 +136,43 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
router.GET("/favicon.ico", api.appFaviconIcon) router.GET("/favicon.ico", api.appFaviconIcon)
router.GET("/sw.js", api.appServiceWorker) router.GET("/sw.js", api.appServiceWorker)
// Local / offline static pages (no template, no auth) // Web App - Offline
router.GET("/local", api.appLocalDocuments) router.GET("/local", api.appLocalDocuments)
// Reader (reader page, document progress, devices) // Web App - Reader
router.GET("/reader", api.appDocumentReader) router.GET("/reader", api.appDocumentReader)
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices) router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress) router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
// Web app // Web App - Templates
router.GET("/", api.authWebAppMiddleware, api.appGetHome) router.GET("/", api.authWebAppMiddleware, api.appGetHome) // DONE
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity) // DONE
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress) // DONE
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments) // DONE
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument) // DONE
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // Web App - Other Routes
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage)) // DONE
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage)) // DONE
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout) // DONE
router.POST("/login", api.appAuthLogin) // DONE
router.POST("/register", api.appAuthRegister) // DONE
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings) // DONE
// TODO
router.GET("/login", api.appGetLogin) router.GET("/login", api.appGetLogin)
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
router.GET("/register", api.appGetRegister) router.GET("/register", api.appGetRegister)
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
// DONE
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
// TODO - WIP
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs) router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
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.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
router.POST("/login", api.appAuthLogin)
router.POST("/register", api.appAuthRegister)
// Demo mode enabled configuration // Demo mode enabled configuration
if api.cfg.DemoMode { if api.cfg.DemoMode {
@@ -174,17 +182,18 @@ func (api *API) registerWebAppRoutes(router *gin.Engine) {
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError) router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError) router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
} else { } else {
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument) router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument) // DONE
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument) // DONE
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument) // DONE
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument) router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocumentNew) // DONE
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings) // DONE
} }
// Search enabled configuration // Search enabled configuration
if api.cfg.SearchEnabled { if api.cfg.SearchEnabled {
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) router.GET("/search", api.authWebAppMiddleware, api.appGetSearch) // DONE
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument) // TODO
} }
} }
@@ -325,6 +334,13 @@ func (api *API) loadTemplates(
return nil 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()
@@ -347,23 +363,16 @@ func loggingMiddleware(c *gin.Context) {
} }
// Get username // Get username
var auth authData var auth *authData
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData) auth = data.(*authData)
} }
// Log user // Log user
if auth.UserName != "" { if auth != nil && auth.UserName != "" {
logData["user"] = auth.UserName logData["user"] = auth.UserName
} }
// 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,7 @@ package api
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"context"
"crypto/md5" "crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -26,6 +27,8 @@ import (
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
"reichard.io/antholume/utils" "reichard.io/antholume/utils"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages"
) )
type adminAction string type adminAction string
@@ -95,71 +98,46 @@ type importResult struct {
Error error Error error
} }
func (api *API) appPerformAdminAction(c *gin.Context) { func (api *API) appGetAdmin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c) api.renderPage(c, &pages.AdminGeneral{})
}
func (api *API) appPerformAdminAction(c *gin.Context) {
var rAdminAction requestAdminAction var rAdminAction requestAdminAction
if err := c.ShouldBind(&rAdminAction); err != nil { if err := c.ShouldBind(&rAdminAction); err != nil {
log.Error("Invalid Form Bind: ", err) log.Error("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return return
} }
var allNotifications []*models.Notification
switch rAdminAction.Action { switch rAdminAction.Action {
case adminMetadataMatch:
// TODO
// 1. Documents xref most recent metadata table?
// 2. Select all / deselect?
case adminCacheTables:
go func() {
err := api.db.CacheTempTables()
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 api.processBackup(c, rAdminAction.BackupTypes)
_, 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 return
} case adminMetadataMatch:
allNotifications = append(allNotifications, &models.Notification{
// Set Headers Type: models.NotificationTypeError,
c.Header("Content-type", "application/octet-stream") Content: "Metadata match not implemented",
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
// Stream Backup ZIP Archive
c.Stream(func(w io.Writer) bool {
var directories []string
for _, item := range rAdminAction.BackupTypes {
if item == backupCovers {
directories = append(directories, "covers")
} else if item == backupDocuments {
directories = append(directories, "documents")
}
}
err := api.createBackup(w, directories)
if err != nil {
log.Error("Backup Error: ", err)
}
return false
}) })
case adminCacheTables:
go func() {
err := api.db.CacheTempTables(c)
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
return allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Initiated table cache",
})
} }
c.HTML(http.StatusOK, "page/admin", templateVars) api.renderPage(c, &pages.AdminGeneral{}, allNotifications...)
}
func (api *API) appGetAdmin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c)
c.HTML(http.StatusOK, "page/admin", templateVars)
} }
func (api *API) appGetAdminLogs(c *gin.Context) { func (api *API) appGetAdminLogs(c *gin.Context) {
@@ -261,7 +239,7 @@ 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 { 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))
@@ -292,11 +270,11 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error var err error
switch rUpdate.Operation { switch rUpdate.Operation {
case opCreate: case opCreate:
err = api.createUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin) err = api.createUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opUpdate: case opUpdate:
err = api.updateUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin) err = api.updateUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opDelete: case opDelete:
err = api.deleteUser(rUpdate.User) err = api.deleteUser(c, rUpdate.User)
default: default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation") appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return return
@@ -307,7 +285,7 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
return return
} }
users, err := api.db.Queries.GetUsers(api.db.Ctx) 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))
@@ -448,7 +426,7 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title) iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// Check already exists // Check already exists
_, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5) _, err = qtx.GetDocument(c, *fileMeta.PartialMD5)
if err == nil { if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5) log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists iResult.Status = importExists
@@ -492,7 +470,7 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
} }
// Upsert document // Upsert document
if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ if _, err = qtx.UpsertDocument(c, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5, ID: *fileMeta.PartialMD5,
Title: fileMeta.Title, Title: fileMeta.Title,
Author: fileMeta.Author, Author: fileMeta.Author,
@@ -532,6 +510,40 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
c.HTML(http.StatusOK, "page/admin-import-results", templateVars) c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
} }
func (api *API) processBackup(c *gin.Context, backupTypes []backupType) {
// Vacuum
_, err := api.db.DB.ExecContext(c, "VACUUM;")
if err != nil {
log.Error("Unable to vacuum DB: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
return
}
// Set Headers
c.Header("Content-type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
// Stream Backup ZIP Archive
c.Stream(func(w io.Writer) bool {
var directories []string
for _, item := range backupTypes {
switch item {
case backupCovers:
directories = append(directories, "covers")
case backupDocuments:
directories = append(directories, "documents")
}
}
err := api.createBackup(c, w, directories)
if err != nil {
log.Error("Backup Error: ", err)
}
return false
})
}
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
// Validate Type & Derive Extension on MIME // Validate Type & Derive Extension on MIME
uploadedFile, err := rAdminAction.RestoreFile.Open() uploadedFile, err := rAdminAction.RestoreFile.Open()
@@ -627,7 +639,7 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
// 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")
@@ -650,13 +662,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)
} }
@@ -717,9 +729,9 @@ func (api *API) removeData() error {
return nil return nil
} }
func (api *API) createBackup(w io.Writer, directories []string) error { func (api *API) createBackup(ctx context.Context, w io.Writer, directories []string) error {
// Vacuum DB // Vacuum DB
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;") _, err := api.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil { if err != nil {
return errors.Wrap(err, "Unable to vacuum database") return errors.Wrap(err, "Unable to vacuum database")
} }
@@ -788,12 +800,12 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
} }
} }
ar.Close() _ = ar.Close()
return nil return nil
} }
func (api *API) isLastAdmin(userID string) (bool, error) { func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(api.db.Ctx) allUsers, err := api.db.Queries.GetUsers(ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err)) return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
} }
@@ -809,7 +821,7 @@ func (api *API) isLastAdmin(userID string) (bool, error) {
return !hasAdmin, nil return !hasAdmin, nil
} }
func (api *API) createUser(user string, rawPassword *string, isAdmin *bool) error { func (api *API) createUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters // Validate Necessary Parameters
if rawPassword == nil || *rawPassword == "" { if rawPassword == nil || *rawPassword == "" {
return fmt.Errorf("password can't be empty") return fmt.Errorf("password can't be empty")
@@ -844,7 +856,7 @@ func (api *API) createUser(user string, rawPassword *string, isAdmin *bool) erro
createParams.AuthHash = &authHash createParams.AuthHash = &authHash
// Create user in DB // Create user in DB
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil { if rows, err := api.db.Queries.CreateUser(ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err) log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user") return fmt.Errorf("unable to create user")
} else if rows == 0 { } else if rows == 0 {
@@ -855,7 +867,7 @@ func (api *API) createUser(user string, rawPassword *string, isAdmin *bool) erro
return nil return nil
} }
func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) error { func (api *API) updateUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters // Validate Necessary Parameters
if rawPassword == nil && isAdmin == nil { if rawPassword == nil && isAdmin == nil {
return fmt.Errorf("nothing to update") return fmt.Errorf("nothing to update")
@@ -870,7 +882,7 @@ func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) erro
if isAdmin != nil { if isAdmin != nil {
updateParams.Admin = *isAdmin updateParams.Admin = *isAdmin
} else { } else {
user, err := api.db.Queries.GetUser(api.db.Ctx, user) user, err := api.db.Queries.GetUser(ctx, user)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
} }
@@ -878,7 +890,7 @@ func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) erro
} }
// Check Admins - Disallow Demotion // Check Admins - Disallow Demotion
if isLast, err := api.isLastAdmin(user); err != nil { if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err return err
} else if isLast && !updateParams.Admin { } else if isLast && !updateParams.Admin {
return fmt.Errorf("unable to demote %s - last admin", user) return fmt.Errorf("unable to demote %s - last admin", user)
@@ -908,7 +920,7 @@ func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) erro
} }
// Update User // Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams) _, err := api.db.Queries.UpdateUser(ctx, updateParams)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err)) return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
} }
@@ -916,9 +928,9 @@ func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) erro
return nil return nil
} }
func (api *API) deleteUser(user string) error { func (api *API) deleteUser(ctx context.Context, user string) error {
// Check Admins // Check Admins
if isLast, err := api.isLastAdmin(user); err != nil { if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err return err
} else if isLast { } else if isLast {
return fmt.Errorf("unable to delete %s - last admin", user) return fmt.Errorf("unable to delete %s - last admin", user)
@@ -934,13 +946,13 @@ func (api *API) deleteUser(user string) error {
// Save Backup File (DB Only) // Save Backup File (DB Only)
w := bufio.NewWriter(backupFile) w := bufio.NewWriter(backupFile)
err = api.createBackup(w, []string{}) err = api.createBackup(ctx, w, []string{})
if err != nil { if err != nil {
return err return err
} }
// Delete User // Delete User
_, err = api.db.Queries.DeleteUser(api.db.Ctx, user) _, err = api.db.Queries.DeleteUser(ctx, user)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err)) return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
} }

513
api/app-routes-new.go Normal file
View File

@@ -0,0 +1,513 @@
package api
import (
"cmp"
"crypto/md5"
"fmt"
"math"
"net/http"
"sort"
"strings"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/pkg/formatters"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/pkg/utils"
"reichard.io/antholume/search"
"reichard.io/antholume/web/components/stats"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages"
)
func (api *API) appGetHome(c *gin.Context) {
_, auth := api.getBaseTemplateVars("home", c)
start := time.Now()
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get daily read stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err))
return
}
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get database info")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err))
return
}
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user streaks")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
return
}
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(c)
if err != nil {
log.WithError(err).Error("failed to get user statistics")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
return
}
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
api.renderPage(c, &pages.Home{
Leaderboard: arrangeUserStatistic(userStatistics),
Streaks: streaks,
DailyStats: dailyStats,
RecordInfo: &databaseInfo,
})
}
func (api *API) appGetDocuments(c *gin.Context) {
qParams, err := bindQueryParams(c, 9)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
_, auth := api.getBaseTemplateVars("documents", c)
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
log.WithError(err).Error("failed to get documents with stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
return
}
length, err := api.db.Queries.GetDocumentsSize(c, query)
if err != nil {
log.WithError(err).Error("failed to get document sizes")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
return
}
if err = api.getDocumentsWordCount(c, documents); err != nil {
log.WithError(err).Error("failed to get word counts")
}
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
nextPage := *qParams.Page + 1
previousPage := *qParams.Page - 1
api.renderPage(c, pages.Documents{
Data: sliceutils.Map(documents, convertDBDocToUI),
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
Limit: int(ptr.Deref(qParams.Limit)),
})
}
func (api *API) appGetDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return
}
api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
}
func (api *API) appGetActivity(c *gin.Context) {
qParams, err := bindQueryParams(c, 15)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
_, auth := api.getBaseTemplateVars("activity", c)
activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
DocFilter: qParams.Document != nil,
DocumentID: ptr.Deref(qParams.Document),
})
if err != nil {
log.WithError(err).Error("failed to get activity")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
return
}
api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
}
func (api *API) appGetProgress(c *gin.Context) {
qParams, err := bindQueryParams(c, 15)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
_, auth := api.getBaseTemplateVars("progress", c)
progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
DocFilter: qParams.Document != nil,
DocumentID: ptr.Deref(qParams.Document),
})
if err != nil {
log.WithError(err).Error("failed to get progress")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
return
}
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
}
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil {
log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Disallow Empty Strings
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
rDocIdentify.Title = nil
}
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
rDocIdentify.Author = nil
}
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
rDocIdentify.ISBN = nil
}
// Validate Values
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
log.Error("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Get Metadata
var searchResult *models.DocumentMetadata
var allNotifications []*models.Notification
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
Title: rDocIdentify.Title,
Author: rDocIdentify.Author,
ISBN10: rDocIdentify.ISBN,
ISBN13: rDocIdentify.ISBN,
})
if err != nil {
log.WithError(err).Error("failed to search metadata")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
return
} else if firstResult, found := sliceutils.First(metadataResults); found {
searchResult = convertMetaToUI(firstResult)
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: rDocID.DocumentID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.SourceID,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.WithError(err).Error("failed to add metadata")
}
} else {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "No Metadata Found",
})
}
// Get Auth
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return
}
api.renderPage(c, &pages.Document{
Data: convertDBDocToUI(*document),
Search: searchResult,
}, allNotifications...)
}
// Tabs:
// - General (Import, Backup & Restore, Version (githash?), Stats?)
// - Users
// - Metadata
func (api *API) appGetSearch(c *gin.Context) {
var sParams searchParams
if err := c.BindQuery(&sParams); err != nil {
log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Only Handle Query
var searchResults []models.SearchResult
var searchError string
if sParams.Query != nil && sParams.Source != nil {
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
if err != nil {
log.WithError(err).Error("failed to search book")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return
}
searchResults = sliceutils.Map(results, convertSearchToUI)
} else if sParams.Query != nil || sParams.Source != nil {
searchError = "Invailid Query"
}
api.renderPage(c, &pages.Search{
Results: searchResults,
Source: ptr.Deref(sParams.Source),
Query: ptr.Deref(sParams.Query),
Error: searchError,
})
}
func (api *API) appGetSettings(c *gin.Context) {
_, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Timezone: ptr.Deref(user.Timezone),
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
})
}
func (api *API) appEditSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
log.Error("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
_, auth := api.getBaseTemplateVars("settings", c)
newUserSettings := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Set New Password
var allNotifications []*models.Notification
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Invalid Password",
})
} else {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Unknown Error",
})
} else {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Password Updated",
})
newUserSettings.Password = &hashedPassword
}
}
}
// Set Time Offset
if rUserSettings.Timezone != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Time Offset Updated",
})
newUserSettings.Timezone = rUserSettings.Timezone
}
// Update User
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
if err != nil {
log.WithError(err).Error("failed to update user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err))
return
}
// Get User
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
// Get Devices
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
Timezone: ptr.Deref(user.Timezone),
}, allNotifications...)
}
func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) {
// Get Authentication Data
auth, err := getAuthData(c)
if err != nil {
log.WithError(err).Error("failed to acquire auth data")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err))
return
}
// Generate Page
pageNode, err := page.Generate(models.PageContext{
UserInfo: &models.UserInfo{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
ServerInfo: &models.ServerInfo{
RegistrationEnabled: api.cfg.RegistrationEnabled,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
Notifications: notifications,
})
if err != nil {
log.WithError(err).Error("failed to generate page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
return
}
// Render Page
err = pageNode.Render(c.Writer)
if err != nil {
log.WithError(err).Error("failed to render page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err))
return
}
}
func sortItem[T cmp.Ordered](
data []database.GetUserStatisticsRow,
accessor func(s database.GetUserStatisticsRow) T,
formatter func(s T) string,
) []stats.LeaderboardItem {
sort.SliceStable(data, func(i, j int) bool {
return accessor(data[i]) > accessor(data[j])
})
var items []stats.LeaderboardItem
for _, s := range data {
items = append(items, stats.LeaderboardItem{
UserID: s.UserID,
Value: formatter(accessor(s)),
})
}
return items
}
func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
return []stats.LeaderboardData{
{
Name: "WPM",
All: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.TotalWpm }, wpmFormatter),
Year: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.YearlyWpm }, wpmFormatter),
Month: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.MonthlyWpm }, wpmFormatter),
Week: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.WeeklyWpm }, wpmFormatter),
},
{
Name: "Words",
All: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.TotalWordsRead }, formatters.FormatNumber),
Year: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.YearlyWordsRead }, formatters.FormatNumber),
Month: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.MonthlyWordsRead }, formatters.FormatNumber),
Week: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.WeeklyWordsRead }, formatters.FormatNumber),
},
{
Name: "Duration",
All: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.TotalSeconds) * time.Second
}, formatters.FormatDuration),
Year: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.YearlySeconds) * time.Second
}, formatters.FormatDuration),
Month: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.MonthlySeconds) * time.Second
}, formatters.FormatDuration),
Week: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.WeeklySeconds) * time.Second
}, formatters.FormatDuration),
},
}
}

View File

@@ -1,21 +1,17 @@
package api package api
import ( import (
"crypto/md5" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"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"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -99,244 +95,6 @@ func (api *API) appDocumentReader(c *gin.Context) {
c.FileFromFS("assets/reader/index.htm", http.FS(api.assets)) c.FileFromFS("assets/reader/index.htm", http.FS(api.assets))
} }
func (api *API) appGetDocuments(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("documents", c)
qParams := bindQueryParams(c, 9)
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
log.Error("GetDocumentsWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
return
}
length, err := api.db.Queries.GetDocumentsSize(api.db.Ctx, query)
if err != nil {
log.Error("GetDocumentsSize DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
return
}
if err = api.getDocumentsWordCount(documents); err != nil {
log.Error("Unable to Get Word Counts: ", err)
}
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
nextPage := *qParams.Page + 1
previousPage := *qParams.Page - 1
if nextPage <= totalPages {
templateVars["NextPage"] = nextPage
}
if previousPage >= 0 {
templateVars["PreviousPage"] = previousPage
}
templateVars["PageLimit"] = *qParams.Limit
templateVars["Data"] = documents
c.HTML(http.StatusOK, "page/documents", templateVars)
}
func (api *API) appGetDocument(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("document", c)
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
log.Error("GetDocumentWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
return
}
templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
c.HTML(http.StatusOK, "page/document", templateVars)
}
func (api *API) appGetProgress(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("progress", c)
qParams := bindQueryParams(c, 15)
progressFilter := database.GetProgressParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
if qParams.Document != nil {
progressFilter.DocFilter = true
progressFilter.DocumentID = *qParams.Document
}
progress, err := api.db.Queries.GetProgress(api.db.Ctx, progressFilter)
if err != nil {
log.Error("GetProgress DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
return
}
templateVars["Data"] = progress
c.HTML(http.StatusOK, "page/progress", templateVars)
}
func (api *API) appGetActivity(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("activity", c)
qParams := bindQueryParams(c, 15)
activityFilter := database.GetActivityParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
if qParams.Document != nil {
activityFilter.DocFilter = true
activityFilter.DocumentID = *qParams.Document
}
activity, err := api.db.Queries.GetActivity(api.db.Ctx, activityFilter)
if err != nil {
log.Error("GetActivity DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
return
}
templateVars["Data"] = activity
c.HTML(http.StatusOK, "page/activity", templateVars)
}
func (api *API) appGetHome(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("home", c)
start := time.Now()
graphData, err := api.db.Queries.GetDailyReadStats(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetDailyReadStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
return
}
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetDatabaseInfo DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
return
}
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetUserStreaks DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
return
}
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(api.db.Ctx)
if err != nil {
log.Error("GetUserStatistics DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
return
}
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
templateVars["Data"] = gin.H{
"Streaks": streaks,
"GraphData": graphData,
"DatabaseInfo": databaseInfo,
"UserStatistics": arrangeUserStatistics(userStatistics),
}
c.HTML(http.StatusOK, "page/home", templateVars)
}
func (api *API) appGetSettings(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return
}
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return
}
templateVars["Data"] = gin.H{
"Timezone": *user.Timezone,
"Devices": devices,
}
c.HTML(http.StatusOK, "page/settings", templateVars)
}
// Tabs:
// - General (Import, Backup & Restore, Version (githash?), Stats?)
// - Users
// - Metadata
func (api *API) appGetSearch(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("search", c)
var sParams searchParams
err := c.BindQuery(&sParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return
}
// Only Handle Query
if sParams.Query != nil && sParams.Source != nil {
// Search
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return
}
templateVars["Data"] = searchResults
templateVars["Source"] = *sParams.Source
} else if sParams.Query != nil || sParams.Source != nil {
templateVars["SearchErrorMessage"] = "Invalid Query"
}
c.HTML(http.StatusOK, "page/search", templateVars)
}
func (api *API) appGetLogin(c *gin.Context) { func (api *API) appGetLogin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("login", c) templateVars, _ := api.getBaseTemplateVars("login", c)
templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled
@@ -368,7 +126,7 @@ 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,
}) })
@@ -378,13 +136,10 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
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
} }
@@ -404,7 +159,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)
@@ -455,7 +210,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))
@@ -483,7 +238,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,
@@ -573,7 +328,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
@@ -581,7 +336,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),
@@ -605,7 +360,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))
@@ -620,88 +375,6 @@ func (api *API) appDeleteDocument(c *gin.Context) {
c.Redirect(http.StatusFound, "../") c.Redirect(http.StatusFound, "../")
} }
func (api *API) appIdentifyDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil {
log.Error("Invalid Form Bind")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Disallow Empty Strings
if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" {
rDocIdentify.Title = nil
}
if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" {
rDocIdentify.Author = nil
}
if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" {
rDocIdentify.ISBN = nil
}
// Validate Values
if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil {
log.Error("Invalid Form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Get Template Variables
templateVars, auth := api.getBaseTemplateVars("document", c)
// Get Metadata
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: rDocIdentify.Title,
Author: rDocIdentify.Author,
ISBN10: rDocIdentify.ISBN,
ISBN13: rDocIdentify.ISBN,
})
if err == nil && len(metadataResults) > 0 {
firstResult := metadataResults[0]
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{
DocumentID: rDocID.DocumentID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("AddMetadata DB Error: ", err)
}
templateVars["Metadata"] = firstResult
} else {
log.Warn("Metadata Error")
templateVars["MetadataError"] = "No Metadata Found"
}
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
log.Error("GetDocumentWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return
}
templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
c.HTML(http.StatusOK, "page/document", templateVars)
}
func (api *API) appSaveNewDocument(c *gin.Context) { func (api *API) appSaveNewDocument(c *gin.Context) {
var rDocAdd requestDocumentAdd var rDocAdd requestDocumentAdd
if err := c.ShouldBind(&rDocAdd); err != nil { if err := c.ShouldBind(&rDocAdd); err != nil {
@@ -817,7 +490,7 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
sendDownloadMessage("Saving to database...", gin.H{"Progress": 99}) sendDownloadMessage("Saving to database...", gin.H{"Progress": 99})
// 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: *metadata.PartialMD5, ID: *metadata.PartialMD5,
Title: &docTitle, Title: &docTitle,
Author: &docAuthor, Author: &docAuthor,
@@ -839,89 +512,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
}) })
} }
func (api *API) appEditSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.Error("Invalid Form Bind")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil {
log.Error("Missing Form Values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
templateVars, auth := api.getBaseTemplateVars("settings", c)
newUserSettings := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
data := api.authorizeCredentials(auth.UserName, password)
if data == nil {
templateVars["PasswordErrorMessage"] = "Invalid Password"
} else {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
templateVars["PasswordErrorMessage"] = "Unknown Error"
} else {
templateVars["PasswordMessage"] = "Password Updated"
newUserSettings.Password = &hashedPassword
}
}
}
// Set Time Offset
if rUserSettings.Timezone != nil {
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
newUserSettings.Timezone = rUserSettings.Timezone
}
// Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, newUserSettings)
if err != nil {
log.Error("UpdateUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err))
return
}
// Get User
user, err := api.db.Queries.GetUser(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetUser DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err))
return
}
// Get Devices
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName)
if err != nil {
log.Error("GetDevices DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return
}
templateVars["Data"] = gin.H{
"Timezone": *user.Timezone,
"Devices": devices,
}
c.HTML(http.StatusOK, "page/settings", templateVars)
}
func (api *API) appDemoModeError(c *gin.Context) { 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 {
@@ -944,7 +539,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 {
@@ -964,10 +559,10 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
return nil return nil
} }
func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) { func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, *authData) {
var auth authData var auth *authData
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData) auth = data.(*authData)
} }
return gin.H{ return gin.H{
@@ -981,12 +576,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
}, auth }, auth
} }
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { func bindQueryParams(c *gin.Context, defaultLimit int64) (*queryParams, error) {
var qParams queryParams var qParams queryParams
err := c.BindQuery(&qParams) err := c.BindQuery(&qParams)
if err != nil { if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) return nil, err
return qParams
} }
if qParams.Limit == nil { if qParams.Limit == nil {
@@ -1001,11 +595,11 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
qParams.Page = &oneValue qParams.Page = &oneValue
} }
return qParams return &qParams, nil
} }
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:
@@ -1024,80 +618,3 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
"Message": errorMessage, "Message": errorMessage,
}) })
} }
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
// Item Sorter
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
sort.SliceStable(sortedData, less)
newData := make([]map[string]any, 0)
for _, item := range sortedData {
v := reflect.Indirect(reflect.ValueOf(item))
var value string
if strings.Contains(key, "Wpm") {
rawVal := v.FieldByName(key).Float()
value = fmt.Sprintf("%.2f WPM", rawVal)
} else if strings.Contains(key, "Seconds") {
rawVal := v.FieldByName(key).Int()
value = niceSeconds(rawVal)
} else if strings.Contains(key, "Words") {
rawVal := v.FieldByName(key).Int()
value = niceNumbers(rawVal)
}
newData = append(newData, map[string]any{
"UserID": item.UserID,
"Value": value,
})
}
return newData
}
return gin.H{
"WPM": gin.H{
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
}),
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
}),
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
}),
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
}),
},
"Duration": gin.H{
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
}),
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
}),
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
}),
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
}),
},
"Words": gin.H{
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
}),
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
}),
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
}),
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
}),
},
}
}

View File

@@ -1,8 +1,10 @@
package api package api
import ( import (
"context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"maps"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -28,31 +30,31 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"` AuthKey string `header:"x-auth-key"`
} }
func (api *API) authorizeCredentials(username string, password string) (auth *authData) { func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (*authData, error) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username) user, err := api.db.Queries.GetUser(ctx, username)
if err != nil { if err != nil {
return return nil, err
} }
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return return nil, err
} }
// Update auth cache // Update Auth Cache
api.userAuthCache[user.ID] = *user.AuthHash api.userAuthCache[user.ID] = *user.AuthHash
return &authData{ return &authData{
UserName: user.ID, UserName: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
AuthHash: *user.AuthHash, AuthHash: *user.AuthHash,
} }, nil
} }
func (api *API) authKOMiddleware(c *gin.Context) { func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session First // Check Session First
if auth, ok := api.getSession(session); ok { if auth, ok := api.authorizeSession(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()
@@ -63,21 +65,25 @@ func (api *API) authKOMiddleware(c *gin.Context) {
var rHeader authKOHeader var rHeader authKOHeader
if err := c.ShouldBindHeader(&rHeader); err != nil { if err := c.ShouldBindHeader(&rHeader); err != nil {
log.WithError(err).Error("failed to bind auth headers")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
return return
} }
if rHeader.AuthUser == "" || rHeader.AuthKey == "" { if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
log.Error("invalid authentication headers")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
authData := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey) authData, err := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
if authData == nil { if err != nil {
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to authorize credentials")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
if err := api.setSession(session, *authData); err != nil { if err := api.setSession(session, authData); err != nil {
log.WithField("user", rHeader.AuthUser).WithError(err).Error("failed to set session")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
@@ -94,14 +100,16 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
// Validate Auth Fields // Validate Auth Fields
if !hasAuth || user == "" || rawPassword == "" { if !hasAuth || user == "" || rawPassword == "" {
log.Error("invalid authorization headers")
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, err := api.authorizeCredentials(c, user, password)
if authData == nil { if err != nil {
log.WithField("user", user).WithError(err).Error("failed to authorize credentials")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return return
} }
@@ -115,7 +123,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 { if auth, ok := api.authorizeSession(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()
@@ -128,7 +136,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
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 { if auth.IsAdmin {
c.Next() c.Next()
return return
@@ -153,8 +161,9 @@ 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, err := api.authorizeCredentials(c, username, password)
if authData == nil { if err != nil {
log.WithField("user", username).WithError(err).Error("failed to authorize credentials")
templateVars["Error"] = "Invalid Credentials" templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars) c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return return
@@ -162,7 +171,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
// Set Session // Set Session
session := sessions.Default(c) session := sessions.Default(c)
if err := api.setSession(session, *authData); err != nil { if err := api.setSession(session, authData); err != nil {
templateVars["Error"] = "Invalid Credentials" templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars) c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return return
@@ -208,7 +217,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
} }
// Get current users // Get current users
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx) currentUsers, err := api.db.Queries.GetUsers(c)
if err != nil { if err != nil {
log.Error("Failed to check all users: ", err) log.Error("Failed to check all users: ", err)
templateVars["Error"] = "Failed to Create User" templateVars["Error"] = "Failed to Create User"
@@ -224,7 +233,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
// Create user in DB // Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ if rows, err := api.db.Queries.CreateUser(c, database.CreateUserParams{
ID: username, ID: username,
Pass: &hashedPassword, Pass: &hashedPassword,
AuthHash: &authHash, AuthHash: &authHash,
@@ -242,7 +251,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
} }
// 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"
@@ -251,7 +260,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
} }
// Set session // Set session
auth := authData{ auth := &authData{
UserName: user.ID, UserName: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
AuthHash: *user.AuthHash, AuthHash: *user.AuthHash,
@@ -312,7 +321,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
} }
// Get current users // Get current users
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx) currentUsers, err := api.db.Queries.GetUsers(c)
if err != nil { if err != nil {
log.Error("Failed to check all users: ", err) log.Error("Failed to check all users: ", err)
apiErrorPage(c, http.StatusBadRequest, "Failed to Create User") apiErrorPage(c, http.StatusBadRequest, "Failed to Create User")
@@ -327,7 +336,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
// Create user // Create user
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ if rows, err := api.db.Queries.CreateUser(c, database.CreateUserParams{
ID: rUser.Username, ID: rUser.Username,
Pass: &hashedPassword, Pass: &hashedPassword,
AuthHash: &authHash, AuthHash: &authHash,
@@ -347,35 +356,40 @@ func (api *API) koAuthRegister(c *gin.Context) {
}) })
} }
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) { func (api *API) authorizeSession(ctx context.Context, session sessions.Session) (*authData, bool) {
// Get Session // Get Session
authorizedUser := session.Get("authorizedUser") authorizedUser := session.Get("authorizedUser")
isAdmin := session.Get("isAdmin") isAdmin := session.Get("isAdmin")
expiresAt := session.Get("expiresAt") expiresAt := session.Get("expiresAt")
authHash := session.Get("authHash") authHash := session.Get("authHash")
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil { if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
return return nil, false
} }
// Create Auth Object // Create Auth Object
auth = authData{ auth := &authData{
UserName: authorizedUser.(string), UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool), IsAdmin: isAdmin.(bool),
AuthHash: authHash.(string), AuthHash: authHash.(string),
} }
logger := log.WithField("user", auth.UserName)
// 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 {
return logger.WithError(err).Error("failed to get auth hash")
return nil, false
} else if correctAuthHash != auth.AuthHash {
logger.Warn("user auth hash mismatch")
return nil, false
} }
// Refresh // Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session") logger.Info("refreshing session")
if err := api.setSession(session, auth); err != nil { if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session") logger.WithError(err).Error("failed to refresh session")
return return nil, false
} }
} }
@@ -383,7 +397,7 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
return auth, true return auth, true
} }
func (api *API) setSession(session sessions.Session, auth authData) error { func (api *API) setSession(session sessions.Session, auth *authData) error {
// Set Session Cookie // Set Session Cookie
session.Set("authorizedUser", auth.UserName) session.Set("authorizedUser", auth.UserName)
session.Set("isAdmin", auth.IsAdmin) session.Set("isAdmin", auth.IsAdmin)
@@ -393,14 +407,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
@@ -412,7 +426,7 @@ func (api *API) getUserAuthHash(username string) (string, error) {
return api.userAuthCache[username], nil return api.userAuthCache[username], nil
} }
func (api *API) rotateAllAuthHashes() error { func (api *API) rotateAllAuthHashes(ctx context.Context) error {
// Do Transaction // Do Transaction
tx, err := api.db.DB.Begin() tx, err := api.db.DB.Begin()
if err != nil { if err != nil {
@@ -428,7 +442,7 @@ func (api *API) rotateAllAuthHashes() error {
}() }()
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
} }
@@ -444,7 +458,7 @@ 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, Admin: user.Admin,
@@ -463,9 +477,7 @@ func (api *API) rotateAllAuthHashes() error {
} }
// Transaction Succeeded -> Update Cache // Transaction Succeeded -> Update Cache
for user, hash := range newAuthHashCache { maps.Copy(api.userAuthCache, newAuthHashCache)
api.userAuthCache[user] = hash
}
return nil return nil
} }

View File

@@ -22,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")
@@ -68,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))
@@ -98,31 +98,31 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
} }
// Attempt Metadata // Attempt Metadata
var coverDir string = filepath.Join(api.cfg.DataPath, "covers") coverDir := filepath.Join(api.cfg.DataPath, "covers")
var coverFile string = "UNKNOWN" coverFile := "UNKNOWN"
// Identify Documents & Save Covers // Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{ metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
Title: document.Title, Title: document.Title,
Author: document.Author, Author: document.Author,
}) })
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil { if err == nil && len(metadataResults) > 0 && metadataResults[0].SourceID != nil {
firstResult := metadataResults[0] firstResult := metadataResults[0]
// Save Cover // Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false) fileName, err := metadata.CacheCover(*firstResult.SourceID, coverDir, document.ID, false)
if err == nil { if err == nil {
coverFile = *fileName coverFile = *fileName
} }
// 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,
Description: firstResult.Description, Description: firstResult.Description,
Gbid: firstResult.ID, Gbid: firstResult.SourceID,
Olid: nil, Olid: nil,
Isbn10: firstResult.ISBN10, Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13, Isbn13: firstResult.ISBN13,
@@ -132,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 {

83
api/convert.go Normal file
View File

@@ -0,0 +1,83 @@
package api
import (
"time"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/utils"
"reichard.io/antholume/search"
"reichard.io/antholume/web/models"
)
func convertDBDocToUI(r database.GetDocumentsWithStatsRow) models.Document {
return models.Document{
ID: r.ID,
Title: ptr.Deref(r.Title),
Author: ptr.Deref(r.Author),
ISBN10: ptr.Deref(r.Isbn10),
ISBN13: ptr.Deref(r.Isbn13),
Description: ptr.Deref(r.Description),
Percentage: r.Percentage,
WPM: r.Wpm,
Words: r.Words,
TotalTimeRead: time.Duration(r.TotalTimeSeconds) * time.Second,
TimePerPercent: time.Duration(r.SecondsPerPercent) * time.Second,
HasFile: ptr.Deref(r.Filepath) != "",
}
}
func convertMetaToUI(m metadata.MetadataInfo) *models.DocumentMetadata {
return &models.DocumentMetadata{
SourceID: ptr.Deref(m.SourceID),
ISBN10: ptr.Deref(m.ISBN10),
ISBN13: ptr.Deref(m.ISBN13),
Title: ptr.Deref(m.Title),
Author: ptr.Deref(m.Author),
Description: ptr.Deref(m.Description),
Source: m.Source,
}
}
func convertDBActivityToUI(r database.GetActivityRow) models.Activity {
return models.Activity{
ID: r.DocumentID,
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
StartTime: r.StartTime,
Duration: time.Duration(r.Duration) * time.Second,
Percentage: r.EndPercentage,
}
}
func convertDBProgressToUI(r database.GetProgressRow) models.Progress {
return models.Progress{
ID: r.DocumentID,
Author: utils.FirstNonZero(ptr.Deref(r.Author), "N/A"),
Title: utils.FirstNonZero(ptr.Deref(r.Title), "N/A"),
DeviceName: r.DeviceName,
Percentage: r.Percentage,
CreatedAt: r.CreatedAt,
}
}
func convertDBDeviceToUI(r database.GetDevicesRow) models.Device {
return models.Device{
DeviceName: r.DeviceName,
LastSynced: r.LastSynced,
CreatedAt: r.CreatedAt,
}
}
func convertSearchToUI(r search.SearchItem) models.SearchResult {
return models.SearchResult{
ID: r.ID,
Title: r.Title,
Author: r.Author,
Series: r.Series,
FileType: r.FileType,
FileSize: r.FileSize,
UploadDate: r.UploadDate,
}
}

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,
@@ -202,7 +202,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// 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)
@@ -212,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,
@@ -225,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,
@@ -247,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),
}) })
} }
@@ -266,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,
@@ -278,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,
}) })
@@ -298,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(),
}) })
} }
@@ -329,7 +329,7 @@ func (api *API) koAddDocuments(c *gin.Context) {
// 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),
@@ -352,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),
}) })
} }
@@ -371,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,
@@ -384,7 +384,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
} }
// 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")
@@ -392,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")
@@ -407,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")
@@ -447,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) {
@@ -467,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")
@@ -522,7 +522,7 @@ 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,
@@ -534,7 +534,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ koJSON(c, http.StatusOK, gin.H{
"status": "ok", "status": "ok",
}) })
} }
@@ -589,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{
@@ -61,13 +62,19 @@ func (api *API) opdsEntry(c *gin.Context) {
} }
func (api *API) opdsDocuments(c *gin.Context) { func (api *API) opdsDocuments(c *gin.Context) {
var auth authData auth, err := getAuthData(c)
if data, _ := c.Get("Authorization"); data != nil { if err != nil {
auth = data.(authData) log.WithError(err).Error("failed to acquire auth data")
c.AbortWithStatus(http.StatusInternalServerError)
} }
// Potential URL Parameters (Default Pagination - 100) // Potential URL Parameters (Default Pagination - 100)
qParams := bindQueryParams(c, 100) qParams, err := bindQueryParams(c, 100)
if err != nil {
log.WithError(err).Error("failed to bind query params")
c.AbortWithStatus(http.StatusBadRequest)
return
}
// Possible Query // Possible Query
var query *string var query *string
@@ -77,14 +84,15 @@ 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,
}) })
if err != nil { if err != nil {
log.Error("GetDocumentsWithStats DB Error:", err) log.WithError(err).Error("failed to get documents with stats")
c.AbortWithStatus(http.StatusBadRequest) c.AbortWithStatus(http.StatusBadRequest)
return return
} }

View File

@@ -8,11 +8,22 @@ import (
"reflect" "reflect"
"strings" "strings"
"github.com/gin-gonic/gin"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/graph" "reichard.io/antholume/graph"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
func getAuthData(ctx *gin.Context) (*authData, error) {
if data, ok := ctx.Get("Authorization"); ok {
var auth *authData
if auth, ok = data.(*authData); ok {
return auth, nil
}
}
return nil, errors.New("could not acquire auth data")
}
// getTimeZones returns a string slice of IANA timezones. // getTimeZones returns a string slice of IANA timezones.
func getTimeZones() []string { func getTimeZones() []string {
return []string{ return []string{
@@ -55,6 +66,7 @@ func getTimeZones() []string {
// niceSeconds takes in an int (in seconds) and returns a string readable // niceSeconds takes in an int (in seconds) and returns a string readable
// representation. For example 1928371 -> "22d 7h 39m 31s". // 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"
@@ -85,6 +97,7 @@ func niceSeconds(input int64) (result string) {
// niceNumbers takes in an int and returns a string representation. For example // niceNumbers takes in an int and returns a string representation. For example
// 19823 -> "19.8k". // 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"

116
assets/index.css Normal file
View File

@@ -0,0 +1,116 @@
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
}
main {
height: calc(100dvh - 4rem - env(safe-area-inset-top));
}
#container {
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
}
/* No Scrollbar - IE, Edge, Firefox */
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* No Scrollbar - WebKit */
*::-webkit-scrollbar {
display: none;
}
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked + div {
visibility: visible;
opacity: 1;
}
.css-button + div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */
/* ------- User Dropdown ------- */
/* ----------------------------- */
#user-dropdown-button:checked + #user-dropdown {
visibility: visible;
opacity: 1;
}
#user-dropdown {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */
/* ----- Mobile Navigation ----- */
/* ----------------------------- */
#mobile-nav-button span {
transform-origin: 5px 0px;
transition:
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
opacity 0.55s ease;
}
#mobile-nav-button span:first-child {
transform-origin: 0% 0%;
}
#mobile-nav-button span:nth-last-child(2) {
transform-origin: 0% 100%;
}
#mobile-nav-button input:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(2px, -2px);
}
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
#mobile-nav-button input:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, 6px);
}
#mobile-nav-button input:checked ~ div {
transform: none;
}
@media (min-width: 1024px) {
#mobile-nav-button input ~ div {
transform: none;
}
}
#menu {
top: 0;
padding-top: env(safe-area-inset-top);
transform-origin: 0% 0%;
transform: translate(-100%, 0);
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
}
@media (orientation: landscape) {
#menu {
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
}
}

View File

@@ -25,7 +25,7 @@
<title>AnthoLume - Local</title> <title>AnthoLume - Local</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/tailwind.css" />
<!-- Libraries --> <!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script> <script src="/assets/lib/jszip.min.js"></script>

View File

@@ -17,7 +17,7 @@
<title>AnthoLume - Reader</title> <title>AnthoLume - Reader</title>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/tailwind.css" />
<!-- Libraries --> <!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script> <script src="/assets/lib/jszip.min.js"></script>
@@ -82,8 +82,13 @@
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
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4"> 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"
>
<a href="#"> <a href="#">
<svg <svg
width="32" width="32"
@@ -152,6 +157,11 @@
</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

@@ -72,7 +72,8 @@ const PRECACHE_ASSETS = [
// Main App Assets // Main App Assets
"/manifest.json", "/manifest.json",
"/assets/index.js", "/assets/index.js",
"/assets/style.css", "/assets/index.css",
"/assets/tailwind.css",
"/assets/common.js", "/assets/common.js",
// Library Assets // Library Assets

1
assets/tailwind.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.27.0 // sqlc v1.29.0
package database package database

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
}

View File

@@ -1,6 +1,7 @@
package database package database
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
@@ -26,7 +27,7 @@ func (suite *DocumentsTestSuite) SetupTest() {
suite.dbm = NewMgr(&cfg) suite.dbm = NewMgr(&cfg)
// Create Document // Create Document
_, err := suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ _, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID, ID: documentID,
Title: &documentTitle, Title: &documentTitle,
Author: &documentAuthor, Author: &documentAuthor,
@@ -42,7 +43,7 @@ func (suite *DocumentsTestSuite) SetupTest() {
// - 󰊕 (q *Queries) GetDocumentsWithStats // - 󰊕 (q *Queries) GetDocumentsWithStats
// - 󰊕 (q *Queries) GetMissingDocuments // - 󰊕 (q *Queries) GetMissingDocuments
func (suite *DocumentsTestSuite) TestGetDocument() { func (suite *DocumentsTestSuite) TestGetDocument() {
doc, err := suite.dbm.Queries.GetDocument(suite.dbm.Ctx, documentID) doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(documentID, doc.ID, "should have changed the document") suite.Equal(documentID, doc.ID, "should have changed the document")
} }
@@ -50,7 +51,7 @@ func (suite *DocumentsTestSuite) TestGetDocument() {
func (suite *DocumentsTestSuite) TestUpsertDocument() { func (suite *DocumentsTestSuite) TestUpsertDocument() {
testDocID := "docid1" testDocID := "docid1"
doc, err := suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ doc, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: testDocID, ID: testDocID,
Title: &documentTitle, Title: &documentTitle,
Author: &documentAuthor, Author: &documentAuthor,
@@ -63,51 +64,51 @@ func (suite *DocumentsTestSuite) TestUpsertDocument() {
} }
func (suite *DocumentsTestSuite) TestDeleteDocument() { func (suite *DocumentsTestSuite) TestDeleteDocument() {
changed, err := suite.dbm.Queries.DeleteDocument(suite.dbm.Ctx, documentID) changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document") suite.Equal(int64(1), changed, "should have changed the document")
doc, err := suite.dbm.Queries.GetDocument(suite.dbm.Ctx, documentID) doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.True(doc.Deleted, "should have deleted the document") suite.True(doc.Deleted, "should have deleted the document")
} }
func (suite *DocumentsTestSuite) TestGetDeletedDocuments() { func (suite *DocumentsTestSuite) TestGetDeletedDocuments() {
changed, err := suite.dbm.Queries.DeleteDocument(suite.dbm.Ctx, documentID) changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document") suite.Equal(int64(1), changed, "should have changed the document")
deletedDocs, err := suite.dbm.Queries.GetDeletedDocuments(suite.dbm.Ctx, []string{documentID}) deletedDocs, err := suite.dbm.Queries.GetDeletedDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(deletedDocs, 1, "should have one deleted document") suite.Len(deletedDocs, 1, "should have one deleted document")
} }
// TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids')); // TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids'));
func (suite *DocumentsTestSuite) TestGetWantedDocuments() { func (suite *DocumentsTestSuite) TestGetWantedDocuments() {
wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(suite.dbm.Ctx, fmt.Sprintf("[\"%s\"]", documentID)) wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(context.Background(), fmt.Sprintf("[\"%s\"]", documentID))
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(wantedDocs, 1, "should have one wanted document") suite.Len(wantedDocs, 1, "should have one wanted document")
} }
func (suite *DocumentsTestSuite) TestGetMissingDocuments() { func (suite *DocumentsTestSuite) TestGetMissingDocuments() {
// Create Document // Create Document
_, err := suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ _, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID, ID: documentID,
Filepath: &documentFilepath, Filepath: &documentFilepath,
}) })
suite.NoError(err) suite.NoError(err)
missingDocs, err := suite.dbm.Queries.GetMissingDocuments(suite.dbm.Ctx, []string{documentID}) missingDocs, err := suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 0, "should have no wanted document") suite.Len(missingDocs, 0, "should have no wanted document")
missingDocs, err = suite.dbm.Queries.GetMissingDocuments(suite.dbm.Ctx, []string{"other"}) missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{"other"})
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 1, "should have one missing document") suite.Len(missingDocs, 1, "should have one missing document")
suite.Equal(documentID, missingDocs[0].ID, "should have missing doc") suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")
// TODO - https://github.com/sqlc-dev/sqlc/issues/3451 // TODO - https://github.com/sqlc-dev/sqlc/issues/3451
// missingDocs, err = suite.dbm.Queries.GetMissingDocuments(suite.dbm.Ctx, []string{}) // missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{})
// suite.Nil(err, "should have nil err") // suite.Nil(err, "should have nil err")
// suite.Len(missingDocs, 1, "should have one missing document") // suite.Len(missingDocs, 1, "should have one missing document")
// suite.Equal(documentID, missingDocs[0].ID, "should have missing doc") // suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")

View File

@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"embed" "embed"
_ "embed"
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
@@ -20,7 +19,6 @@ import (
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
} }
@@ -54,12 +52,9 @@ func init() {
// NewMgr Returns an initialized manager // 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")
} }
@@ -67,7 +62,7 @@ func NewMgr(c *config.Config) *DBManager {
} }
// init loads the DB 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 {
@@ -113,14 +108,14 @@ 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
if err := dbm.CacheTempTables(); err != nil { if err := dbm.CacheTempTables(ctx); err != nil {
log.Warn("Refreshing temp table cache failed: ", err) log.Warn("Refreshing temp table cache failed: ", err)
} }
@@ -128,7 +123,7 @@ func (dbm *DBManager) init() error {
} }
// Reload closes the DB & reinits // 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 {
@@ -136,7 +131,7 @@ 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
} }
@@ -144,15 +139,15 @@ func (dbm *DBManager) Reload() error {
} }
// CacheTempTables clears existing statistics and recalculates // CacheTempTables clears existing statistics and recalculates
func (dbm *DBManager) CacheTempTables() error { func (dbm *DBManager) CacheTempTables(ctx context.Context) error {
start := time.Now() start := time.Now()
if _, err := dbm.DB.ExecContext(dbm.Ctx, user_streaks); err != nil { if _, err := dbm.DB.ExecContext(ctx, user_streaks); 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()
if _, err := dbm.DB.ExecContext(dbm.Ctx, document_user_statistics); err != nil { if _, err := dbm.DB.ExecContext(ctx, document_user_statistics); 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))
@@ -162,7 +157,7 @@ func (dbm *DBManager) CacheTempTables() error {
// updateSettings ensures that we're enforcing foreign keys and enable journal // updateSettings ensures that we're enforcing foreign keys and enable journal
// mode. // mode.
func (dbm *DBManager) updateSettings() error { 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;
@@ -174,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 {

View File

@@ -1,6 +1,7 @@
package database package database
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
"time" "time"
@@ -46,7 +47,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
// Create User // Create User
rawAuthHash, _ := utils.GenerateToken(64) rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
_, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ _, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: userID, ID: userID,
Pass: &userPass, Pass: &userPass,
AuthHash: &authHash, AuthHash: &authHash,
@@ -54,7 +55,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
suite.NoError(err) suite.NoError(err)
// Create Document // Create Document
_, err = suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ _, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID, ID: documentID,
Title: &documentTitle, Title: &documentTitle,
Author: &documentAuthor, Author: &documentAuthor,
@@ -64,7 +65,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
suite.NoError(err) suite.NoError(err)
// Create Device // Create Device
_, err = suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ _, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID, ID: deviceID,
UserID: userID, UserID: userID,
DeviceName: deviceName, DeviceName: deviceName,
@@ -80,7 +81,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
counter += 1 counter += 1
// Add Item // Add Item
activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID, DocumentID: documentID,
DeviceID: deviceID, DeviceID: deviceID,
UserID: userID, UserID: userID,
@@ -95,7 +96,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
} }
// Initiate Cache // Initiate Cache
err = suite.dbm.CacheTempTables() err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err) suite.NoError(err)
} }
@@ -105,7 +106,7 @@ func (suite *DatabaseTestSuite) SetupTest() {
// - 󰊕 (q *Queries) UpsertDevice // - 󰊕 (q *Queries) UpsertDevice
func (suite *DatabaseTestSuite) TestDevice() { func (suite *DatabaseTestSuite) TestDevice() {
testDevice := "dev123" testDevice := "dev123"
device, err := suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ device, err := suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: testDevice, ID: testDevice,
UserID: userID, UserID: userID,
DeviceName: deviceName, DeviceName: deviceName,
@@ -123,7 +124,7 @@ func (suite *DatabaseTestSuite) TestDevice() {
// - 󰊕 (q *Queries) GetLastActivity // - 󰊕 (q *Queries) GetLastActivity
func (suite *DatabaseTestSuite) TestActivity() { func (suite *DatabaseTestSuite) TestActivity() {
// Validate Exists // Validate Exists
existsRows, err := suite.dbm.Queries.GetActivity(suite.dbm.Ctx, GetActivityParams{ existsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID, UserID: userID,
Offset: 0, Offset: 0,
Limit: 50, Limit: 50,
@@ -133,7 +134,7 @@ func (suite *DatabaseTestSuite) TestActivity() {
suite.Len(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 := suite.dbm.Queries.GetActivity(suite.dbm.Ctx, GetActivityParams{ doesntExistsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID, UserID: userID,
DocumentID: "unknownDoc", DocumentID: "unknownDoc",
DocFilter: true, DocFilter: true,
@@ -151,7 +152,7 @@ func (suite *DatabaseTestSuite) TestActivity() {
// - 󰊕 (q *Queries) GetDatabaseInfo // - 󰊕 (q *Queries) GetDatabaseInfo
// - 󰊕 (q *Queries) UpdateSettings // - 󰊕 (q *Queries) UpdateSettings
func (suite *DatabaseTestSuite) TestGetDailyReadStats() { func (suite *DatabaseTestSuite) TestGetDailyReadStats() {
readStats, err := suite.dbm.Queries.GetDailyReadStats(suite.dbm.Ctx, userID) readStats, err := suite.dbm.Queries.GetDailyReadStats(context.Background(), userID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(readStats, 30, "should have length of 30") suite.Len(readStats, 30, "should have length of 30")

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.27.0 // sqlc v1.29.0
package database package database
@@ -78,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"`

View File

@@ -67,7 +67,7 @@ WITH filtered_activity AS (
SELECT SELECT
document_id, document_id,
device_id, device_id,
LOCAL_TIME(activity.start_time, users.timezone) AS start_time, CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
title, title,
author, author,
duration, duration,
@@ -138,8 +138,8 @@ WHERE id = $device_id LIMIT 1;
SELECT SELECT
devices.id, devices.id,
devices.device_name, devices.device_name,
LOCAL_TIME(devices.created_at, users.timezone) AS created_at, CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) 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
@@ -163,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', LOCAL_TIME(COALESCE(dus.last_read, STRFTIME('%Y-%m-%dT%H:%M:%SZ', 0, 'unixepoch')), users.timezone))
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
@@ -236,26 +200,25 @@ SELECT
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
@@ -283,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,
LOCAL_TIME(progress.created_at, users.timezone) AS created_at CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) 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

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.27.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,
@@ -193,7 +193,7 @@ WITH filtered_activity AS (
SELECT SELECT
document_id, document_id,
device_id, device_id,
LOCAL_TIME(activity.start_time, users.timezone) AS start_time, CAST(LOCAL_TIME(activity.start_time, users.timezone) AS TEXT) AS start_time,
title, title,
author, author,
duration, duration,
@@ -216,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 interface{} `json:"start_time"` StartTime string `json:"start_time"`
Title *string `json:"title"` Title *string `json:"title"`
Author *string `json:"author"` Author *string `json:"author"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
@@ -422,8 +422,8 @@ const getDevices = `-- name: GetDevices :many
SELECT SELECT
devices.id, devices.id,
devices.device_name, devices.device_name,
LOCAL_TIME(devices.created_at, users.timezone) AS created_at, CAST(LOCAL_TIME(devices.created_at, users.timezone) AS TEXT) AS created_at,
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced CAST(LOCAL_TIME(devices.last_synced, users.timezone) AS TEXT) 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
@@ -433,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 interface{} `json:"created_at"` CreatedAt string `json:"created_at"`
LastSynced interface{} `json:"last_synced"` LastSynced string `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) {
@@ -543,87 +543,6 @@ 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', LOCAL_TIME(COALESCE(dus.last_read, STRFTIME('%Y-%m-%dT%H:%M:%SZ', 0, 'unixepoch')), users.timezone))
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, basepath, 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
@@ -719,35 +638,36 @@ SELECT
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"`
} }
@@ -766,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,
@@ -902,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,
LOCAL_TIME(progress.created_at, users.timezone) AS created_at CAST(LOCAL_TIME(progress.created_at, users.timezone) AS TEXT) 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
@@ -935,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 interface{} `json:"created_at"` CreatedAt string `json:"created_at"`
} }
func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) { func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]GetProgressRow, error) {

View File

@@ -1,6 +1,7 @@
package database package database
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"testing" "testing"
@@ -36,7 +37,7 @@ func (suite *UsersTestSuite) SetupTest() {
// Create User // Create User
rawAuthHash, _ := utils.GenerateToken(64) rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
_, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ _, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUserID, ID: testUserID,
Pass: &testUserPass, Pass: &testUserPass,
AuthHash: &authHash, AuthHash: &authHash,
@@ -44,7 +45,7 @@ func (suite *UsersTestSuite) SetupTest() {
suite.NoError(err) suite.NoError(err)
// Create Document // Create Document
_, err = suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ _, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID, ID: documentID,
Title: &documentTitle, Title: &documentTitle,
Author: &documentAuthor, Author: &documentAuthor,
@@ -53,7 +54,7 @@ func (suite *UsersTestSuite) SetupTest() {
suite.NoError(err) suite.NoError(err)
// Create Device // Create Device
_, err = suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ _, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID, ID: deviceID,
UserID: testUserID, UserID: testUserID,
DeviceName: deviceName, DeviceName: deviceName,
@@ -62,7 +63,7 @@ func (suite *UsersTestSuite) SetupTest() {
} }
func (suite *UsersTestSuite) TestGetUser() { func (suite *UsersTestSuite) TestGetUser() {
user, err := suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUserID) user, err := suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(testUserPass, *user.Pass) suite.Equal(testUserPass, *user.Pass)
} }
@@ -76,7 +77,7 @@ func (suite *UsersTestSuite) TestCreateUser() {
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
authHash := fmt.Sprintf("%x", rawAuthHash) authHash := fmt.Sprintf("%x", rawAuthHash)
changed, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ changed, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUser, ID: testUser,
Pass: &testPass, Pass: &testPass,
AuthHash: &authHash, AuthHash: &authHash,
@@ -85,29 +86,29 @@ func (suite *UsersTestSuite) TestCreateUser() {
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed) suite.Equal(int64(1), changed)
user, err := suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUser) user, err := suite.dbm.Queries.GetUser(context.Background(), testUser)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(testPass, *user.Pass) suite.Equal(testPass, *user.Pass)
} }
func (suite *UsersTestSuite) TestDeleteUser() { func (suite *UsersTestSuite) TestDeleteUser() {
changed, err := suite.dbm.Queries.DeleteUser(suite.dbm.Ctx, testUserID) changed, err := suite.dbm.Queries.DeleteUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have one changed row") suite.Equal(int64(1), changed, "should have one changed row")
_, err = suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUserID) _, err = suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.ErrorIs(err, sql.ErrNoRows, "should have no rows error") suite.ErrorIs(err, sql.ErrNoRows, "should have no rows error")
} }
func (suite *UsersTestSuite) TestGetUsers() { func (suite *UsersTestSuite) TestGetUsers() {
users, err := suite.dbm.Queries.GetUsers(suite.dbm.Ctx) users, err := suite.dbm.Queries.GetUsers(context.Background())
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(users, 1, "should have single user") suite.Len(users, 1, "should have single user")
} }
func (suite *UsersTestSuite) TestUpdateUser() { func (suite *UsersTestSuite) TestUpdateUser() {
newPassword := "newPass123" newPassword := "newPass123"
user, err := suite.dbm.Queries.UpdateUser(suite.dbm.Ctx, UpdateUserParams{ user, err := suite.dbm.Queries.UpdateUser(context.Background(), UpdateUserParams{
UserID: testUserID, UserID: testUserID,
Password: &newPassword, Password: &newPassword,
}) })
@@ -116,11 +117,11 @@ func (suite *UsersTestSuite) TestUpdateUser() {
} }
func (suite *UsersTestSuite) TestGetUserStatistics() { func (suite *UsersTestSuite) TestGetUserStatistics() {
err := suite.dbm.CacheTempTables() err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err) suite.NoError(err)
// Ensure Zero Items // Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStatistics(suite.dbm.Ctx) userStats, err := suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty") suite.Empty(userStats, "should be empty")
@@ -133,7 +134,7 @@ func (suite *UsersTestSuite) TestGetUserStatistics() {
counter += 1 counter += 1
// Add Item // Add Item
activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID, DocumentID: documentID,
DeviceID: deviceID, DeviceID: deviceID,
UserID: testUserID, UserID: testUserID,
@@ -147,21 +148,21 @@ func (suite *UsersTestSuite) TestGetUserStatistics() {
suite.Equal(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))
} }
err = suite.dbm.CacheTempTables() err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err) suite.NoError(err)
// Ensure One Item // Ensure One Item
userStats, err = suite.dbm.Queries.GetUserStatistics(suite.dbm.Ctx) userStats, err = suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(userStats, 1, "should have length of one") suite.Len(userStats, 1, "should have length of one")
} }
func (suite *UsersTestSuite) TestGetUsersStreaks() { func (suite *UsersTestSuite) TestGetUsersStreaks() {
err := suite.dbm.CacheTempTables() err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err) suite.NoError(err)
// Ensure Zero Items // Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStreaks(suite.dbm.Ctx, testUserID) userStats, err := suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty") suite.Empty(userStats, "should be empty")
@@ -174,7 +175,7 @@ func (suite *UsersTestSuite) TestGetUsersStreaks() {
counter += 1 counter += 1
// Add Item // Add Item
activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID, DocumentID: documentID,
DeviceID: deviceID, DeviceID: deviceID,
UserID: testUserID, UserID: testUserID,
@@ -188,11 +189,11 @@ func (suite *UsersTestSuite) TestGetUsersStreaks() {
suite.Equal(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))
} }
err = suite.dbm.CacheTempTables() err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err) suite.NoError(err)
// Ensure Two Item // Ensure Two Item
userStats, err = suite.dbm.Queries.GetUserStreaks(suite.dbm.Ctx, testUserID) userStats, err = suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err") suite.Nil(err, "should have nil err")
suite.Len(userStats, 2, "should have length of two") suite.Len(userStats, 2, "should have length of two")

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
'';
};
}
);
}

95
go.mod
View File

@@ -1,81 +1,86 @@
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/pkg/errors v0.9.1 // 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
maragu.dev/gomponents v1.1.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
) )

155
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,45 @@ 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=
maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
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

@@ -28,7 +28,7 @@ type SVGBezierOpposedLine struct {
func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphData { func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphData {
// Derive Height // Derive Height
var maxHeight int = 0 var maxHeight int
for _, item := range inputData { for _, item := range inputData {
if int(item) > maxHeight { if int(item) > maxHeight {
maxHeight = int(item) maxHeight = int(item)
@@ -39,19 +39,19 @@ func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphDat
var sizePercentage float32 = 0.5 var sizePercentage float32 = 0.5
// Scale Ratio -> Desired Height // Scale Ratio -> Desired Height
var sizeRatio float32 = float32(svgHeight) * sizePercentage / float32(maxHeight) sizeRatio := float32(svgHeight) * sizePercentage / float32(maxHeight)
// Point Block Offset // Point Block Offset
var blockOffset int = int(math.Floor(float64(svgWidth) / float64(len(inputData)))) blockOffset := int(math.Floor(float64(svgWidth) / float64(len(inputData))))
// Line & Bar Points // Line & Bar Points
linePoints := []SVGGraphPoint{} linePoints := []SVGGraphPoint{}
barPoints := []SVGGraphPoint{} barPoints := []SVGGraphPoint{}
// Bezier Fill Coordinates (Max X, Min X, Max Y) // Bezier Fill Coordinates (Max X, Min X, Max Y)
var maxBX int = 0 var maxBX int
var maxBY int = 0 var maxBY int
var minBX int = 0 var minBX int
for idx, item := range inputData { for idx, item := range inputData {
itemSize := int(float32(item) * sizeRatio) itemSize := int(float32(item) * sizeRatio)
itemY := svgHeight - itemSize itemY := svgHeight - itemSize
@@ -98,7 +98,7 @@ func getSVGBezierOpposedLine(pointA SVGGraphPoint, pointB SVGGraphPoint) SVGBezi
lengthY := float64(pointB.Y - pointA.Y) lengthY := float64(pointB.Y - pointA.Y)
return SVGBezierOpposedLine{ return SVGBezierOpposedLine{
Length: int(math.Sqrt(math.Pow(lengthX, 2) + math.Pow(lengthY, 2))), Length: int(math.Sqrt(lengthX*lengthX + lengthY*lengthY)),
Angle: int(math.Atan2(lengthY, lengthX)), Angle: int(math.Atan2(lengthY, lengthX)),
} }
} }
@@ -113,15 +113,15 @@ func getSVGBezierControlPoint(currentPoint *SVGGraphPoint, prevPoint *SVGGraphPo
} }
// Modifiers // Modifiers
var smoothingRatio float64 = 0.2 smoothingRatio := 0.2
var directionModifier float64 = 0 var directionModifier float64 = 0
if isReverse { if isReverse {
directionModifier = math.Pi directionModifier = math.Pi
} }
opposingLine := getSVGBezierOpposedLine(*prevPoint, *nextPoint) opposingLine := getSVGBezierOpposedLine(*prevPoint, *nextPoint)
var lineAngle float64 = float64(opposingLine.Angle) + directionModifier lineAngle := float64(opposingLine.Angle) + directionModifier
var lineLength float64 = float64(opposingLine.Length) * smoothingRatio lineLength := float64(opposingLine.Length) * smoothingRatio
// Calculate Control Point // Calculate Control Point
return SVGGraphPoint{ return SVGGraphPoint{
@@ -156,7 +156,7 @@ func getSVGBezierCurve(point SVGGraphPoint, index int, allPoints []SVGGraphPoint
} }
func getSVGBezierPath(allPoints []SVGGraphPoint) string { func getSVGBezierPath(allPoints []SVGGraphPoint) string {
var bezierSVGPath string = "" var bezierSVGPath string
for index, point := range allPoints { for index, point := range allPoints {
if index == 0 { if index == 0 {

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

@@ -41,9 +41,9 @@ const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/ima
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) { func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
var queryResults []gBooksQueryItem var queryResults []gBooksQueryItem
if metadataSearch.ID != nil { if metadataSearch.SourceID != nil {
// Use GBID // Use GBID
resp, err := performGBIDRequest(*metadataSearch.ID) resp, err := performGBIDRequest(*metadataSearch.SourceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -83,15 +83,16 @@ func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
queryResults = resp.Items queryResults = resp.Items
} else { } else {
return nil, errors.New("Invalid Data") return nil, errors.New("invalid data")
} }
// Normalize Data // Normalize Data
allMetadata := []MetadataInfo{} var allMetadata []MetadataInfo
for i := range queryResults { for i := range queryResults {
item := queryResults[i] // Range Value Pointer Issue item := queryResults[i] // Range Value Pointer Issue
itemResult := MetadataInfo{ itemResult := MetadataInfo{
ID: &item.ID, SourceID: &item.ID,
Source: SourceGoogleBooks,
Title: &item.Info.Title, Title: &item.Info.Title,
Description: &item.Info.Description, Description: &item.Info.Description,
} }
@@ -130,7 +131,7 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
out, err := os.Create(coverFilePath) out, err := os.Create(coverFilePath)
if err != nil { if err != nil {
log.Error("File Create Error") log.Error("File Create Error")
return errors.New("File Failure") return errors.New("file failure")
} }
defer out.Close() defer out.Close()
@@ -149,7 +150,7 @@ func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
_, err = io.Copy(out, resp.Body) _, err = io.Copy(out, resp.Body)
if err != nil { if err != nil {
log.Error("File Copy Error") log.Error("File Copy Error")
return errors.New("File Failure") return errors.New("file failure")
} }
return nil return nil
@@ -164,18 +165,13 @@ func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
return nil, errors.New("API Failure") return nil, errors.New("API Failure")
} }
parsedResp := gBooksQueryResponse{} var parsedResp gBooksQueryResponse
err = json.NewDecoder(resp.Body).Decode(&parsedResp) err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil { if err != nil {
log.Error("Google Books Query API Decode Failure") log.Error("Google Books Query API Decode Failure")
return nil, errors.New("API Failure") return nil, errors.New("API Failure")
} }
if len(parsedResp.Items) == 0 {
log.Warn("No Results")
return nil, errors.New("No Results")
}
return &parsedResp, nil return &parsedResp, nil
} }

View File

@@ -65,7 +65,7 @@ func TestGBooksGBIDMetadata(t *testing.T) {
GBID := "ZxwpakTv_MIC" GBID := "ZxwpakTv_MIC"
expectedURL := fmt.Sprintf(GBOOKS_GBID_INFO_URL, GBID) expectedURL := fmt.Sprintf(GBOOKS_GBID_INFO_URL, GBID)
metadataResp, err := getGBooksMetadata(MetadataInfo{ID: &GBID}) metadataResp, err := getGBooksMetadata(MetadataInfo{SourceID: &GBID})
assert.Nil(t, err, "should not have error") assert.Nil(t, err, "should not have error")
assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL") assert.Contains(t, hookDetails.URLs, expectedURL, "should have intercepted URL")

View File

@@ -25,12 +25,12 @@ var extensionHandlerMap = map[DocumentType]MetadataHandler{
type Source int type Source int
const ( const (
SOURCE_GBOOK Source = iota SourceGoogleBooks Source = iota
SOURCE_OLIB SourceOpenLibrary
) )
type MetadataInfo struct { type MetadataInfo struct {
ID *string SourceID *string
MD5 *string MD5 *string
PartialMD5 *string PartialMD5 *string
WordCount *int64 WordCount *int64
@@ -41,6 +41,7 @@ type MetadataInfo struct {
ISBN10 *string ISBN10 *string
ISBN13 *string ISBN13 *string
Type DocumentType Type DocumentType
Source Source
} }
// Downloads the Google Books cover file and saves it to the provided directory. // Downloads the Google Books cover file and saves it to the provided directory.
@@ -62,9 +63,9 @@ func CacheCover(gbid string, coverDir string, documentID string, overwrite bool)
// Searches source for metadata based on the provided information. // Searches source for metadata based on the provided information.
func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) { func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
switch s { switch s {
case SOURCE_GBOOK: case SourceGoogleBooks:
return getGBooksMetadata(metadataSearch) return getGBooksMetadata(metadataSearch)
case SOURCE_OLIB: case SourceOpenLibrary:
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 +88,7 @@ func GetWordCount(filepath string) (*int64, error) {
} }
return &totalWords, nil return &totalWords, nil
} else { } else {
return nil, fmt.Errorf("Invalid extension: %s", fileExtension) return nil, fmt.Errorf("invalid extension: %s", fileExtension)
} }
} }

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

@@ -4,14 +4,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"regexp"
"strings" "strings"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
var commentRE = regexp.MustCompile(`(?s)<!--(.*?)-->`)
func searchAnnasArchive(query string) ([]SearchItem, error) { func searchAnnasArchive(query string) ([]SearchItem, error) {
searchURL := "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en" searchURL := "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en"
url := fmt.Sprintf(searchURL, url.QueryEscape(query)) url := fmt.Sprintf(searchURL, url.QueryEscape(query))
@@ -32,72 +29,34 @@ 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) {
rawBook = getAnnasArchiveBookSelection(rawBook)
// 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) < 4 { if len(detailsSplit) < 3 {
return return
} }
language := detailsSplit[0]
fileType := detailsSplit[1]
fileSize := detailsSplit[3]
// 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
return allEntries, nil return allEntries, nil
} }
// getAnnasArchiveBookSelection parses potentially commented out HTML. For some reason
// Annas Archive comments out blocks "below the fold". They aren't rendered until you
// scroll. This attempts to parse the commented out HTML.
func getAnnasArchiveBookSelection(rawBook *goquery.Selection) *goquery.Selection {
rawHTML, err := rawBook.Html()
if err != nil {
return rawBook
}
strippedHTML := strings.TrimSpace(rawHTML)
if !strings.HasPrefix(strippedHTML, "<!--") || !strings.HasSuffix(strippedHTML, "-->") {
return rawBook
}
allMatches := commentRE.FindAllStringSubmatch(strippedHTML, -1)
if len(allMatches) != 1 || len(allMatches[0]) != 2 {
return rawBook
}
captureGroup := allMatches[0][1]
docReader := strings.NewReader(captureGroup)
doc, err := goquery.NewDocumentFromReader(docReader)
if err != nil {
return rawBook
}
return doc.Selection
}

View File

@@ -25,7 +25,7 @@ func getLibGenDownloadURL(md5 string, _ Source) ([]string, error) {
// Return Download URL // Return Download URL
downloadPath, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href") downloadPath, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
if !exists { if !exists {
return nil, fmt.Errorf("Download URL not found") return nil, fmt.Errorf("download URL not found")
} }
// Possible Funky URL // Possible Funky URL
@@ -37,10 +37,10 @@ func getLibraryDownloadURL(md5 string, source Source) ([]string, error) {
// Derive Info URL // Derive Info URL
var infoURL string var infoURL string
switch source { switch source {
case SOURCE_LIBGEN_FICTION, SOURCE_ANNAS_ARCHIVE: case SourceLibGen, SourceAnnasArchive:
infoURL = "http://library.lol/fiction/" + md5 infoURL = "http://library.lol/fiction/" + md5
case SOURCE_LIBGEN_NON_FICTION: // case SOURCE_LIBGEN_NON_FICTION:
infoURL = "http://library.lol/main/" + md5 // infoURL = "http://library.lol/main/" + md5
default: default:
return nil, errors.New("invalid source") return nil, errors.New("invalid source")
} }
@@ -62,7 +62,7 @@ func getLibraryDownloadURL(md5 string, source Source) ([]string, error) {
// downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href") // downloadURL, _ := doc.Find("#download [href*=cloudflare]").Attr("href")
downloadURL, exists := doc.Find("#download h2 a").Attr("href") downloadURL, exists := doc.Find("#download h2 a").Attr("href")
if !exists { if !exists {
return nil, errors.New("Download URL not found") return nil, errors.New("download URL not found")
} }
return []string{downloadURL}, nil return []string{downloadURL}, nil

View File

@@ -1,26 +1,44 @@
package search package search
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func searchLibGenFiction(query string) ([]SearchItem, error) { const LIBGEN_SEARCH_URL = "https://%s/index.php?req=ext:epub+%s&gmode=on"
searchURL := "https://libgen.is/fiction/?q=%s&language=English&format=epub"
url := fmt.Sprintf(searchURL, url.QueryEscape(query)) var libgenDomains []string = []string{
body, err := getPage(url) "libgen.vg",
if err != nil { "libgen.is",
return nil, err
}
return parseLibGenFiction(body)
} }
func parseLibGenFiction(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)
@@ -30,98 +48,25 @@ func parseLibGenFiction(body io.ReadCloser) ([]SearchItem, error) {
// Normalize Results // Normalize Results
var allEntries []SearchItem var allEntries []SearchItem
doc.Find("table.catalog tbody > tr").Each(func(ix int, rawBook *goquery.Selection) { doc.Find("#tablelibgen tbody > tr").Each(func(ix int, rawBook *goquery.Selection) {
// Parse File Details
fileItem := rawBook.Find("td:nth-child(5)")
fileDesc := fileItem.Text()
fileDescSplit := strings.Split(fileDesc, "/")
fileType := strings.ToLower(strings.TrimSpace(fileDescSplit[0]))
fileSize := strings.TrimSpace(fileDescSplit[1])
// Parse Upload Date
uploadedRaw, _ := fileItem.Attr("title")
uploadedDateRaw := strings.Split(uploadedRaw, "Uploaded at ")[1]
uploadDate, _ := time.Parse("2006-01-02 15:04:05", uploadedDateRaw)
// Parse MD5 // Parse MD5
editHref, _ := rawBook.Find("td:nth-child(7) a").Attr("href") linksRaw := rawBook.Find("td:nth-child(9) a")
hrefArray := strings.Split(editHref, "/") linksHref, _ := linksRaw.Attr("href")
id := hrefArray[len(hrefArray)-1] hrefArray := strings.Split(linksHref, "?md5=")
if len(hrefArray) == 0 {
// Parse Other Details return
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 searchLibGenNonFiction(query string) ([]SearchItem, error) {
searchURL := "https://libgen.is/search.php?req=%s"
url := fmt.Sprintf(searchURL, url.QueryEscape(query))
body, err := getPage(url)
if err != nil {
return nil, err
}
return parseLibGenNonFiction(body)
}
func parseLibGenNonFiction(body io.ReadCloser) ([]SearchItem, error) {
// Parse
defer body.Close()
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results
var allEntries []SearchItem
doc.Find("table.c tbody > tr:nth-child(n + 2)").Each(func(ix int, rawBook *goquery.Selection) {
// Parse Type & Size
fileSize := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(8)").Text()))
fileType := strings.ToLower(strings.TrimSpace(rawBook.Find("td:nth-child(9)").Text()))
// Parse MD5
titleRaw := rawBook.Find("td:nth-child(3) [id]")
editHref, _ := titleRaw.Attr("href")
hrefArray := strings.Split(editHref, "?md5=")
id := hrefArray[1] 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

View File

@@ -25,9 +25,8 @@ const (
type Source string type Source string
const ( const (
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive" SourceAnnasArchive Source = "Annas Archive"
SOURCE_LIBGEN_FICTION Source = "LibGen Fiction" SourceLibGen Source = "LibGen"
SOURCE_LIBGEN_NON_FICTION Source = "LibGen Non-fiction"
) )
type SearchItem struct { type SearchItem struct {
@@ -45,9 +44,8 @@ type searchFunc func(query string) (searchResults []SearchItem, err error)
type downloadFunc func(md5 string, source Source) (downloadURL []string, err error) type downloadFunc func(md5 string, source Source) (downloadURL []string, err error)
var searchDefs = map[Source]searchFunc{ var searchDefs = map[Source]searchFunc{
SOURCE_ANNAS_ARCHIVE: searchAnnasArchive, SourceAnnasArchive: searchAnnasArchive,
SOURCE_LIBGEN_FICTION: searchLibGenFiction, SourceLibGen: searchLibGen,
SOURCE_LIBGEN_NON_FICTION: searchLibGenNonFiction,
} }
var downloadFuncs = []downloadFunc{ var downloadFuncs = []downloadFunc{
@@ -60,7 +58,6 @@ func SearchBook(query string, source Source) ([]SearchItem, error) {
if !found { if !found {
return nil, fmt.Errorf("invalid source: %s", source) return nil, fmt.Errorf("invalid source: %s", source)
} }
log.Debug("Source: ", source)
return searchFunc(query) return searchFunc(query)
} }

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,13 +0,0 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
packages = with pkgs; [
go
nodejs
nodePackages.tailwindcss
python311Packages.grip
];
shellHook = ''
export PATH=$PATH:~/go/bin
'';
}

View File

@@ -2,6 +2,7 @@
module.exports = { module.exports = {
content: [ content: [
"./templates/**/*.{tmpl,html,htm,svg}", "./templates/**/*.{tmpl,html,htm,svg}",
"./web/**/*.go",
"./assets/local/*.{html,htm,svg,js}", "./assets/local/*.{html,htm,svg,js}",
"./assets/reader/*.{html,htm,svg,js}", "./assets/reader/*.{html,htm,svg,js}",
], ],
@@ -16,6 +17,20 @@ module.exports = {
minWidth: { minWidth: {
40: "10rem", 40: "10rem",
}, },
animation: {
notification:
"slideIn 0.25s ease-out forwards, slideOut 0.25s ease-out 4.5s forwards",
},
keyframes: {
slideIn: {
"0%": { transform: "translateX(100%)" },
"100%": { transform: "translateX(0)" },
},
slideOut: {
"0%": { transform: "translateX(0)" },
"100%": { transform: "translateX(100%)" },
},
},
}, },
}, },
plugins: [], plugins: [],

View File

@@ -1,28 +0,0 @@
{{ 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

@@ -1,50 +0,0 @@
<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,12 +0,0 @@
{{ 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

@@ -1,32 +0,0 @@
<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

@@ -1,64 +0,0 @@
<div class="w-full">
<div class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
<div>
<div class="flex justify-between">
<p class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
{{ .Name }} Leaderboard
</p>
<div class="flex gap-2 text-xs text-gray-400 items-center">
<label for="all-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white">all</label>
<label for="year-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white">year</label>
<label for="month-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white">month</label>
<label for="week-{{ .Name }}"
class="cursor-pointer hover:text-black dark:hover:text-white">week</label>
</div>
</div>
</div>
<input type="radio"
name="options-{{ .Name }}"
id="all-{{ .Name }}"
class="hidden peer/All"
checked />
<input type="radio"
name="options-{{ .Name }}"
id="year-{{ .Name }}"
class="hidden peer/Year" />
<input type="radio"
name="options-{{ .Name }}"
id="month-{{ .Name }}"
class="hidden peer/Month" />
<input type="radio"
name="options-{{ .Name }}"
id="week-{{ .Name }}"
class="hidden peer/Week" />
{{ range $key, $data := .Data }}
<div class="flex items-end my-6 space-x-2 hidden peer-checked/{{ $key }}:block">
{{ $length := len $data }}
{{ if eq $length 0 }}
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
{{ else }}
<p class="text-5xl font-bold text-black dark:text-white">{{ (index $data 0).UserID }}</p>
{{ end }}
</div>
<div class="hidden dark:text-white peer-checked/{{ $key }}:block">
{{ range $index, $item := $data }}
{{ if lt $index 3 }}
{{ if eq $index 0 }}
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
{{ else }}
<div class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200">
{{ end }}
<div>
<p>{{ $item.UserID }}</p>
</div>
<div class="flex items-end font-bold">{{ $item.Value }}</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end}}
</div>
</div>

View File

@@ -1,147 +0,0 @@
{{ 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

@@ -1,54 +0,0 @@
<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

@@ -1,32 +0,0 @@
{{ $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

@@ -1,28 +0,0 @@
{{ 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

@@ -1,254 +0,0 @@
{{ template "base" . }}
{{ define "title" }}Documents{{ end }}
{{ define "header" }}<a href="/documents">Documents</a>{{ end }}
{{ define "content" }}
<div class="h-full w-full relative">
<!-- Document Info -->
<div
class="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
>
<div
class="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
>
<label class="z-10 cursor-pointer" for="edit-cover-button">
<img
class="rounded object-fill w-full"
src="/documents/{{ .Data.ID }}/cover"
/>
</label>
{{ if .Data.Filepath }}
<a
href="/reader#id={{ .Data.ID }}&type=REMOTE"
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>Read</a
>
{{ end }}
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
<div class="min-w-[50%] md:mr-2">
<div class="flex gap-1 text-sm">
<p class="text-gray-500">ISBN-10:</p>
<p class="font-medium">{{ or .Data.Isbn10 "N/A" }}</p>
</div>
<div class="flex gap-1 text-sm">
<p class="text-gray-500">ISBN-13:</p>
<p class="font-medium">{{ or .Data.Isbn13 "N/A" }}</p>
</div>
</div>
<div
class="flex grow justify-between my-auto text-gray-500 dark:text-gray-500"
>
<input
type="checkbox"
id="edit-cover-button"
class="hidden css-button"
/>
<div
class="absolute z-30 flex flex-col gap-2 top-0 left-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
>
<form
method="POST"
enctype="multipart/form-data"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
>
<input type="file" id="cover_file" name="cover_file" />
{{ template "component/button" (dict "Title" "Upload Cover") }}
</form>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-72 text-black dark:text-white text-sm"
>
<input
type="checkbox"
checked
id="remove_cover"
name="remove_cover"
class="hidden"
/>
{{ template "component/button" (dict "Title" "Remove Cover") }}
</form>
</div>
<div class="relative">
<label for="delete-button" class="cursor-pointer"
>{{ template "svg/delete" (dict "Size" 28) }}</label
>
<input
type="checkbox"
id="delete-button"
class="hidden css-button"
/>
<div
class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
>
<form
method="POST"
action="./{{ .Data.ID }}/delete"
class="text-black dark:text-white text-sm w-24"
>
{{ template "component/button" (dict "Title" "Delete") }}
</form>
</div>
</div>
<a href="../activity?document={{ .Data.ID }}"
>{{ template "svg/activity" (dict "Size" 28) }}</a
>
<div class="relative">
<label for="search-button"
>{{ template "svg/search" (dict "Size" 28) }}</label
>
<input
type="checkbox"
id="search-button"
class="hidden css-button"
/>
<div
class="absolute z-30 bottom-7 left-5 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
>
<form
method="POST"
action="./{{ .Data.ID }}/identify"
class="flex flex-col gap-2 text-black dark:text-white text-sm"
>
<input
type="text"
id="title"
name="title"
placeholder="Title"
value="{{ or .Data.Title nil }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
<input
type="text"
id="author"
name="author"
placeholder="Author"
value="{{ or .Data.Author nil }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
<input
type="text"
id="isbn"
name="isbn"
placeholder="ISBN 10 / ISBN 13"
value="{{ or .Data.Isbn13 (or .Data.Isbn10 nil) }}"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
/>
{{ template "component/button" (dict "Title" "Identify") }}
</form>
</div>
</div>
{{ if .Data.Filepath }}
<a href="./{{ .Data.ID }}/file"
>{{ template "svg/download" (dict "Size" 28) }}</a
>
{{ else }}
{{ template "svg/download" (dict "Size" 28 "Disabled" true) }}
{{ end }}
</div>
</div>
</div>
<div class="grid sm:grid-cols-2 justify-between gap-4 pb-4">
{{ template "component/key-val-edit" (dict
"Title" "Title"
"Value" .Data.Title
"URL" (printf "./%s/edit" .Data.ID)
"FormValue" "title"
)
}}
{{ template "component/key-val-edit" (dict
"Title" "Author"
"Value" .Data.Author
"URL" (printf "./%s/edit" .Data.ID)
"FormValue" "author"
)
}}
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>Time Read</p>
<label class="my-auto" for="progress-info-button"
>{{ template "svg/info" (dict "Size" 18) }}</label
>
<input
type="checkbox"
id="progress-info-button"
class="hidden css-button"
/>
<div
class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600"
>
<div class="text-xs flex">
<p class="text-gray-400 w-32">Seconds / Percent</p>
<p class="font-medium dark:text-white">
{{ .Data.SecondsPerPercent }}
</p>
</div>
<div class="text-xs flex">
<p class="text-gray-400 w-32">Words / Minute</p>
<p class="font-medium dark:text-white">{{ .Data.Wpm }}</p>
</div>
<div class="text-xs flex">
<p class="text-gray-400 w-32">Est. Time Left</p>
<p class="font-medium dark:text-white whitespace-nowrap">
{{ niceSeconds .TotalTimeLeftSeconds }}
</p>
</div>
</div>
</div>
<p class="font-medium text-lg">
{{ niceSeconds .Data.TotalTimeSeconds }}
</p>
</div>
<div>
<p class="text-gray-500">Progress</p>
<p class="font-medium text-lg">{{ .Data.Percentage }}%</p>
</div>
</div>
<div class="relative">
<div class="text-gray-500 inline-flex gap-2 relative">
<p>Description</p>
<label class="my-auto" for="edit-description-button"
>{{ template "svg/edit" (dict "Size" 18) }}</label
>
</div>
</div>
<div class="relative font-medium text-justify hyphens-auto">
<input
type="checkbox"
id="edit-description-button"
class="hidden css-button"
/>
<div
class="absolute h-full w-full min-h-[10em] z-30 top-1 right-0 gap-4 flex transition-all duration-200"
>
<img
class="hidden md:block invisible rounded w-44 md:w-60 lg:w-80 object-fill"
src="/documents/{{ .Data.ID }}/cover"
/>
<form
method="POST"
action="./{{ .Data.ID }}/edit"
class="flex flex-col gap-2 w-full text-black bg-gray-200 rounded shadow-lg shadow-gray-500 dark:text-white dark:shadow-gray-900 dark:bg-gray-600 text-sm p-3"
>
<textarea
type="text"
id="description"
name="description"
class="h-full w-full p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"
>
{{ or .Data.Description "N/A" }}</textarea
>
{{ template "component/button" (dict "Title" "Save") }}
</form>
</div>
<p>{{ or .Data.Description "N/A" }}</p>
</div>
</div>
{{ template "component/metadata" (dict
"ID" .Data.ID
"Metadata" .Metadata
"Error" .MetadataError
)
}}
</div>
{{ end }}

View File

@@ -1,99 +0,0 @@
{{ template "base" . }}
{{ define "title" }}Documents{{ end }}
{{ define "header" }}<a href="./documents">Documents</a>{{ end }}
{{ define "content" }}
<div
class="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form
class="flex gap-4 flex-col lg:flex-row"
action="./documents"
method="GET"
>
<div class="flex flex-col w-full grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
{{ template "svg/search2" (dict "Size" 15) }}
</span>
<input
type="text"
id="search"
name="search"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Search Author / Title"
/>
</div>
</div>
<div class="lg:w-60">
{{ template "component/button" (dict
"Title" "Search"
"Variant" "Secondary"
)
}}
</div>
</form>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{{ range $doc := .Data }}
{{ template "component/document-card" $doc }}
{{ end }}
</div>
<div class="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
{{ if .PreviousPage }}
<a
href="./documents?page={{ .PreviousPage }}&limit={{ .PageLimit }}"
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
>◄</a
>
{{ end }}
{{ if .NextPage }}
<a
href="./documents?page={{ .NextPage }}&limit={{ .PageLimit }}"
class="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
>►</a
>
{{ end }}
</div>
<div
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
>
<input type="checkbox" id="upload-file-button" class="hidden css-button" />
<div
class="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"
action="./documents"
class="flex flex-col gap-2"
>
<input
type="file"
accept=".epub"
id="document_file"
name="document_file"
/>
<button
class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
type="submit"
>
Upload File
</button>
</form>
<label for="upload-file-button">
<div
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel Upload
</div>
</label>
</div>
<label
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
for="upload-file-button"
>{{ template "svg/upload" (dict "Size" 34) }}</label
>
</div>
{{ end }}

View File

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

View File

@@ -1,28 +0,0 @@
{{ template "base" . }}
{{ define "title" }}Progress{{ end }}
{{ define "header" }}<a href="./progress">Progress</a>{{ end }}
{{ define "content" }}
<div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow">
<!-- Table Component - Utilizes Template "table-cell" -->
{{ template "component/table" (dict
"Columns" (slice "Document" "Device Name" "Percentage" "Created At")
"Keys" (slice "Document" "DeviceName" "Percentage" "CreatedAt")
"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 "Percentage" }}
{{ index (fields .Data) .Name }}%
{{ else }}
{{ index (fields .Data) .Name }}
{{ end }}
{{ end }}

View File

@@ -1,157 +0,0 @@
{{ template "base" . }}
{{ define "title" }}Search{{ end }}
{{ define "header" }}<a href="./search">Search</a>{{ end }}
{{ define "content" }}
<div class="w-full flex flex-col md:flex-row gap-4">
<div class="flex flex-col gap-4 grow">
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<form class="flex gap-4 flex-col lg:flex-row" action="./search">
<div class="flex flex-col w-full grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
{{ template "svg/search2" (dict "Size" 15) }}
</span>
<input
type="text"
id="query"
name="query"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Query"
/>
</div>
</div>
<div class="flex relative min-w-[12em]">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
{{ template "svg/documents" (dict "Size" 15) }}
</span>
<select
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
id="source"
name="source"
>
<option value="Annas Archive">Annas Archive</option>
<option value="LibGen Fiction">LibGen Fiction</option>
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
</select>
</div>
<div class="lg:w-60">
{{ template "component/button" (dict
"Title" "Search"
"Variant" "Secondary"
)
}}
</div>
</form>
{{ if .SearchErrorMessage }}
<span class="text-red-400 text-xs">{{ .SearchErrorMessage }}</span>
{{ end }}
</div>
<div class="inline-block min-w-full overflow-hidden rounded shadow">
<table
class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
>
<thead class="text-gray-800 dark:text-gray-400">
<tr>
<th
scope="col"
class="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
></th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Document
</th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Series
</th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Type
</th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Size
</th>
<th
scope="col"
class="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Date
</th>
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not .Data }}
<tr>
<td class="text-center p-3" colspan="6">No Results</td>
</tr>
{{ end }}
{{ range $item := .Data }}
<tr>
<td
class="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
>
<form action="./search" method="POST">
<input
class="hidden"
type="text"
id="source"
name="source"
value="{{ $.Source }}"
/>
<input
class="hidden"
type="text"
id="title"
name="title"
value="{{ $item.Title }}"
/>
<input
class="hidden"
type="text"
id="author"
name="author"
value="{{ $item.Author }}"
/>
<button name="id" value="{{ $item.ID }}">
{{ template "svg/download" }}
</button>
</form>
</td>
<td class="p-3 border-b border-gray-200">
{{ $item.Author }} - {{ $item.Title }}
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ or $item.Series "N/A" }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ or $item.FileType "N/A" }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ or $item.FileSize "N/A" }}</p>
</td>
<td class="hidden md:table-cell p-3 border-b border-gray-200">
<p>{{ or $item.UploadDate "N/A" }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}

20
web/assets/embed.go Normal file
View File

@@ -0,0 +1,20 @@
package assets
import (
"embed"
"fmt"
"io/fs"
g "maragu.dev/gomponents"
)
//go:embed svgs/*
var assets embed.FS
func Asset(name string) g.Node {
b, err := fs.ReadFile(assets, name)
if err != nil {
fmt.Println(err)
}
return g.Raw(string(b))
}

18
web/assets/icons.go Normal file
View File

@@ -0,0 +1,18 @@
package assets
import (
"strconv"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func Icon(name string, size int) g.Node {
return h.SVG(
g.Attr("width", strconv.Itoa(size)),
g.Attr("height", strconv.Itoa(size)),
g.Attr("viewBox", "0 0 24 24"),
g.Attr("fill", "currentColor"),
Asset("svgs/"+name+".svg"),
)
}

View File

@@ -0,0 +1,2 @@
<path d="M9.5 2C8.67157 2 8 2.67157 8 3.5V4.5C8 5.32843 8.67157 6 9.5 6H14.5C15.3284 6 16 5.32843 16 4.5V3.5C16 2.67157 15.3284 2 14.5 2H9.5Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 4.03662C5.24209 4.10719 4.44798 4.30764 3.87868 4.87694C3 5.75562 3 7.16983 3 9.99826V15.9983C3 18.8267 3 20.2409 3.87868 21.1196C4.75736 21.9983 6.17157 21.9983 9 21.9983H15C17.8284 21.9983 19.2426 21.9983 20.1213 21.1196C21 20.2409 21 18.8267 21 15.9983V9.99826C21 7.16983 21 5.75562 20.1213 4.87694C19.552 4.30764 18.7579 4.10719 17.5 4.03662V4.5C17.5 6.15685 16.1569 7.5 14.5 7.5H9.5C7.84315 7.5 6.5 6.15685 6.5 4.5V4.03662ZM7 9.75C6.58579 9.75 6.25 10.0858 6.25 10.5C6.25 10.9142 6.58579 11.25 7 11.25H7.5C7.91421 11.25 8.25 10.9142 8.25 10.5C8.25 10.0858 7.91421 9.75 7.5 9.75H7ZM10.5 9.75C10.0858 9.75 9.75 10.0858 9.75 10.5C9.75 10.9142 10.0858 11.25 10.5 11.25H17C17.4142 11.25 17.75 10.9142 17.75 10.5C17.75 10.0858 17.4142 9.75 17 9.75H10.5ZM7 13.25C6.58579 13.25 6.25 13.5858 6.25 14C6.25 14.4142 6.58579 14.75 7 14.75H7.5C7.91421 14.75 8.25 14.4142 8.25 14C8.25 13.5858 7.91421 13.25 7.5 13.25H7ZM10.5 13.25C10.0858 13.25 9.75 13.5858 9.75 14C9.75 14.4142 10.0858 14.75 10.5 14.75H17C17.4142 14.75 17.75 14.4142 17.75 14C17.75 13.5858 17.4142 13.25 17 13.25H10.5ZM7 16.75C6.58579 16.75 6.25 17.0858 6.25 17.5C6.25 17.9142 6.58579 18.25 7 18.25H7.5C7.91421 18.25 8.25 17.9142 8.25 17.5C8.25 17.0858 7.91421 16.75 7.5 16.75H7ZM10.5 16.75C10.0858 16.75 9.75 17.0858 9.75 17.5C9.75 17.9142 10.0858 18.25 10.5 18.25H17C17.4142 18.25 17.75 17.9142 17.75 17.5C17.75 17.0858 17.4142 16.75 17 16.75H10.5Z"/>

5
web/assets/svgs/add.svg Normal file
View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 8.25C12.4142 8.25 12.75 8.58579 12.75 9V11.25H15C15.4142 11.25 15.75 11.5858 15.75 12C15.75 12.4142 15.4142 12.75 15 12.75H12.75L12.75 15C12.75 15.4142 12.4142 15.75 12 15.75C11.5858 15.75 11.25 15.4142 11.25 15V12.75H9C8.58579 12.75 8.25 12.4142 8.25 12C8.25 11.5858 8.58579 11.25 9 11.25H11.25L11.25 9C11.25 8.58579 11.5858 8.25 12 8.25Z"
/>

View File

@@ -0,0 +1,9 @@
<path
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
fill="white"
/>

View File

@@ -0,0 +1,6 @@
<path
d="M3 6.52381C3 6.12932 3.32671 5.80952 3.72973 5.80952H8.51787C8.52437 4.9683 8.61554 3.81504 9.45037 3.01668C10.1074 2.38839 11.0081 2 12 2C12.9919 2 13.8926 2.38839 14.5496 3.01668C15.3844 3.81504 15.4756 4.9683 15.4821 5.80952H20.2703C20.6733 5.80952 21 6.12932 21 6.52381C21 6.9183 20.6733 7.2381 20.2703 7.2381H3.72973C3.32671 7.2381 3 6.9183 3 6.52381Z"
/>
<path
d="M11.6066 22H12.3935C15.101 22 16.4547 22 17.3349 21.1368C18.2151 20.2736 18.3052 18.8576 18.4853 16.0257L18.7448 11.9452C18.8425 10.4086 18.8913 9.64037 18.4498 9.15352C18.0082 8.66667 17.2625 8.66667 15.7712 8.66667H8.22884C6.7375 8.66667 5.99183 8.66667 5.55026 9.15352C5.1087 9.64037 5.15756 10.4086 5.25528 11.9452L5.51479 16.0257C5.69489 18.8576 5.78494 20.2736 6.66513 21.1368C7.54532 22 8.89906 22 11.6066 22Z"
/>

View File

@@ -0,0 +1,2 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.27103 2.11151C5.46135 2.21816 5.03258 2.41324 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.82716 15.513 6.44305 15.5132 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.2092 2 7.10452 2.00172 6.27103 2.11151ZM6.75862 6.59459C6.75862 6.1468 7.12914 5.78378 7.58621 5.78378H16.4138C16.8709 5.78378 17.2414 6.1468 17.2414 6.59459C17.2414 7.04239 16.8709 7.40541 16.4138 7.40541H7.58621C7.12914 7.40541 6.75862 7.04239 6.75862 6.59459ZM7.58621 9.56757C7.12914 9.56757 6.75862 9.93058 6.75862 10.3784C6.75862 10.8262 7.12914 11.1892 7.58621 11.1892H13.1034C13.5605 11.1892 13.931 10.8262 13.931 10.3784C13.931 9.93058 13.5605 9.56757 13.1034 9.56757H7.58621Z" />
<path d="M7.47341 17.1351H8.68965H13.1034H19.9991C19.9956 18.2657 19.9776 19.1088 19.8862 19.775C19.7773 20.5683 19.5782 20.9884 19.2728 21.2876C18.9674 21.5868 18.5387 21.7818 17.729 21.8885C16.8955 21.9983 15.7908 22 14.2069 22H9.7931C8.2092 22 7.10452 21.9983 6.27103 21.8885C5.46135 21.7818 5.03258 21.5868 4.72718 21.2876C4.42179 20.9884 4.22268 20.5683 4.11382 19.775C4.07259 19.4746 4.0463 19.1382 4.02952 18.7558C4.30088 18.0044 4.93365 17.4264 5.72738 17.218C6.01657 17.1421 6.39395 17.1351 7.47341 17.1351Z" />

View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>

View File

@@ -0,0 +1 @@
<path fill-rule="nonzero" fill-opacity="1" d="M 18.855469 9.429688 C 18.855469 9.660156 18.773438 9.863281 18.601562 10.03125 L 12.601562 16.03125 C 12.433594 16.199219 12.230469 16.285156 12 16.285156 C 11.769531 16.285156 11.566406 16.199219 11.398438 16.03125 L 5.398438 10.03125 C 5.226562 9.863281 5.144531 9.660156 5.144531 9.429688 C 5.144531 9.195312 5.226562 8.996094 5.398438 8.824219 C 5.566406 8.65625 5.769531 8.570312 6 8.570312 L 18 8.570312 C 18.230469 8.570312 18.433594 8.65625 18.601562 8.824219 C 18.773438 8.996094 18.855469 9.195312 18.855469 9.429688 Z M 18.855469 9.429688 "/>

9
web/assets/svgs/edit.svg Normal file
View File

@@ -0,0 +1,9 @@
<path
d="M21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.6982 7.19469C20.5539 7.16345 20.3722 7.11589 20.1651 7.04404C19.6108 6.85172 18.8823 6.48827 18.197 5.803C17.5117 5.11774 17.1483 4.38923 16.956 3.8349C16.8841 3.62781 16.8366 3.44609 16.8053 3.30179L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624Z"
/>
<path
d="M14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.99089 12.1646C9.17157 11.6225 9.26191 11.3515 9.38344 11.0965C9.52679 10.7957 9.70249 10.5114 9.90743 10.2487C10.0812 10.0259 10.2832 9.82394 10.6872 9.41993L15.6033 4.50385C15.867 5.19804 16.3293 6.05663 17.1363 6.86366C17.9434 7.67069 18.802 8.13296 19.4962 8.39674L14.5801 13.3128Z"
/>
<path
d="M20.5355 20.5355C22 19.0711 22 16.714 22 12C22 10.4517 22 9.15774 21.9481 8.0661L15.586 14.4283C15.2347 14.7797 14.9708 15.0437 14.6738 15.2753C14.3252 15.5473 13.948 15.7804 13.5488 15.9706C13.2088 16.1327 12.8546 16.2506 12.3833 16.4076L9.45143 17.3849C8.64568 17.6535 7.75734 17.4438 7.15678 16.8432C6.55621 16.2427 6.34651 15.3543 6.61509 14.5486L7.59235 11.6167C7.74936 11.1454 7.86732 10.7912 8.02935 10.4512C8.21958 10.052 8.45272 9.6748 8.72466 9.32615C8.9563 9.02918 9.22032 8.76528 9.57173 8.41404L15.9339 2.05188C14.8423 2 13.5483 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355Z"
/>

21
web/assets/svgs/gitea.svg Normal file
View File

@@ -0,0 +1,21 @@
<defs>
<clipPath id="clip-0">
<path clip-rule="nonzero" d="M 17.425781 0.207031 L 20.164062 0.207031 L 20.164062 18 L 17.425781 18 Z M 17.425781 0.207031 "/>
</clipPath>
<clipPath id="clip-1">
<path clip-rule="nonzero" d="M 20.054688 2.347656 L 23.929688 2.347656 L 23.929688 18 L 20.054688 18 Z M 20.054688 2.347656 "/>
</clipPath>
<clipPath id="clip-2">
<path clip-rule="nonzero" d="M 0 0.207031 L 10 0.207031 L 10 24 L 0 24 Z M 0 0.207031 "/>
</clipPath>
</defs>
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 14.34375 8.304688 C 13.816406 8.304688 13.425781 8.917969 13.425781 10.394531 C 13.425781 11.503906 13.683594 12.277344 14.3125 12.277344 C 14.847656 12.277344 15.210938 11.53125 15.210938 10.347656 C 15.210938 9.007812 14.886719 8.304688 14.34375 8.304688 Z M 13.292969 18.726562 C 13.167969 19.089844 13.046875 19.476562 13.046875 19.929688 C 13.046875 20.835938 13.53125 21.109375 14.199219 21.109375 C 14.753906 21.109375 15.507812 21.019531 15.507812 19.792969 C 15.507812 19.066406 15.144531 19.019531 14.683594 18.953125 Z M 16.117188 8.375 C 16.289062 8.894531 16.46875 9.621094 16.46875 10.667969 C 16.46875 13.1875 15.640625 14.664062 14.4375 14.664062 C 14.132812 14.664062 13.855469 14.570312 13.683594 14.457031 L 13.371094 15.660156 L 14.304688 15.796875 C 15.953125 16.046875 16.925781 16.160156 16.925781 19.179688 C 16.925781 21.789062 15.964844 23.265625 14.304688 23.265625 C 12.578125 23.265625 11.917969 22.222656 11.917969 20.429688 C 11.917969 19.40625 12.109375 18.863281 12.445312 18.113281 C 12.128906 17.796875 12.023438 17.230469 12.023438 16.613281 C 12.023438 16.117188 12.128906 15.660156 12.300781 15.230469 C 12.472656 14.796875 12.664062 14.367188 12.894531 13.867188 C 12.425781 13.324219 12.074219 12.140625 12.074219 10.460938 C 12.074219 7.851562 12.796875 6.058594 14.257812 6.058594 C 14.667969 6.058594 14.914062 6.148438 15.132812 6.285156 L 16.992188 6.285156 L 16.992188 8.214844 L 16.117188 8.375 "/>
<g clip-path="url(#clip-0)">
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 18.671875 4.246094 C 18.128906 4.246094 17.8125 3.5 17.8125 2.203125 C 17.8125 0.910156 18.128906 0.207031 18.671875 0.207031 C 19.226562 0.207031 19.539062 0.910156 19.539062 2.203125 C 19.539062 3.5 19.226562 4.246094 18.671875 4.246094 Z M 17.441406 17.890625 L 17.441406 16.097656 L 17.925781 15.941406 C 18.0625 15.894531 18.082031 15.828125 18.082031 15.484375 L 18.082031 8.808594 C 18.082031 8.5625 18.050781 8.402344 17.957031 8.335938 L 17.441406 7.902344 L 17.546875 6.0625 L 19.519531 6.0625 L 19.519531 15.484375 C 19.519531 15.851562 19.53125 15.894531 19.671875 15.941406 L 20.160156 16.097656 L 20.160156 17.890625 L 17.441406 17.890625 "/>
</g>
<g clip-path="url(#clip-1)">
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 23.929688 17.011719 C 23.519531 17.488281 22.921875 17.917969 22.375 17.917969 C 21.242188 17.917969 20.8125 16.832031 20.8125 14.261719 L 20.8125 8.316406 C 20.8125 8.179688 20.8125 8.089844 20.734375 8.089844 L 20.066406 8.089844 L 20.066406 6.066406 C 20.90625 5.839844 21.242188 4.839844 21.347656 2.367188 L 22.253906 2.367188 L 22.253906 5.589844 C 22.253906 5.75 22.253906 5.820312 22.328125 5.820312 L 23.671875 5.820312 L 23.671875 8.089844 L 22.253906 8.089844 L 22.253906 13.515625 C 22.253906 14.855469 22.386719 15.375 22.902344 15.375 C 23.167969 15.375 23.445312 15.21875 23.671875 15.011719 L 23.929688 17.011719 "/>
</g>
<g clip-path="url(#clip-2)">
<path fill-rule="nonzero" fill="rgb(0%, 0%, 0%)" fill-opacity="1" d="M 9.800781 11.054688 L 5.4375 0.671875 C 5.1875 0.0742188 4.78125 0.0742188 4.527344 0.671875 L 3.625 2.828125 L 4.773438 5.5625 C 5.046875 5.339844 5.351562 5.507812 5.558594 6 C 5.765625 6.492188 5.835938 7.222656 5.738281 7.882812 L 6.847656 10.515625 C 7.125 10.289062 7.429688 10.457031 7.636719 10.949219 C 7.78125 11.289062 7.863281 11.753906 7.863281 12.238281 C 7.863281 12.722656 7.78125 13.183594 7.636719 13.527344 C 7.492188 13.867188 7.300781 14.058594 7.097656 14.058594 C 6.894531 14.058594 6.699219 13.867188 6.554688 13.527344 C 6.335938 13.003906 6.269531 12.222656 6.386719 11.542969 L 5.355469 9.085938 L 5.355469 15.554688 C 5.429688 15.640625 5.5 15.757812 5.558594 15.898438 C 5.855469 16.609375 5.855469 17.765625 5.558594 18.476562 C 5.257812 19.1875 4.773438 19.1875 4.476562 18.476562 C 4.332031 18.132812 4.25 17.671875 4.25 17.1875 C 4.25 16.703125 4.332031 16.242188 4.476562 15.898438 C 4.546875 15.726562 4.632812 15.59375 4.726562 15.5 L 4.726562 8.972656 C 4.632812 8.882812 4.546875 8.746094 4.476562 8.574219 C 4.257812 8.054688 4.191406 7.265625 4.3125 6.582031 L 3.179688 3.886719 L 0.1875 11.003906 C -0.0625 11.601562 -0.0625 12.574219 0.1875 13.171875 L 4.550781 23.550781 C 4.800781 24.148438 5.207031 24.148438 5.457031 23.550781 L 9.800781 13.21875 C 10.050781 12.621094 10.050781 11.652344 9.800781 11.054688 "/>
</g>

1
web/assets/svgs/home.svg Normal file
View File

@@ -0,0 +1 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5192 7.82274C2 8.77128 2 9.91549 2 12.2039V13.725C2 17.6258 2 19.5763 3.17157 20.7881C4.34315 22 6.22876 22 10 22H14C17.7712 22 19.6569 22 20.8284 20.7881C22 19.5763 22 17.6258 22 13.725V12.2039C22 9.91549 22 8.77128 21.4808 7.82274C20.9616 6.87421 20.0131 6.28551 18.116 5.10812L16.116 3.86687C14.1106 2.62229 13.1079 2 12 2C10.8921 2 9.88939 2.62229 7.88403 3.86687L5.88403 5.10813C3.98695 6.28551 3.0384 6.87421 2.5192 7.82274ZM11.25 18C11.25 18.4142 11.5858 18.75 12 18.75C12.4142 18.75 12.75 18.4142 12.75 18V15C12.75 14.5858 12.4142 14.25 12 14.25C11.5858 14.25 11.25 14.5858 11.25 15V18Z" />

View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.06935 5.00839C2 5.37595 2 5.81722 2 6.69975V13.75C2 17.5212 2 19.4069 3.17157 20.5784C4.34315 21.75 6.22876 21.75 10 21.75H14C17.7712 21.75 19.6569 21.75 20.8284 20.5784C22 19.4069 22 17.5212 22 13.75V11.5479C22 8.91554 22 7.59935 21.2305 6.74383C21.1598 6.66514 21.0849 6.59024 21.0062 6.51946C20.1506 5.75 18.8345 5.75 16.2021 5.75H15.8284C14.6747 5.75 14.0979 5.75 13.5604 5.59678C13.2651 5.5126 12.9804 5.39471 12.7121 5.24543C12.2237 4.97367 11.8158 4.56578 11 3.75L10.4497 3.19975C10.1763 2.92633 10.0396 2.78961 9.89594 2.67051C9.27652 2.15704 8.51665 1.84229 7.71557 1.76738C7.52976 1.75 7.33642 1.75 6.94975 1.75C6.06722 1.75 5.62595 1.75 5.25839 1.81935C3.64031 2.12464 2.37464 3.39031 2.06935 5.00839ZM12 11C12.4142 11 12.75 11.3358 12.75 11.75V13H14C14.4142 13 14.75 13.3358 14.75 13.75C14.75 14.1642 14.4142 14.5 14 14.5H12.75V15.75C12.75 16.1642 12.4142 16.5 12 16.5C11.5858 16.5 11.25 16.1642 11.25 15.75V14.5H10C9.58579 14.5 9.25 14.1642 9.25 13.75C9.25 13.3358 9.58579 13 10 13H11.25V11.75C11.25 11.3358 11.5858 11 12 11Z"
/>

5
web/assets/svgs/info.svg Normal file
View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75ZM12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"
/>

View File

@@ -0,0 +1,36 @@
<style>
.spinner_l9ve {
animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite;
}
.spinner_cMYp {
animation-delay: 0.4s;
}
.spinner_gHR3 {
animation-delay: 0.8s;
}
@keyframes spinner_rcyq {
0% {
transform: translate(12px, 12px) scale(0);
opacity: 1;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0;
}
}
</style>
<path
class="spinner_l9ve"
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)"
/>
<path
class="spinner_l9ve spinner_cMYp"
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)"
/>
<path
class="spinner_l9ve spinner_gHR3"
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)"
/>

View File

@@ -0,0 +1 @@
<path fill-rule="nonzero" fill-opacity="1" d="M 18.429688 10.285156 C 18.785156 10.285156 19.089844 10.410156 19.339844 10.660156 C 19.589844 10.910156 19.714844 11.214844 19.714844 11.570312 L 19.714844 19.285156 C 19.714844 19.644531 19.589844 19.945312 19.339844 20.195312 C 19.089844 20.445312 18.785156 20.570312 18.429688 20.570312 L 5.570312 20.570312 C 5.214844 20.570312 4.910156 20.445312 4.660156 20.195312 C 4.410156 19.945312 4.285156 19.644531 4.285156 19.285156 L 4.285156 11.570312 C 4.285156 11.214844 4.410156 10.910156 4.660156 10.660156 C 4.910156 10.410156 5.214844 10.285156 5.570312 10.285156 L 6 10.285156 L 6 6 C 6 4.347656 6.585938 2.933594 7.761719 1.761719 C 8.933594 0.585938 10.347656 0 12 0 C 13.652344 0 15.066406 0.585938 16.238281 1.761719 C 17.414062 2.933594 18 4.347656 18 6 C 18 6.230469 17.914062 6.433594 17.746094 6.601562 C 17.574219 6.773438 17.375 6.855469 17.144531 6.855469 L 16.285156 6.855469 C 16.054688 6.855469 15.851562 6.773438 15.683594 6.601562 C 15.511719 6.433594 15.429688 6.230469 15.429688 6 C 15.429688 5.054688 15.09375 4.246094 14.425781 3.574219 C 13.753906 2.90625 12.945312 2.570312 12 2.570312 C 11.054688 2.570312 10.246094 2.90625 9.574219 3.574219 C 8.90625 4.246094 8.570312 5.054688 8.570312 6 L 8.570312 10.285156 Z M 18.429688 10.285156 "/>

View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z"
/>

View File

@@ -0,0 +1,6 @@
<rect width="24" height="24" fill="none" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.8487 18 13.551 17.3729 14.9056 16.3199L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L16.3199 14.9056C17.3729 13.551 18 11.8487 18 10C18 5.58172 14.4183 2 10 2Z"
/>

View File

@@ -0,0 +1,5 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z"
/>

View File

@@ -0,0 +1,8 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
/>
<path
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
/>

1
web/assets/svgs/user.svg Normal file
View File

@@ -0,0 +1 @@
<path fill-rule="nonzero" fill-opacity="1" d="M 20.398438 17.933594 C 20.199219 16.550781 19.808594 15.398438 19.226562 14.484375 C 18.640625 13.570312 17.816406 13.039062 16.753906 12.898438 C 16.15625 13.558594 15.445312 14.074219 14.617188 14.445312 C 13.792969 14.816406 12.917969 15 12 15 C 11.082031 15 10.207031 14.816406 9.382812 14.445312 C 8.554688 14.074219 7.84375 13.558594 7.246094 12.898438 C 6.183594 13.039062 5.359375 13.570312 4.773438 14.484375 C 4.191406 15.398438 3.800781 16.550781 3.601562 17.933594 C 4.550781 19.273438 5.757812 20.332031 7.230469 21.113281 C 8.707031 21.894531 10.292969 22.285156 12 22.285156 C 13.707031 22.285156 15.292969 21.894531 16.769531 21.113281 C 18.242188 20.332031 19.449219 19.273438 20.398438 17.933594 Z M 17.144531 8.570312 C 17.144531 7.152344 16.640625 5.941406 15.636719 4.933594 C 14.632812 3.929688 13.417969 3.429688 12 3.429688 C 10.582031 3.429688 9.367188 3.929688 8.363281 4.933594 C 7.359375 5.941406 6.855469 7.152344 6.855469 8.570312 C 6.855469 9.992188 7.359375 11.203125 8.363281 12.207031 C 9.367188 13.210938 10.582031 13.714844 12 13.714844 C 13.417969 13.714844 14.632812 13.210938 15.636719 12.207031 C 16.640625 11.203125 17.144531 9.992188 17.144531 8.570312 Z M 24 12 C 24 13.625 23.683594 15.175781 23.050781 16.652344 C 22.414062 18.132812 21.566406 19.410156 20.496094 20.484375 C 19.429688 21.558594 18.15625 22.414062 16.675781 23.050781 C 15.191406 23.683594 13.632812 24 12 24 C 10.375 24 8.820312 23.683594 7.339844 23.050781 C 5.855469 22.414062 4.582031 21.5625 3.507812 20.492188 C 2.4375 19.417969 1.585938 18.144531 0.949219 16.660156 C 0.316406 15.179688 0 13.625 0 12 C 0 10.375 0.316406 8.820312 0.949219 7.339844 C 1.585938 5.855469 2.4375 4.582031 3.507812 3.507812 C 4.582031 2.4375 5.855469 1.585938 7.339844 0.949219 C 8.820312 0.316406 10.375 0 12 0 C 13.625 0 15.179688 0.316406 16.660156 0.949219 C 18.144531 1.585938 19.417969 2.4375 20.492188 3.507812 C 21.5625 4.582031 22.414062 5.855469 23.050781 7.339844 C 23.683594 8.820312 24 10.375 24 12 Z M 24 12 "/>

View File

@@ -0,0 +1,134 @@
package document
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/utils"
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
)
func Actions(d models.Document) g.Node {
return h.Div(
h.Class("flex flex-col float-left gap-2 w-44 md:w-60 lg:w-80 mr-4 relative"),
// Cover
ui.AnchoredPopover(
h.Img(
h.Class("rounded object-fill w-full"),
h.Src(fmt.Sprintf("/documents/%s/cover", d.ID)),
),
editCoverPopover(d.ID),
),
// Read
ui.LinkButton(g.Text("Read"), fmt.Sprintf("/reader#id=%s&type=REMOTE", d.ID)),
// Actions
h.Div(
h.Class("flex flex-col justify-between z-20 gap-2 relative"),
h.Div(
h.Class("flex grow align-center justify-between my-auto text-gray-500 dark:text-gray-500"),
ui.AnchoredPopover(
ui.SpanButton(assets.Icon("delete", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
deletePopover(d.ID),
),
ui.LinkButton(
assets.Icon("activity", 28),
fmt.Sprintf("../activity?document=%s", d.ID),
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
),
ui.AnchoredPopover(
ui.SpanButton(assets.Icon("search", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
searchPopover(d),
),
ui.LinkButton(
assets.Icon("download", 28),
fmt.Sprintf("./%s/file", d.ID),
ui.ButtonConfig{
Variant: ui.ButtonVariantGhost,
Disabled: !d.HasFile,
},
),
),
),
)
}
func editCoverPopover(docID string) g.Node {
return h.Div(
h.Class("flex flex-col gap-2"),
h.Form(
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
h.Method("POST"),
g.Attr("enctype", "multipart/form-data"),
h.Action(fmt.Sprintf("./%s/edit", docID)),
h.Input(h.Type("file"), h.ID("cover_file"), h.Name("cover_file")),
ui.FormButton(g.Text("Upload Cover"), ""),
),
h.Form(
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
h.Method("POST"),
h.Action(fmt.Sprintf("./%s/edit", docID)),
h.Input(
h.ID("remove_cover"),
h.Name("remove_cover"),
h.Class("hidden"),
h.Type("checkbox"),
h.Checked(),
),
ui.FormButton(g.Text("Remove Cover"), ""),
),
)
}
func deletePopover(id string) g.Node {
return h.Form(
h.Class("text-black dark:text-white text-sm w-24"),
h.Method("POST"),
h.Action(fmt.Sprintf("./%s/delete", id)),
ui.FormButton(g.Text("Delete"), ""),
)
}
func searchPopover(d models.Document) g.Node {
return h.Form(
h.Method("POST"),
h.Action(fmt.Sprintf("./%s/identify", d.ID)),
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
h.Input(
h.ID("title"),
h.Name("title"),
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
h.Type("text"),
h.Placeholder("Title"),
h.Value(d.Title),
),
h.Input(
h.ID("author"),
h.Name("author"),
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
h.Type("text"),
h.Placeholder("Author"),
h.Value(d.Author),
),
h.Input(
h.ID("isbn"),
h.Name("isbn"),
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
h.Type("text"),
h.Placeholder("ISBN 10 / ISBN 13"),
h.Value(utils.FirstNonZero(d.ISBN13, d.ISBN10)),
),
ui.FormButton(g.Text("Identify"), ""),
)
}

View File

@@ -0,0 +1,54 @@
package document
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/formatters"
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
)
func Card(d models.Document) g.Node {
return h.Div(
h.Class("w-full relative"),
h.Div(
h.Class("flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"),
h.Div(
h.Class("min-w-fit my-auto h-48 relative"),
h.A(
h.Href("./documents/"+d.ID),
h.Img(
h.Src("./documents/"+d.ID+"/cover"),
h.Class("rounded object-cover h-full"),
),
),
),
h.Div(
h.Class("flex flex-col justify-around dark:text-white w-full text-sm"),
ui.KeyValue(g.Text("Title"), g.Text(d.Title)),
ui.KeyValue(g.Text("Author"), g.Text(d.Author)),
ui.KeyValue(g.Text("Progress"), g.Text(fmt.Sprintf("%.2f%%", d.Percentage))),
ui.KeyValue(g.Text("Time Read"), g.Text(formatters.FormatDuration(d.TotalTimeRead))),
),
),
h.Div(
h.Class("absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"),
ui.LinkButton(
assets.Icon("activity", 24),
"./activity?document="+d.ID,
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
),
ui.LinkButton(
assets.Icon("download", 24),
"./documents/"+d.ID+"/file",
ui.ButtonConfig{
Variant: ui.ButtonVariantGhost,
Disabled: !d.HasFile,
},
),
),
)
}

View File

@@ -0,0 +1,89 @@
package document
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/utils"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
)
func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node {
if m == nil {
return nil
}
return ui.Popover(h.Div(
h.Class("flex flex-col gap-2"),
h.H3(
h.Class("text-lg font-bold text-center"),
g.Text("Metadata Results"),
),
h.Form(
h.ID("metadata-save"),
h.Method("POST"),
h.Action(fmt.Sprintf("/documents/%s/edit", docID)),
h.Class("text-black dark:text-white border-b dark:border-black"),
h.Dl(
h.Div(
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Cover")),
h.Dd(
h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"),
h.Img(
h.Class("rounded object-fill h-32"),
h.Src(fmt.Sprintf("https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690", m.SourceID)),
),
),
),
h.Div(
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Title")),
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Title, "N/A"))),
),
h.Div(
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Author")),
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Author, "N/A"))),
),
h.Div(
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 10")),
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN10, "N/A"))),
),
h.Div(
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 13")),
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN13, "N/A"))),
),
h.Div(
h.Class("p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"),
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Description")),
h.Dd(
h.Class("max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2"),
g.Text(utils.FirstNonZero(m.Description, "N/A")),
),
),
),
h.Div(
h.Class("hidden"),
h.Input(h.Type("text"), h.ID("title"), h.Name("title"), h.Value(m.Title)),
h.Input(h.Type("text"), h.ID("author"), h.Name("author"), h.Value(m.Author)),
h.Input(h.Type("text"), h.ID("description"), h.Name("description"), h.Value(m.Description)),
h.Input(h.Type("text"), h.ID("isbn_10"), h.Name("isbn_10"), h.Value(m.ISBN10)),
h.Input(h.Type("text"), h.ID("isbn_13"), h.Name("isbn_13"), h.Value(m.ISBN13)),
h.Input(h.Type("text"), h.ID("cover_gbid"), h.Name("cover_gbid"), h.Value(m.SourceID)),
),
),
h.Div(
h.Class("flex justify-end"),
h.Div(
h.Class("flex gap-4 w-48"),
ui.LinkButton(g.Text("Cancel"), fmt.Sprintf("/documents/%s", docID)),
ui.FormButton(g.Text("Save"), "metadata-save"),
),
),
))
}

View File

@@ -0,0 +1,23 @@
package forms
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/web/components/ui"
)
func Edit(key, val, url string) g.Node {
return h.Form(
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
h.Method("POST"),
h.Action(url),
h.Input(
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
h.Type("text"),
h.ID(key),
h.Name(key),
h.Value(val),
),
ui.FormButton(g.Text("Save"), ""),
)
}

View File

@@ -0,0 +1,35 @@
package stats
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
type InfoCardData struct {
Title string
Size int64
Link string
}
func InfoCard(d InfoCardData) g.Node {
cardContent := h.Div(
g.Attr("class", "flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"),
h.Div(
g.Attr("class", "flex flex-col justify-around w-full text-sm"),
h.P(g.Attr("class", "text-2xl font-bold"), g.Text(fmt.Sprint(d.Size))),
h.P(g.Attr("class", "text-sm text-gray-400"), g.Text(d.Title)),
),
)
if d.Link == "" {
return h.Div(g.Attr("class", "w-full"), cardContent)
}
return h.A(
g.Attr("class", "w-full"),
h.Href(d.Link),
cardContent,
)
}

View File

@@ -0,0 +1,130 @@
package stats
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
type LeaderboardItem struct {
UserID string
Value string
}
type LeaderboardData struct {
Name string
All []LeaderboardItem
Year []LeaderboardItem
Month []LeaderboardItem
Week []LeaderboardItem
}
func LeaderboardCard(l LeaderboardData) g.Node {
orderedItems := map[string][]LeaderboardItem{
"All": l.All,
"Year": l.Year,
"Month": l.Month,
"Week": l.Week,
}
var allNodes []g.Node
for key, items := range orderedItems {
// Get Top Reader Nodes
topReaders := items[:min(len(items), 3)]
var topReaderNodes []g.Node
for idx, reader := range topReaders {
border := ""
if idx > 0 {
border = " border-t border-gray-200"
}
topReaderNodes = append(topReaderNodes, h.Div(
g.Attr("class", "flex items-center justify-between pt-2 pb-2 text-sm"+border),
h.Div(h.P(g.Text(reader.UserID))),
h.Div(g.Attr("class", "flex items-end font-bold"), g.Text(reader.Value)),
))
}
allNodes = append(allNodes, g.Group([]g.Node{
h.Div(
g.Attr("class", "flex items-end my-6 space-x-2 hidden peer-checked/"+key+":block"),
g.If(len(items) == 0,
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text("N/A")),
),
g.If(len(items) > 0,
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text(items[0].UserID)),
),
),
h.Div(
g.Attr("class", "hidden dark:text-white peer-checked/"+key+":block"),
g.Group(topReaderNodes),
),
}))
}
return h.Div(
g.Attr("class", "w-full"),
h.Div(
g.Attr("class", "flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
h.Div(
h.Div(
g.Attr("class", "flex justify-between"),
h.P(
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
g.Textf("%s Leaderboard", l.Name),
),
h.Div(
g.Attr("class", "flex gap-2 text-xs text-gray-400 items-center"),
h.Label(
g.Attr("for", fmt.Sprintf("all-%s", l.Name)),
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
g.Text("all"),
),
h.Label(
g.Attr("for", fmt.Sprintf("year-%s", l.Name)),
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
g.Text("year"),
),
h.Label(
g.Attr("for", fmt.Sprintf("month-%s", l.Name)),
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
g.Text("month"),
),
h.Label(
g.Attr("for", fmt.Sprintf("week-%s", l.Name)),
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
g.Text("week"),
),
),
),
),
h.Input(
g.Attr("type", "radio"),
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
g.Attr("id", fmt.Sprintf("all-%s", l.Name)),
g.Attr("class", "hidden peer/All"),
g.Attr("checked", ""),
),
h.Input(
g.Attr("type", "radio"),
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
g.Attr("id", fmt.Sprintf("year-%s", l.Name)),
g.Attr("class", "hidden peer/Year"),
),
h.Input(
g.Attr("type", "radio"),
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
g.Attr("id", fmt.Sprintf("month-%s", l.Name)),
g.Attr("class", "hidden peer/Month"),
),
h.Input(
g.Attr("type", "radio"),
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
g.Attr("id", fmt.Sprintf("week-%s", l.Name)),
g.Attr("class", "hidden peer/Week"),
),
g.Group(allNodes),
),
)
}

View File

@@ -0,0 +1,61 @@
package stats
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/database"
"reichard.io/antholume/graph"
)
func MonthlyChart(dailyStats []database.GetDailyReadStatsRow) g.Node {
graphData := buildSVGGraphData(dailyStats, 800, 70)
return h.Div(
g.Attr("class", "relative"),
h.SVG(
g.Attr("viewBox", fmt.Sprintf("26 0 755 %d", graphData.Height)),
g.Attr("preserveAspectRatio", "none"),
g.Attr("width", "100%"),
g.Attr("height", "6em"),
g.El("path",
g.Attr("fill", "#316BBE"),
g.Attr("fill-opacity", "0.5"),
g.Attr("stroke", "none"),
g.Attr("d", graphData.BezierPath+" "+graphData.BezierFill),
),
g.El("path",
g.Attr("fill", "none"),
g.Attr("stroke", "#316BBE"),
g.Attr("d", graphData.BezierPath),
),
),
h.Div(
g.Attr("class", "flex absolute w-full h-full top-0"),
g.Attr("style", "width: calc(100%*31/30); transform: translateX(-50%); left: 50%"),
g.Group(g.Map(dailyStats, func(d database.GetDailyReadStatsRow) g.Node {
return h.Div(
g.Attr("onclick", ""),
g.Attr("class", "opacity-0 hover:opacity-100 w-full"),
g.Attr("style", "background: linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%"),
h.Div(
g.Attr("class", "flex flex-col items-center p-2 rounded absolute top-3 dark:text-white text-xs pointer-events-none"),
g.Attr("style", "transform: translateX(-50%); background-color: rgba(128, 128, 128, 0.2); left: 50%"),
h.Span(g.Text(d.Date)),
h.Span(g.Textf("%d minutes", d.MinutesRead)),
),
)
})),
),
)
}
// buildSVGGraphData builds SVGGraphData from the provided stats, width and height.
func buildSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
var intData []int64
for _, item := range inputData {
intData = append(intData, item.MinutesRead)
}
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
}

View File

@@ -0,0 +1,65 @@
package stats
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/database"
)
func StreakCard(s database.UserStreak) g.Node {
return h.Div(
g.Attr("class", "w-full"),
h.Div(
g.Attr("class", "relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
h.P(
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
g.If(s.Window == "WEEK", g.Text("Weekly Read Streak")),
g.If(s.Window != "WEEK", g.Text("Daily Read Streak")),
),
h.Div(
g.Attr("class", "flex items-end my-6 space-x-2"),
h.P(
g.Attr("class", "text-5xl font-bold text-black dark:text-white"),
g.Textf("%d", s.CurrentStreak),
),
),
h.Div(
g.Attr("class", "dark:text-white"),
h.Div(
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"),
h.Div(
h.P(
g.If(s.Window == "WEEK", g.Text("Current Weekly Streak")),
g.If(s.Window != "WEEK", g.Text("Current Daily Streak")),
),
h.Div(
g.Attr("class", "flex items-end text-sm text-gray-400"),
g.Textf("%s ➞ %s", s.CurrentStreakStartDate, s.CurrentStreakEndDate),
),
),
h.Div(
g.Attr("class", "flex items-end font-bold"),
g.Textf("%d", s.CurrentStreak),
),
),
h.Div(
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm"),
h.Div(
h.P(
g.If(s.Window == "WEEK", g.Text("Best Weekly Streak")),
g.If(s.Window != "WEEK", g.Text("Best Daily Streak")),
),
h.Div(
g.Attr("class", "flex items-end text-sm text-gray-400"),
g.Textf("%s ➞ %s", s.MaxStreakStartDate, s.MaxStreakEndDate),
),
),
h.Div(
g.Attr("class", "flex items-end font-bold"),
g.Textf("%d", s.MaxStreak),
),
),
),
),
)
}

View File

@@ -0,0 +1,99 @@
package ui
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/pkg/utils"
)
type ButtonVariant string
const (
ButtonVariantPrimary ButtonVariant = "primary"
ButtonVariantSecondary ButtonVariant = "secondary"
ButtonVariantGhost ButtonVariant = "ghost"
)
type buttonAs int
const (
buttonAsLink buttonAs = iota
buttonAsForm
buttonAsSpan
)
type ButtonConfig struct {
Variant ButtonVariant
Disabled bool
as buttonAs
value string
}
// LinkButton creates a button that links to a url. The default variant is ButtonVariantPrimary.
func LinkButton(content g.Node, url string, cfg ...ButtonConfig) g.Node {
config := buildButtonConfig(cfg, buttonAsLink, url)
return button(content, config)
}
// FormButton creates a button that is a form. The default variant is ButtonVariantPrimary.
func FormButton(content g.Node, formName string, cfg ...ButtonConfig) g.Node {
config := buildButtonConfig(cfg, buttonAsForm, formName)
return button(content, config)
}
// SpanButton creates a button that has no target (i.e. span). The default variant is ButtonVariantPrimary.
func SpanButton(content g.Node, cfg ...ButtonConfig) g.Node {
config := buildButtonConfig(cfg, buttonAsSpan, "")
return button(content, config)
}
func button(content g.Node, config ButtonConfig) g.Node {
classes := config.getClasses()
if config.as == buttonAsSpan || config.Disabled {
return h.Span(content, h.Class(classes))
} else if config.as == buttonAsLink {
return h.A(h.Class(classes), h.Href(config.value), content)
}
return h.Button(
content,
h.Type("submit"),
h.Class(classes),
g.If(config.value != "", h.FormAttr(config.value)),
)
}
func (c *ButtonConfig) getClasses() string {
baseClass := "transition duration-100 ease-in font-medium text-center inline-block"
var variantClass string
switch c.Variant {
case ButtonVariantPrimary:
variantClass = "h-full w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
case ButtonVariantSecondary:
variantClass = "h-full w-full px-2 py-1 text-white bg-black shadow-md hover:text-black hover:bg-white"
case ButtonVariantGhost:
variantClass = "text-gray-500 hover:text-gray-800 dark:hover:text-gray-100"
}
classes := baseClass + " " + variantClass
if c.Disabled {
classes += " opacity-40 pointer-events-none"
}
return classes
}
func buildButtonConfig(cfg []ButtonConfig, as buttonAs, val string) ButtonConfig {
c, found := sliceutils.First(cfg)
if !found {
c = ButtonConfig{Variant: ButtonVariantPrimary}
}
c.Variant = utils.FirstNonZero(c.Variant, ButtonVariantPrimary)
c.as = as
c.value = val
return c
}

24
web/components/ui/kv.go Normal file
View File

@@ -0,0 +1,24 @@
package ui
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
// KeyValue is a basic vertical key/value pair component
func KeyValue(key, val g.Node) g.Node {
return h.Div(
h.Class("flex flex-col"),
h.Div(h.Class("text-gray-500"), key),
h.Div(h.Class("font-medium text-black dark:text-white"), val),
)
}
// HKeyValue is a basic horizontal key/value pair component
func HKeyValue(key, val g.Node) g.Node {
return h.Div(
h.Class("flex gap-2"),
h.Div(h.Class("text-gray-500"), key),
h.Div(h.Class("font-medium text-black dark:text-white"), val),
)
}

View File

@@ -0,0 +1,28 @@
package ui
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/models"
)
func Notifications(notifications []*models.Notification) g.Node {
if len(notifications) == 0 {
return nil
}
return h.Div(
h.Class("fixed flex flex-col gap-2 bottom-0 right-0 text-white dark:text-black"),
g.Group(sliceutils.Map(notifications, notificationNode)),
)
}
func notificationNode(n *models.Notification) g.Node {
return h.Div(
h.Class("p-2 sm:p-4 animate-notification"),
h.Div(
h.Class("bg-gray-600 dark:bg-gray-400 px-4 py-2 rounded-lg shadow-lg w-64"),
g.Text(n.Content),
),
)
}

View File

@@ -0,0 +1,99 @@
package ui
import (
"strings"
"github.com/google/uuid"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/pkg/utils"
)
type PopoverPosition string
const (
// ---- Cornered ----
// PopoverTopLeft PopoverPosition = "left-0 top-0 origin-bottom-right -translate-x-full -translate-y-full"
// PopoverTopRight PopoverPosition = "right-0 top-0 origin-bottom-left translate-x-full -translate-y-full"
// PopoverBottomLeft PopoverPosition = "left-0 bottom-0 origin-top-right -translate-x-full translate-y-full"
// PopoverBottomRight PopoverPosition = "right-0 bottom-0 origin-top-left translate-x-full translate-y-full"
// ---- Flush ----
PopoverTopLeft PopoverPosition = "right-0 -top-1 origin-bottom-right -translate-y-full"
PopoverTopRight PopoverPosition = "left-0 -top-1 origin-bottom-left -translate-y-full"
PopoverBottomLeft PopoverPosition = "right-0 -bottom-1 origin-top-right translate-y-full"
PopoverBottomRight PopoverPosition = "left-0 -bottom-1 origin-top-left translate-y-full"
// ---- Centered ----
PopoverTopCenter PopoverPosition = "left-1/2 top-0 origin-bottom -translate-x-1/2 -translate-y-full"
PopoverBottomCenter PopoverPosition = "left-1/2 bottom-0 origin-top -translate-x-1/2 translate-y-full"
PopoverLeftCenter PopoverPosition = "left-0 top-1/2 origin-right -translate-x-full -translate-y-1/2"
PopoverRightCenter PopoverPosition = "right-0 top-1/2 origin-left translate-x-full -translate-y-1/2"
PopoverCenter PopoverPosition = "left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2"
)
type PopoverConfig struct {
Position PopoverPosition
Classes string
Dim *bool
}
// AnchoredPopover creates a popover with content anchored to the anchor node.
// The default position is PopoverBottomRight.
func AnchoredPopover(anchor, content g.Node, cfg ...PopoverConfig) g.Node {
// Get Popover Config
c, _ := sliceutils.First(cfg)
c.Position = utils.FirstNonZero(c.Position, PopoverBottomRight)
if c.Dim == nil {
c.Dim = ptr.Of(false)
}
popoverID := uuid.NewString()
return h.Div(
h.Class("relative"),
h.Label(
h.Class("cursor-pointer"),
h.For(popoverID),
anchor,
),
h.Input(
h.ID(popoverID),
h.Class("hidden css-button"),
h.Type("checkbox"),
),
Popover(content, c),
)
}
func Popover(content g.Node, cfg ...PopoverConfig) g.Node {
// Get Popover Config
c, _ := sliceutils.First(cfg)
c.Position = utils.FirstNonZero(c.Position, PopoverCenter)
if c.Dim == nil {
c.Dim = ptr.Of(true)
}
wrappedContent := h.Div(h.Class(c.getClasses()), content)
if !ptr.Deref(c.Dim) {
return wrappedContent
}
return h.Div(
h.Div(h.Class("fixed top-0 left-0 bg-black z-40 opacity-50 w-screen h-screen")),
wrappedContent,
)
}
func (c *PopoverConfig) getClasses() string {
return strings.Join([]string{
"absolute z-50 p-2 transition-all duration-200 rounded shadow-lg",
"bg-gray-200 dark:bg-gray-600 shadow-gray-500 dark:shadow-gray-900",
c.Classes,
string(c.Position),
}, " ")
}

View File

@@ -0,0 +1,64 @@
package ui
import (
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
type TableRow map[string]TableCell
type TableCell struct {
String string
Value g.Node
}
type TableConfig struct {
Columns []string
Rows []TableRow
}
func Table(cfg TableConfig) g.Node {
return h.Table(
h.Class("min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"),
h.THead(
h.Class("text-gray-800 dark:text-gray-400"),
h.Tr(
g.Map(cfg.Columns, func(col string) g.Node {
return h.Th(
h.Class("p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"),
g.Text(col),
)
})...,
),
),
h.TBody(
h.Class("text-black dark:text-white"),
g.If(len(cfg.Rows) == 0,
h.Tr(
h.Td(
h.Class("text-center p-3"),
g.Attr("colspan", fmt.Sprintf("%d", len(cfg.Columns))),
g.Text("No Results"),
),
),
),
g.Map(cfg.Rows, func(row TableRow) g.Node {
return h.Tr(
g.Map(cfg.Columns, func(col string) g.Node {
cell, ok := row[col]
content := cell.Value
if !ok || content == nil {
content = g.Text(cell.String)
}
return h.Td(
h.Class("p-3 border-b border-gray-200"),
content,
)
})...,
)
}),
),
)
}

Some files were not shown because too many files have changed in this diff Show More