3 Commits

Author SHA1 Message Date
946e158d25 update templ 2025-01-25 15:07:21 -05:00
7ac9c3d573 templ wip 2 2025-01-25 15:05:41 -05:00
Evan Reichard
580d64227d templ wip 1 2025-01-25 15:05:41 -05:00
242 changed files with 5778 additions and 22534 deletions

View File

@@ -1,5 +1,5 @@
kind: pipeline
type: docker
type: kubernetes
name: default
trigger:
@@ -27,8 +27,6 @@ steps:
registry: gitea.va.reichard.io
tags:
- dev
custom_dns:
- 8.8.8.8
username:
from_secret: docker_username
password:

2
.envrc
View File

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

View File

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

View File

@@ -1,31 +0,0 @@
# AnthoLume - Agent Context
## Critical Rules
### Generated Files
- **NEVER edit generated files directly** - Always edit the source and regenerate
- Go backend API: Edit `api/v1/openapi.yaml` then run:
- `go generate ./api/v1/generate.go`
- `cd frontend && bun run generate:api`
- Examples of generated files:
- `api/v1/api.gen.go`
- `frontend/src/generated/**/*.ts`
### Database Access
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
- Define queries in `database/query.sql` and regenerate via `sqlc generate`
### Error Handling
- Use `fmt.Errorf("message: %w", err)` for wrapping errors
- Do NOT use `github.com/pkg/errors`
## Frontend
- **Package manager**: bun (not npm)
- **Icons**: Use custom icon components in `src/icons/` (not external icon libraries)
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
- **Format**: `cd frontend && bun run format` (and `format:fix`)
- **Generate API client**: `cd frontend && bun run generate:api`
## Regeneration
- Go backend: `go generate ./api/v1/generate.go`
- TS client: `cd frontend && bun run generate:api`

View File

@@ -3,7 +3,7 @@ FROM alpine AS alpine
RUN apk update && apk add --no-cache ca-certificates tzdata
# Build Image
FROM golang:1.24 AS build
FROM golang:1.21 AS build
# Create Package Directory
RUN mkdir -p /opt/antholume

View File

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

BIN
antholume

Binary file not shown.

View File

@@ -16,7 +16,9 @@ import (
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/api/renderer"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
"reichard.io/antholume/utils"
@@ -45,6 +47,10 @@ func NewApi(db *database.DBManager, c *config.Config, assets fs.FS) *API {
// Create router
router := gin.New()
// Override renderer
ginRenderer := router.HTMLRender
router.HTMLRender = &renderer.HTMLTemplRenderer{FallbackHtmlRenderer: ginRenderer}
// Add server
api.httpServer = &http.Server{
Handler: router,
@@ -113,11 +119,6 @@ func (api *API) Start() error {
return api.httpServer.ListenAndServe()
}
// Handler returns the underlying http.Handler for the Gin router
func (api *API) Handler() http.Handler {
return api.httpServer.Handler
}
func (api *API) Stop() error {
// Stop server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -297,7 +298,7 @@ func (api *API) loadTemplates(
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
if err != nil {
return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err)
return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory))
}
// Generate Templates
@@ -309,7 +310,7 @@ func (api *API) loadTemplates(
// Read Template
b, err := fs.ReadFile(api.assets, templatePath)
if err != nil {
return fmt.Errorf("unable to read template %s: %w", templateName, err)
return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName))
}
// Clone? (Pages - Don't Stomp)
@@ -320,7 +321,7 @@ func (api *API) loadTemplates(
// Parse Template
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
if err != nil {
return fmt.Errorf("unable to parse template %s: %w", templateName, err)
return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName))
}
allTemplates[templateName] = baseTemplate
@@ -329,13 +330,6 @@ func (api *API) loadTemplates(
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) {
// Start timer
startTime := time.Now()
@@ -371,3 +365,10 @@ func loggingMiddleware(c *gin.Context) {
// Log result
log.WithFields(logData).Info(fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
}
func (api *API) templateMiddleware(router *gin.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
router.HTMLRender = *api.generateTemplates()
c.Next()
}
}

View File

@@ -3,7 +3,6 @@ package api
import (
"archive/zip"
"bufio"
"context"
"crypto/md5"
"encoding/json"
"fmt"
@@ -22,6 +21,7 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
@@ -112,7 +112,7 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
// 2. Select all / deselect?
case adminCacheTables:
go func() {
err := api.db.CacheTempTables(c)
err := api.db.CacheTempTables()
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
@@ -122,7 +122,7 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return
case adminBackup:
// Vacuum
_, err := api.db.DB.ExecContext(c, "VACUUM;")
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
if err != nil {
log.Error("Unable to vacuum DB: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
@@ -144,7 +144,7 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
}
}
err := api.createBackup(c, w, directories)
err := api.createBackup(w, directories)
if err != nil {
log.Error("Backup Error: ", err)
}
@@ -261,7 +261,7 @@ func (api *API) appGetAdminLogs(c *gin.Context) {
func (api *API) appGetAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
users, err := api.db.Queries.GetUsers(c)
users, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
log.Error("GetUsers DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
@@ -292,11 +292,11 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error
switch rUpdate.Operation {
case opCreate:
err = api.createUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
err = api.createUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opUpdate:
err = api.updateUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
err = api.updateUser(rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opDelete:
err = api.deleteUser(c, rUpdate.User)
err = api.deleteUser(rUpdate.User)
default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return
@@ -307,7 +307,7 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
return
}
users, err := api.db.Queries.GetUsers(c)
users, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
log.Error("GetUsers DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
@@ -448,7 +448,7 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// Check already exists
_, err = qtx.GetDocument(c, *fileMeta.PartialMD5)
_, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
@@ -492,7 +492,7 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
}
// Upsert document
if _, err = qtx.UpsertDocument(c, database.UpsertDocumentParams{
if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
Title: fileMeta.Title,
Author: fileMeta.Author,
@@ -627,7 +627,7 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
// Save Backup File
w := bufio.NewWriter(backupFile)
err = api.createBackup(c, w, []string{"covers", "documents"})
err = api.createBackup(w, []string{"covers", "documents"})
if err != nil {
log.Error("Unable to save backup file: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file")
@@ -650,13 +650,13 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
}
// Reinit DB
if err := api.db.Reload(c); err != nil {
if err := api.db.Reload(); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to reload DB")
log.Panicf("Unable to reload DB: %v", err)
}
// Rotate Auth Hashes
if err := api.rotateAllAuthHashes(c); err != nil {
if err := api.rotateAllAuthHashes(); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to rotate hashes")
log.Panicf("Unable to rotate auth hashes: %v", err)
}
@@ -717,11 +717,11 @@ func (api *API) removeData() error {
return nil
}
func (api *API) createBackup(ctx context.Context, w io.Writer, directories []string) error {
func (api *API) createBackup(w io.Writer, directories []string) error {
// Vacuum DB
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
_, err := api.db.DB.ExecContext(api.db.Ctx, "VACUUM;")
if err != nil {
return fmt.Errorf("Unable to vacuum database: %w", err)
return errors.Wrap(err, "Unable to vacuum database")
}
ar := zip.NewWriter(w)
@@ -792,10 +792,10 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
return nil
}
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(ctx)
func (api *API) isLastAdmin(userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
return false, fmt.Errorf("GetUsers DB Error: %w", err)
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err))
}
hasAdmin := false
@@ -809,7 +809,7 @@ func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
return !hasAdmin, nil
}
func (api *API) createUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
func (api *API) createUser(user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil || *rawPassword == "" {
return fmt.Errorf("password can't be empty")
@@ -844,7 +844,7 @@ func (api *API) createUser(ctx context.Context, user string, rawPassword *string
createParams.AuthHash = &authHash
// Create user in DB
if rows, err := api.db.Queries.CreateUser(ctx, createParams); err != nil {
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
@@ -855,7 +855,7 @@ func (api *API) createUser(ctx context.Context, user string, rawPassword *string
return nil
}
func (api *API) updateUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil && isAdmin == nil {
return fmt.Errorf("nothing to update")
@@ -870,15 +870,15 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
if isAdmin != nil {
updateParams.Admin = *isAdmin
} else {
user, err := api.db.Queries.GetUser(ctx, user)
user, err := api.db.Queries.GetUser(api.db.Ctx, user)
if err != nil {
return fmt.Errorf("GetUser DB Error: %w", err)
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
}
updateParams.Admin = user.Admin
}
// Check Admins - Disallow Demotion
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
if isLast, err := api.isLastAdmin(user); err != nil {
return err
} else if isLast && !updateParams.Admin {
return fmt.Errorf("unable to demote %s - last admin", user)
@@ -908,17 +908,17 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
}
// Update User
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams)
if err != nil {
return fmt.Errorf("UpdateUser DB Error: %w", err)
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
}
return nil
}
func (api *API) deleteUser(ctx context.Context, user string) error {
func (api *API) deleteUser(user string) error {
// Check Admins
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
if isLast, err := api.isLastAdmin(user); err != nil {
return err
} else if isLast {
return fmt.Errorf("unable to delete %s - last admin", user)
@@ -934,15 +934,15 @@ func (api *API) deleteUser(ctx context.Context, user string) error {
// Save Backup File (DB Only)
w := bufio.NewWriter(backupFile)
err = api.createBackup(ctx, w, []string{})
err = api.createBackup(w, []string{})
if err != nil {
return err
}
// Delete User
_, err = api.db.Queries.DeleteUser(ctx, user)
_, err = api.db.Queries.DeleteUser(api.db.Ctx, user)
if err != nil {
return fmt.Errorf("DeleteUser DB Error: %w", err)
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
}
return nil

View File

@@ -1,7 +1,6 @@
package api
import (
"context"
"crypto/md5"
"database/sql"
"fmt"
@@ -21,9 +20,11 @@ import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"reichard.io/antholume/api/renderer"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/ngtemplates/common"
"reichard.io/antholume/ngtemplates/pages"
"reichard.io/antholume/search"
)
@@ -102,7 +103,7 @@ func (api *API) appDocumentReader(c *gin.Context) {
}
func (api *API) appGetDocuments(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("documents", c)
settings, auth := api.getBaseTemplateVarsNew(common.RouteDocuments, c)
qParams := bindQueryParams(c, 9)
var query *string
@@ -111,10 +112,9 @@ func (api *API) appGetDocuments(c *gin.Context) {
query = &search
}
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
@@ -124,14 +124,14 @@ func (api *API) appGetDocuments(c *gin.Context) {
return
}
length, err := api.db.Queries.GetDocumentsSize(c, query)
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(c, documents); err != nil {
if err = api.getDocumentsWordCount(documents); err != nil {
log.Error("Unable to Get Word Counts: ", err)
}
@@ -139,18 +139,15 @@ func (api *API) appGetDocuments(c *gin.Context) {
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)
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Documents(
settings,
documents,
nextPage,
previousPage,
totalPages,
*qParams.Limit,
))
c.Render(http.StatusOK, r)
}
func (api *API) appGetDocument(c *gin.Context) {
@@ -163,10 +160,13 @@ func (api *API) appGetDocument(c *gin.Context) {
return
}
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
log.Error("GetDocumentWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
return
}
@@ -192,7 +192,7 @@ func (api *API) appGetProgress(c *gin.Context) {
progressFilter.DocumentID = *qParams.Document
}
progress, err := api.db.Queries.GetProgress(c, progressFilter)
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))
@@ -205,7 +205,7 @@ func (api *API) appGetProgress(c *gin.Context) {
}
func (api *API) appGetActivity(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("activity", c)
settings, auth := api.getBaseTemplateVarsNew(common.RouteActivity, c)
qParams := bindQueryParams(c, 15)
activityFilter := database.GetActivityParams{
@@ -219,23 +219,25 @@ func (api *API) appGetActivity(c *gin.Context) {
activityFilter.DocumentID = *qParams.Document
}
activity, err := api.db.Queries.GetActivity(c, activityFilter)
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)
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Activity(
settings,
activity,
))
c.Render(http.StatusOK, r)
}
func (api *API) appGetHome(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("home", c)
settings, auth := api.getBaseTemplateVarsNew(common.RouteHome, c)
start := time.Now()
graphData, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
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))
@@ -244,7 +246,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
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))
@@ -253,7 +255,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
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))
@@ -262,7 +264,7 @@ func (api *API) appGetHome(c *gin.Context) {
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(c)
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))
@@ -270,27 +272,32 @@ func (api *API) appGetHome(c *gin.Context) {
}
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)
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Home(
settings,
getSVGGraphData(graphData, 800, 70),
streaks,
arrangeUserStatistics(userStatistics),
common.UserMetadata{
DocumentCount: int(databaseInfo.DocumentsSize),
ActivityCount: int(databaseInfo.ActivitySize),
ProgressCount: int(databaseInfo.ProgressSize),
DeviceCount: int(databaseInfo.DevicesSize),
},
))
c.Render(http.StatusOK, r)
}
func (api *API) appGetSettings(c *gin.Context) {
templateVars, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(c, auth.UserName)
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(c, auth.UserName)
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))
@@ -368,7 +375,7 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
return
}
progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{
progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{
DocumentID: rDoc.DocumentID,
UserID: auth.UserName,
})
@@ -378,10 +385,13 @@ func (api *API) appGetDocumentProgress(c *gin.Context) {
return
}
document, err := api.db.GetDocument(c, rDoc.DocumentID, auth.UserName)
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
UserID: auth.UserName,
DocumentID: rDoc.DocumentID,
})
if err != nil {
log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
log.Error("GetDocumentWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return
}
@@ -401,7 +411,7 @@ func (api *API) appGetDevices(c *gin.Context) {
auth = data.(authData)
}
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
devices, err := api.db.Queries.GetDevices(api.db.Ctx, auth.UserName)
if err != nil && err != sql.ErrNoRows {
log.Error("GetDevices DB Error: ", err)
@@ -452,7 +462,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
}
// Check Already Exists
_, err = api.db.Queries.GetDocument(c, *metadataInfo.PartialMD5)
_, err = api.db.Queries.GetDocument(api.db.Ctx, *metadataInfo.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *metadataInfo.PartialMD5)
c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", *metadataInfo.PartialMD5))
@@ -480,7 +490,7 @@ func (api *API) appUploadNewDocument(c *gin.Context) {
}
// Upsert Document
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *metadataInfo.PartialMD5,
Title: metadataInfo.Title,
Author: metadataInfo.Author,
@@ -570,7 +580,7 @@ func (api *API) appEditDocument(c *gin.Context) {
coverFileName = &fileName
} else if rDocEdit.CoverGBID != nil {
coverDir := filepath.Join(api.cfg.DataPath, "covers")
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
fileName, err := metadata.CacheCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true)
if err == nil {
coverFileName = fileName
@@ -578,7 +588,7 @@ func (api *API) appEditDocument(c *gin.Context) {
}
// Update Document
if _, err := api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err := api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: rDocID.DocumentID,
Title: api.sanitizeInput(rDocEdit.Title),
Author: api.sanitizeInput(rDocEdit.Author),
@@ -602,7 +612,7 @@ func (api *API) appDeleteDocument(c *gin.Context) {
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
changed, err := api.db.Queries.DeleteDocument(c, rDocID.DocumentID)
changed, err := api.db.Queries.DeleteDocument(api.db.Ctx, rDocID.DocumentID)
if err != nil {
log.Error("DeleteDocument DB Error")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("DeleteDocument DB Error: %v", err))
@@ -664,7 +674,7 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
firstResult := metadataResults[0]
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{
DocumentID: rDocID.DocumentID,
Title: firstResult.Title,
Author: firstResult.Author,
@@ -683,10 +693,13 @@ func (api *API) appIdentifyDocument(c *gin.Context) {
templateVars["MetadataError"] = "No Metadata Found"
}
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
UserID: auth.UserName,
DocumentID: rDocID.DocumentID,
})
if err != nil {
log.Error("GetDocument DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
log.Error("GetDocumentWithStats DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return
}
@@ -811,7 +824,7 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
sendDownloadMessage("Saving to database...", gin.H{"Progress": 99})
// Upsert Document
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *metadata.PartialMD5,
Title: &docTitle,
Author: &docAuthor,
@@ -858,7 +871,7 @@ func (api *API) appEditSettings(c *gin.Context) {
// Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
data := api.authorizeCredentials(c, auth.UserName, password)
data := api.authorizeCredentials(auth.UserName, password)
if data == nil {
templateVars["PasswordErrorMessage"] = "Invalid Password"
} else {
@@ -880,7 +893,7 @@ func (api *API) appEditSettings(c *gin.Context) {
}
// Update User
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
_, 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))
@@ -888,7 +901,7 @@ func (api *API) appEditSettings(c *gin.Context) {
}
// Get User
user, err := api.db.Queries.GetUser(c, auth.UserName)
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))
@@ -896,7 +909,7 @@ func (api *API) appEditSettings(c *gin.Context) {
}
// Get Devices
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
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))
@@ -915,7 +928,7 @@ func (api *API) appDemoModeError(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
}
func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.GetDocumentsWithStatsRow) error {
func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStatsRow) error {
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
@@ -938,7 +951,7 @@ func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.
if err != nil {
log.Warn("Word Count Error: ", err)
} else {
if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{
if _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: item.ID,
Words: wordCount,
}); err != nil {
@@ -975,6 +988,21 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
}, auth
}
func (api *API) getBaseTemplateVarsNew(route common.Route, c *gin.Context) (common.Settings, authData) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
return common.Settings{
Route: route,
User: auth.UserName,
IsAdmin: auth.IsAdmin,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
}, auth
}
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams
err := c.BindQuery(&qParams)
@@ -999,7 +1027,7 @@ func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
}
func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
errorHuman := "We're not even sure what happened."
var errorHuman string = "We're not even sure what happened."
switch errorCode {
case http.StatusInternalServerError:
@@ -1019,13 +1047,13 @@ func appErrorPage(c *gin.Context, errorCode int, errorMessage string) {
})
}
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H {
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) common.UserStatistics {
// Item Sorter
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []map[string]any {
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []common.UserStatisticEntry {
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
sort.SliceStable(sortedData, less)
newData := make([]map[string]any, 0)
newData := make([]common.UserStatisticEntry, 0)
for _, item := range sortedData {
v := reflect.Indirect(reflect.ValueOf(item))
@@ -1041,17 +1069,17 @@ func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H
value = niceNumbers(rawVal)
}
newData = append(newData, map[string]any{
"UserID": item.UserID,
"Value": value,
newData = append(newData, common.UserStatisticEntry{
UserID: item.UserID,
Value: value,
})
}
return newData
}
return gin.H{
"WPM": gin.H{
return common.UserStatistics{
WPM: map[string][]common.UserStatisticEntry{
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
}),
@@ -1065,7 +1093,7 @@ func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
}),
},
"Duration": gin.H{
Duration: map[string][]common.UserStatisticEntry{
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
}),
@@ -1079,7 +1107,7 @@ func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) gin.H
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
}),
},
"Words": gin.H{
Words: map[string][]common.UserStatisticEntry{
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
}),

View File

@@ -1,7 +1,6 @@
package api
import (
"context"
"crypto/md5"
"fmt"
"net/http"
@@ -29,8 +28,8 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"`
}
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(ctx, username)
func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil {
return
}
@@ -53,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session First
if auth, ok := api.getSession(c, session); ok {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@@ -72,7 +71,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
return
}
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
authData := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
@@ -101,7 +100,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
// Validate Auth
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(c, user, password)
authData := api.authorizeCredentials(user, password)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
@@ -116,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session
if auth, ok := api.getSession(c, session); ok {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@@ -154,7 +153,7 @@ func (api *API) appAuthLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
authData := api.authorizeCredentials(c, username, password)
authData := api.authorizeCredentials(username, password)
if authData == nil {
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
@@ -209,7 +208,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(c)
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
log.Error("Failed to check all users: ", err)
templateVars["Error"] = "Failed to Create User"
@@ -225,7 +224,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
// Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(c, database.CreateUserParams{
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
@@ -243,7 +242,7 @@ func (api *API) appAuthRegister(c *gin.Context) {
}
// Get user
user, err := api.db.Queries.GetUser(c, username)
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil {
log.Error("GetUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists"
@@ -313,7 +312,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(c)
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
log.Error("Failed to check all users: ", err)
apiErrorPage(c, http.StatusBadRequest, "Failed to Create User")
@@ -328,7 +327,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
// Create user
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(c, database.CreateUserParams{
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: rUser.Username,
Pass: &hashedPassword,
AuthHash: &authHash,
@@ -348,7 +347,7 @@ func (api *API) koAuthRegister(c *gin.Context) {
})
}
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Get Session
authorizedUser := session.Get("authorizedUser")
isAdmin := session.Get("isAdmin")
@@ -366,7 +365,7 @@ func (api *API) getSession(ctx context.Context, session sessions.Session) (auth
}
// Validate Auth Hash
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
correctAuthHash, err := api.getUserAuthHash(auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash {
return
}
@@ -394,14 +393,14 @@ func (api *API) setSession(session sessions.Session, auth authData) error {
return session.Save()
}
func (api *API) getUserAuthHash(ctx context.Context, username string) (string, error) {
func (api *API) getUserAuthHash(username string) (string, error) {
// Return Cache
if api.userAuthCache[username] != "" {
return api.userAuthCache[username], nil
}
// Get DB
user, err := api.db.Queries.GetUser(ctx, username)
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil {
log.Error("GetUser DB Error:", err)
return "", err
@@ -413,7 +412,7 @@ func (api *API) getUserAuthHash(ctx context.Context, username string) (string, e
return api.userAuthCache[username], nil
}
func (api *API) rotateAllAuthHashes(ctx context.Context) error {
func (api *API) rotateAllAuthHashes() error {
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
@@ -429,7 +428,7 @@ func (api *API) rotateAllAuthHashes(ctx context.Context) error {
}()
qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(ctx)
users, err := qtx.GetUsers(api.db.Ctx)
if err != nil {
return err
}
@@ -445,7 +444,7 @@ func (api *API) rotateAllAuthHashes(ctx context.Context) error {
// Update User
authHash := fmt.Sprintf("%x", rawAuthHash)
if _, err = qtx.UpdateUser(ctx, database.UpdateUserParams{
if _, err = qtx.UpdateUser(api.db.Ctx, database.UpdateUserParams{
UserID: user.ID,
AuthHash: &authHash,
Admin: user.Admin,

View File

@@ -22,7 +22,7 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
}
// Get Document
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusBadRequest, "Unknown Document")
@@ -68,7 +68,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
}
// Validate Document Exists in DB
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
@@ -117,7 +117,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
}
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
if _, err = api.db.Queries.AddMetadata(api.db.Ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
@@ -132,7 +132,7 @@ func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string))
}
// Upsert Document
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &coverFile,
}); err != nil {

View File

@@ -72,7 +72,7 @@ type requestDocumentID struct {
}
func (api *API) koAuthorizeUser(c *gin.Context) {
koJSON(c, 200, gin.H{
c.JSON(200, gin.H{
"authorized": "OK",
})
}
@@ -91,7 +91,7 @@ func (api *API) koSetProgress(c *gin.Context) {
}
// Upsert Device
if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
ID: rPosition.DeviceID,
UserID: auth.UserName,
DeviceName: rPosition.Device,
@@ -101,14 +101,14 @@ func (api *API) koSetProgress(c *gin.Context) {
}
// Upsert Document
if _, err := api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err := api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: rPosition.DocumentID,
}); err != nil {
log.Error("UpsertDocument DB Error:", err)
}
// Create or Replace Progress
progress, err := api.db.Queries.UpdateProgress(c, database.UpdateProgressParams{
progress, err := api.db.Queries.UpdateProgress(api.db.Ctx, database.UpdateProgressParams{
Percentage: rPosition.Percentage,
DocumentID: rPosition.DocumentID,
DeviceID: rPosition.DeviceID,
@@ -121,7 +121,7 @@ func (api *API) koSetProgress(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID,
"timestamp": progress.CreatedAt,
})
@@ -140,14 +140,14 @@ func (api *API) koGetProgress(c *gin.Context) {
return
}
progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{
progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{
DocumentID: rDocID.DocumentID,
UserID: auth.UserName,
})
if err == sql.ErrNoRows {
// Not Found
koJSON(c, http.StatusOK, gin.H{})
c.JSON(http.StatusOK, gin.H{})
return
} else if err != nil {
log.Error("GetDocumentProgress DB Error:", err)
@@ -155,7 +155,7 @@ func (api *API) koGetProgress(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID,
"percentage": progress.Percentage,
"progress": progress.Progress,
@@ -202,7 +202,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Upsert Documents
for _, doc := range allDocuments {
if _, err := qtx.UpsertDocument(c, database.UpsertDocumentParams{
if _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: doc,
}); err != nil {
log.Error("UpsertDocument DB Error:", err)
@@ -212,7 +212,7 @@ func (api *API) koAddActivities(c *gin.Context) {
}
// Upsert Device
if _, err = qtx.UpsertDevice(c, database.UpsertDeviceParams{
if _, err = qtx.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
ID: rActivity.DeviceID,
UserID: auth.UserName,
DeviceName: rActivity.Device,
@@ -225,7 +225,7 @@ func (api *API) koAddActivities(c *gin.Context) {
// Add All Activity
for _, item := range rActivity.Activity {
if _, err := qtx.AddActivity(c, database.AddActivityParams{
if _, err := qtx.AddActivity(api.db.Ctx, database.AddActivityParams{
UserID: auth.UserName,
DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID,
@@ -247,7 +247,7 @@ func (api *API) koAddActivities(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"added": len(rActivity.Activity),
})
}
@@ -266,7 +266,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
}
// Upsert Device
if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
ID: rCheckActivity.DeviceID,
UserID: auth.UserName,
DeviceName: rCheckActivity.Device,
@@ -278,7 +278,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
}
// Get Last Device Activity
lastActivity, err := api.db.Queries.GetLastActivity(c, database.GetLastActivityParams{
lastActivity, err := api.db.Queries.GetLastActivity(api.db.Ctx, database.GetLastActivityParams{
UserID: auth.UserName,
DeviceID: rCheckActivity.DeviceID,
})
@@ -298,7 +298,7 @@ func (api *API) koCheckActivitySync(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"last_sync": parsedTime.Unix(),
})
}
@@ -329,7 +329,7 @@ func (api *API) koAddDocuments(c *gin.Context) {
// Upsert Documents
for _, doc := range rNewDocs.Documents {
_, err := qtx.UpsertDocument(c, database.UpsertDocumentParams{
_, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: doc.ID,
Title: api.sanitizeInput(doc.Title),
Author: api.sanitizeInput(doc.Author),
@@ -352,7 +352,7 @@ func (api *API) koAddDocuments(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"changed": len(rNewDocs.Documents),
})
}
@@ -371,7 +371,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
}
// Upsert Device
_, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
_, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID,
UserID: auth.UserName,
DeviceName: rCheckDocs.Device,
@@ -384,7 +384,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
}
// Get Missing Documents
missingDocs, err := api.db.Queries.GetMissingDocuments(c, rCheckDocs.Have)
missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -392,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
}
// Get Deleted Documents
deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(c, rCheckDocs.Have)
deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -407,7 +407,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return
}
wantedDocs, err := api.db.Queries.GetWantedDocuments(c, string(jsonHaves))
wantedDocs, err := api.db.Queries.GetWantedDocuments(api.db.Ctx, string(jsonHaves))
if err != nil {
log.Error("GetWantedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@@ -447,7 +447,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
rCheckDocSync.Delete = deletedDocIDs
}
koJSON(c, http.StatusOK, rCheckDocSync)
c.JSON(http.StatusOK, rCheckDocSync)
}
func (api *API) koUploadExistingDocument(c *gin.Context) {
@@ -467,7 +467,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
}
// Validate Document Exists in DB
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("GetDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Document")
@@ -522,7 +522,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
}
// Upsert Document
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
@@ -534,7 +534,7 @@ func (api *API) koUploadExistingDocument(c *gin.Context) {
return
}
koJSON(c, http.StatusOK, gin.H{
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
@@ -589,10 +589,3 @@ func getFileMD5(filePath string) (*string, error) {
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,7 +10,6 @@ import (
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/opds"
"reichard.io/antholume/pkg/ptr"
)
var mimeMapping map[string]string = map[string]string{
@@ -78,10 +77,9 @@ func (api *API) opdsDocuments(c *gin.Context) {
}
// Get Documents
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})

59
api/renderer/renderer.go Normal file
View File

@@ -0,0 +1,59 @@
package renderer
import (
"context"
"net/http"
"github.com/gin-gonic/gin/render"
"github.com/a-h/templ"
)
var Default = &HTMLTemplRenderer{}
type HTMLTemplRenderer struct {
FallbackHtmlRenderer render.HTMLRender
}
func (r *HTMLTemplRenderer) Instance(s string, d any) render.Render {
templData, ok := d.(templ.Component)
if !ok {
if r.FallbackHtmlRenderer != nil {
return r.FallbackHtmlRenderer.Instance(s, d)
}
}
return &Renderer{
Ctx: context.Background(),
Status: -1,
Component: templData,
}
}
func New(ctx context.Context, status int, component templ.Component) *Renderer {
return &Renderer{
Ctx: ctx,
Status: status,
Component: component,
}
}
type Renderer struct {
Ctx context.Context
Status int
Component templ.Component
}
func (t Renderer) Render(w http.ResponseWriter) error {
t.WriteContentType(w)
if t.Status != -1 {
w.WriteHeader(t.Status)
}
if t.Component != nil {
return t.Component.Render(t.Ctx, w)
}
return nil
}
func (t Renderer) WriteContentType(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}

View File

@@ -55,7 +55,6 @@ func getTimeZones() []string {
// niceSeconds takes in an int (in seconds) and returns a string readable
// representation. For example 1928371 -> "22d 7h 39m 31s".
// Deprecated: Use formatters.FormatDuration
func niceSeconds(input int64) (result string) {
if input == 0 {
return "N/A"
@@ -86,7 +85,6 @@ func niceSeconds(input int64) (result string) {
// niceNumbers takes in an int and returns a string representation. For example
// 19823 -> "19.8k".
// Deprecated: Use formatters.FormatNumber
func niceNumbers(input int64) string {
if input == 0 {
return "0"
@@ -108,9 +106,12 @@ func niceNumbers(input int64) string {
// getSVGGraphData builds SVGGraphData from the provided stats, width and height.
// It is used exclusively in templates to generate the daily read stats graph.
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
var intData []int64
var intData []graph.SVGRawData
for _, item := range inputData {
intData = append(intData, item.MinutesRead)
intData = append(intData, graph.SVGRawData{
Value: int(item.MinutesRead),
Label: item.Date,
})
}
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)

View File

@@ -1,74 +0,0 @@
package v1
import (
"context"
"reichard.io/antholume/database"
)
// GET /activity
func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
docFilter := false
if request.Params.DocFilter != nil {
docFilter = *request.Params.DocFilter
}
documentID := ""
if request.Params.DocumentId != nil {
documentID = *request.Params.DocumentId
}
offset := int64(0)
if request.Params.Offset != nil {
offset = *request.Params.Offset
}
limit := int64(100)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
UserID: auth.UserName,
DocFilter: docFilter,
DocumentID: documentID,
Offset: offset,
Limit: limit,
})
if err != nil {
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiActivities := make([]Activity, len(activities))
for i, a := range activities {
// Convert StartTime from interface{} to string
startTimeStr := ""
if a.StartTime != nil {
if str, ok := a.StartTime.(string); ok {
startTimeStr = str
}
}
apiActivities[i] = Activity{
DocumentId: a.DocumentID,
DeviceId: a.DeviceID,
StartTime: startTimeStr,
Title: a.Title,
Author: a.Author,
Duration: a.Duration,
StartPercentage: float32(a.StartPercentage),
EndPercentage: float32(a.EndPercentage),
ReadPercentage: float32(a.ReadPercentage),
}
}
response := ActivityResponse{
Activities: apiActivities,
}
return GetActivity200JSONResponse(response), nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,268 +0,0 @@
package v1
import (
"context"
"crypto/md5"
"fmt"
"net/http"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gorilla/sessions"
log "github.com/sirupsen/logrus"
)
// POST /auth/login
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
if request.Body == nil {
return Login400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
}
req := *request.Body
if req.Username == "" || req.Password == "" {
return Login400JSONResponse{Code: 400, Message: "Invalid credentials"}, nil
}
// MD5 - KOSync compatibility
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
// Verify credentials
user, err := s.db.Queries.GetUser(ctx, req.Username)
if err != nil {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
}
return Login200JSONResponse{
Username: user.ID,
IsAdmin: user.Admin,
}, nil
}
// POST /auth/register
func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) {
if !s.cfg.RegistrationEnabled {
return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil
}
if request.Body == nil {
return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
}
req := *request.Body
if req.Username == "" || req.Password == "" {
return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil
}
currentUsers, err := s.db.Queries.GetUsers(ctx)
if err != nil {
return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil
}
isAdmin := len(currentUsers) == 0
if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil {
return Register400JSONResponse{Code: 400, Message: err.Error()}, nil
}
user, err := s.db.Queries.GetUser(ctx, req.Username)
if err != nil {
return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil
}
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
return Register500JSONResponse{Code: 500, Message: err.Error()}, nil
}
return Register201JSONResponse{
Username: user.ID,
IsAdmin: user.Admin,
}, nil
}
// POST /auth/logout
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
}
session, err := s.getCookieSession(r)
if err != nil {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil {
return Logout401JSONResponse{Code: 401, Message: "Failed to logout"}, nil
}
return Logout200Response{}, nil
}
// GET /auth/me
func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetMe401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
return GetMe200JSONResponse{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
}, nil
}
func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error {
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return fmt.Errorf("internal context error")
}
session, err := s.getCookieSession(r)
if err != nil {
return fmt.Errorf("unauthorized")
}
session.Values["authorizedUser"] = username
session.Values["isAdmin"] = isAdmin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
session.Values["authHash"] = authHash
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to create session")
}
return nil
}
func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) {
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, err := store.Get(r, "token")
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
session.Options.Secure = s.cfg.CookieSecure
return session, nil
}
// getSessionFromContext extracts authData from context
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
auth, ok := ctx.Value("auth").(authData)
if !ok {
return authData{}, false
}
return auth, true
}
// isAdmin checks if a user has admin privileges
func (s *Server) isAdmin(ctx context.Context) bool {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return false
}
return auth.IsAdmin
}
// getRequestFromContext extracts the HTTP request from context
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
r, ok := ctx.Value("request").(*http.Request)
if !ok {
return nil
}
return r
}
// getResponseWriterFromContext extracts the response writer from context
func (s *Server) getResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
w, ok := ctx.Value("response").(http.ResponseWriter)
if !ok {
return nil
}
return w
}
// getSession retrieves auth data from the session cookie
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
// Get session from cookie store
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
} else {
log.Error("invalid cookie encryption key (must be 16 or 32 bytes)")
return authData{}, false
}
}
session, err := store.Get(r, "token")
if err != nil {
return authData{}, false
}
// Get session values
authorizedUser := session.Values["authorizedUser"]
isAdmin := session.Values["isAdmin"]
expiresAt := session.Values["expiresAt"]
authHash := session.Values["authHash"]
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
return authData{}, false
}
auth = authData{
UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool),
AuthHash: authHash.(string),
}
// Validate auth hash
ctx := r.Context()
correctAuthHash, err := s.getUserAuthHash(ctx, auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash {
return authData{}, false
}
return auth, true
}
// getUserAuthHash retrieves the user's auth hash from DB or cache
func (s *Server) getUserAuthHash(ctx context.Context, username string) (string, error) {
user, err := s.db.Queries.GetUser(ctx, username)
if err != nil {
return "", err
}
return *user.AuthHash, nil
}
// authData represents authenticated user information
type authData struct {
UserName string
IsAdmin bool
AuthHash string
}

View File

@@ -1,211 +0,0 @@
package v1
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
type AuthTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func (suite *AuthTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestAuth(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (suite *AuthTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *AuthTestSuite) createTestUser(username, password string) {
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
authHash := "test-auth-hash"
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
reqBody := LoginRequest{
Username: username,
Password: password,
}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code, "login should return 200")
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "should have session cookie")
return cookies[0]
}
func (suite *AuthTestSuite) TestAPILogin() {
suite.createTestUser("testuser", "testpass")
reqBody := LoginRequest{
Username: "testuser",
Password: "testpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
}
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
reqBody := LoginRequest{
Username: "testuser",
Password: "wrongpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *AuthTestSuite) TestAPIRegister() {
reqBody := LoginRequest{
Username: "newuser",
Password: "newpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusCreated, w.Code)
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("newuser", resp.Username)
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
cookies := w.Result().Cookies()
suite.Require().NotEmpty(cookies, "register should set a session cookie")
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
suite.Require().NoError(err)
suite.True(user.Admin)
}
func (suite *AuthTestSuite) TestAPIRegisterDisabled() {
suite.cfg.RegistrationEnabled = false
suite.srv = NewServer(suite.db, suite.cfg, nil)
reqBody := LoginRequest{
Username: "newuser",
Password: "newpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusForbidden, w.Code)
}
func (suite *AuthTestSuite) TestAPILogout() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
}
func (suite *AuthTestSuite) TestAPIGetMe() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp UserData
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
}
func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -1,882 +0,0 @@
package v1
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
)
// GET /documents
func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetDocuments401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
page := int64(1)
if request.Params.Page != nil {
page = *request.Params.Page
}
limit := int64(9)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
search := ""
if request.Params.Search != nil {
search = "%" + *request.Params.Search + "%"
}
rows, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: &search,
Deleted: ptrOf(false),
Offset: (page - 1) * limit,
Limit: limit,
},
)
if err != nil {
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
}
total := int64(len(rows))
var nextPage *int64
var previousPage *int64
if page*limit < total {
nextPage = ptrOf(page + 1)
}
if page > 1 {
previousPage = ptrOf(page - 1)
}
apiDocuments := make([]Document, len(rows))
wordCounts := make([]WordCount, 0, len(rows))
for i, row := range rows {
apiDocuments[i] = Document{
Id: row.ID,
Title: *row.Title,
Author: *row.Author,
Description: row.Description,
Isbn10: row.Isbn10,
Isbn13: row.Isbn13,
Words: row.Words,
Filepath: row.Filepath,
Percentage: ptrOf(float32(row.Percentage)),
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
Wpm: ptrOf(float32(row.Wpm)),
SecondsPerPercent: ptrOf(row.SecondsPerPercent),
LastRead: parseInterfaceTime(row.LastRead),
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available
}
if row.Words != nil {
wordCounts = append(wordCounts, WordCount{
DocumentId: row.ID,
Count: *row.Words,
})
}
}
response := DocumentsResponse{
Documents: apiDocuments,
Total: total,
Page: page,
Limit: limit,
NextPage: nextPage,
PreviousPage: previousPage,
Search: request.Params.Search,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
WordCounts: wordCounts,
}
return GetDocuments200JSONResponse(response), nil
}
// GET /documents/{id}
func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Use GetDocumentsWithStats to get document with stats
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserId: &progressRow.UserID,
DocumentId: &progressRow.DocumentID,
DeviceName: &progressRow.DeviceName,
Percentage: &progressRow.Percentage,
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
}
}
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available
}
response := DocumentResponse{
Document: apiDoc,
Progress: progress,
}
return GetDocument200JSONResponse(response), nil
}
// POST /documents/{id}
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists and get current state
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Validate at least one editable field is provided
if request.Body.Title == nil &&
request.Body.Author == nil &&
request.Body.Description == nil &&
request.Body.Isbn10 == nil &&
request.Body.Isbn13 == nil &&
request.Body.CoverGbid == nil {
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
}
// Handle cover via Google Books ID
var coverFileName *string
if request.Body.CoverGbid != nil {
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
if err == nil {
coverFileName = fileName
}
}
// Update document with provided editable fields only
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Title: request.Body.Title,
Author: request.Body.Author,
Description: request.Body.Description,
Isbn10: request.Body.Isbn10,
Isbn13: request.Body.Isbn13,
Coverfile: coverFileName,
// Preserve existing values for non-editable fields
Md5: currentDoc.Md5,
Basepath: currentDoc.Basepath,
Filepath: currentDoc.Filepath,
Words: currentDoc.Words,
})
if err != nil {
log.Error("UpsertDocument DB Error:", err)
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
}
// Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserId: &progressRow.UserID,
DocumentId: &progressRow.DocumentID,
DeviceName: &progressRow.DeviceName,
Percentage: &progressRow.Percentage,
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
}
}
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
}
response := DocumentResponse{
Document: apiDoc,
Progress: progress,
}
return EditDocument200JSONResponse(response), nil
}
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName
var newFileName string
if metadataInfo.Author != nil && *metadataInfo.Author != "" {
newFileName = newFileName + *metadataInfo.Author
} else {
newFileName = newFileName + "Unknown"
}
if metadataInfo.Title != nil && *metadataInfo.Title != "" {
newFileName = newFileName + " - " + *metadataInfo.Title
} else {
newFileName = newFileName + " - Unknown"
}
// Remove Slashes
fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
}
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries
func parseInterfaceTime(t any) *time.Time {
if t == nil {
return nil
}
switch v := t.(type) {
case string:
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil
}
return &parsed
case time.Time:
return &v
default:
return nil
}
}
// serveNoCover serves the default no-cover image from assets
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
// Try to open the no-cover image from assets
file, err := s.assets.Open("assets/images/no-cover.jpg")
if err != nil {
return nil, "", 0, err
}
// Get file info
info, err := file.Stat()
if err != nil {
file.Close()
return nil, "", 0, err
}
return file, "image/jpeg", info.Size(), nil
}
// openFileReader opens a file and returns it as an io.ReaderCloser
func openFileReader(path string) (*os.File, error) {
return os.Open(path)
}
// GET /documents/{id}/cover
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the cover image
// Validate Document Exists in DB
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
var coverFile fs.File
var contentType string
var contentLength int64
var needMetadataFetch bool
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Derive Path
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
// Validate File Exists
fileInfo, err := os.Stat(coverPath)
if os.IsNotExist(err) {
log.Error("Cover file should but doesn't exist: ", err)
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Open the cover file
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cover file:", err)
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
}
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
} else {
needMetadataFetch = true
}
// Attempt Metadata fetch if needed
var cachedCoverFile string = "UNKNOWN"
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
if needMetadataFetch {
// Create context with timeout for metadata service calls
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
if err == nil {
cachedCoverFile = *fileName
}
// Store First Metadata Result
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("AddMetadata DB Error:", err)
}
}
// Upsert Document
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &cachedCoverFile,
}); err != nil {
log.Warn("UpsertDocument DB Error:", err)
}
// Update cover file if we got a new cover
if cachedCoverFile != "UNKNOWN" {
coverPath := filepath.Join(coverDir, cachedCoverFile)
fileInfo, err := os.Stat(coverPath)
if err != nil {
log.Error("Failed to stat cached cover:", err)
// Keep the no-cover image
} else {
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cached cover:", err)
// Keep the no-cover image
} else {
_ = coverFile.Close() // Close the previous file
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
}
}
return &GetDocumentCover200Response{
Body: coverFile,
ContentLength: contentLength,
ContentType: contentType,
}, nil
}
// POST /documents/{id}/cover
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists
_, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Read multipart form
form, err := request.Body.ReadForm(32 << 20) // 32MB max
if err != nil {
log.Error("ReadForm error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
}
// Get file from form
fileField := form.File["cover_file"]
if len(fileField) == 0 {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
}
file := fileField[0]
// Validate file extension
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
}
// Open file
f, err := file.Open()
if err != nil {
log.Error("Open file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
}
defer f.Close()
// Read file content
data, err := io.ReadAll(f)
if err != nil {
log.Error("Read file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
}
if !allowedTypes[contentType] {
return UploadDocumentCover400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
}, nil
}
// Derive storage path
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
safePath := filepath.Join(coverDir, fileName)
// Save file
err = os.WriteFile(safePath, data, 0644)
if err != nil {
log.Error("Save file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
}
// Upsert document with new cover
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Coverfile: &fileName,
})
if err != nil {
log.Error("UpsertDocument DB error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
}
// Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserId: &progressRow.UserID,
DocumentId: &progressRow.DocumentID,
DeviceName: &progressRow.DeviceName,
Percentage: &progressRow.Percentage,
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
}
}
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
}
response := DocumentResponse{
Document: apiDoc,
Progress: progress,
}
return UploadDocumentCover200JSONResponse(response), nil
}
// GET /documents/{id}/file
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the document file download
// Get Document
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
if document.Filepath == nil {
log.Error("Document Doesn't Have File:", request.Id)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Derive Basepath
basepath := filepath.Join(s.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location
filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists
fileInfo, err := os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("File should but doesn't exist:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Open file
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open document file:", err)
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
}
return &GetDocumentFile200Response{
Body: file,
ContentLength: fileInfo.Size(),
Filename: filepath.Base(*document.Filepath),
}, nil
}
// POST /documents
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return CreateDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Read multipart form
form, err := request.Body.ReadForm(32 << 20) // 32MB max memory
if err != nil {
log.Error("ReadForm error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
}
// Get file from form
fileField := form.File["document_file"]
if len(fileField) == 0 {
return CreateDocument400JSONResponse{Code: 400, Message: "No file provided"}, nil
}
file := fileField[0]
// Validate file extension
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
}
// Open file
f, err := file.Open()
if err != nil {
log.Error("Open file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
}
defer f.Close()
// Read file content
data, err := io.ReadAll(f)
if err != nil {
log.Error("Read file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
if contentType != "application/epub+zip" && contentType != "application/zip" {
return CreateDocument400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
}, nil
}
// Create temp file to get metadata
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Error("Temp file create error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Write data to temp file
if _, err := tempFile.Write(data); err != nil {
log.Error("Write temp file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to write temp file"}, nil
}
// Get metadata using metadata package
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
if err != nil {
log.Error("GetMetadata error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to acquire metadata"}, nil
}
// Check if already exists
_, err = s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
if err == nil {
// Document already exists
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
apiDoc := Document{
Id: existingDoc.ID,
Title: *existingDoc.Title,
Author: *existingDoc.Author,
Description: existingDoc.Description,
Isbn10: existingDoc.Isbn10,
Isbn13: existingDoc.Isbn13,
Words: existingDoc.Words,
Filepath: existingDoc.Filepath,
CreatedAt: parseTime(existingDoc.CreatedAt),
UpdatedAt: parseTime(existingDoc.UpdatedAt),
Deleted: existingDoc.Deleted,
}
response := DocumentResponse{
Document: apiDoc,
}
return CreateDocument200JSONResponse(response), nil
}
// Derive & sanitize file name
fileName := deriveBaseFileName(metadataInfo)
basePath := filepath.Join(s.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save file to storage
err = os.WriteFile(safePath, data, 0644)
if err != nil {
log.Error("Save file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
}
// Upsert document
doc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: *metadataInfo.PartialMD5,
Title: metadataInfo.Title,
Author: metadataInfo.Author,
Description: metadataInfo.Description,
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
Filepath: &fileName,
Basepath: &basePath,
})
if err != nil {
log.Error("UpsertDocument DB error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to save document"}, nil
}
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
CreatedAt: parseTime(doc.CreatedAt),
UpdatedAt: parseTime(doc.UpdatedAt),
Deleted: doc.Deleted,
}
response := DocumentResponse{
Document: apiDoc,
}
return CreateDocument200JSONResponse(response), nil
}
// GetDocumentCover200Response is a custom response type that allows setting content type
type GetDocumentCover200Response struct {
Body io.Reader
ContentLength int64
ContentType string
}
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", response.ContentType)
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
// GetDocumentFile200Response is a custom response type that allows setting filename for download
type GetDocumentFile200Response struct {
Body io.Reader
ContentLength int64
Filename string
}
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}

View File

@@ -1,179 +0,0 @@
package v1
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
"reichard.io/antholume/pkg/ptr"
)
type DocumentsTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func (suite *DocumentsTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestDocuments(t *testing.T) {
suite.Run(t, new(DocumentsTestSuite))
}
func (suite *DocumentsTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
suite.authTestSuiteHelper(username, password)
}
func (suite *DocumentsTestSuite) login(username, password string) *http.Cookie {
return suite.authLoginHelper(username, password)
}
func (suite *DocumentsTestSuite) authTestSuiteHelper(username, password string) {
// MD5 hash for KOSync compatibility (matches existing system)
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
// Then argon2 hash the MD5
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: ptr.Of("test-auth-hash"),
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *DocumentsTestSuite) authLoginHelper(username, password string) *http.Cookie {
reqBody := LoginRequest{Username: username, Password: password}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
return cookies[0]
}
func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp DocumentsResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(int64(1), resp.Page)
suite.Equal(int64(9), resp.Limit)
suite.Equal("testuser", resp.User.Username)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocument() {
suite.createTestUser("testuser", "testpass")
docID := "test-doc-1"
_, err := suite.db.Queries.UpsertDocument(suite.T().Context(), database.UpsertDocumentParams{
ID: docID,
Title: ptr.Of("Test Document"),
Author: ptr.Of("Test Author"),
})
suite.Require().NoError(err)
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp DocumentResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(docID, resp.Document.Id)
suite.Equal("Test Document", resp.Document.Title)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/non-existent", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusNotFound, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -1,3 +0,0 @@
package v1
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml

View File

@@ -1,226 +0,0 @@
package v1
import (
"context"
"sort"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/graph"
)
// GET /home
func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetHome401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Get database info
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, auth.UserName)
if err != nil {
log.Error("GetDatabaseInfo DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get streaks
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
if err != nil {
log.Error("GetUserStreaks DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get graph data
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
if err != nil {
log.Error("GetDailyReadStats DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get user statistics
userStats, err := s.db.Queries.GetUserStatistics(ctx)
if err != nil {
log.Error("GetUserStatistics DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Build response
response := HomeResponse{
DatabaseInfo: DatabaseInfo{
DocumentsSize: dbInfo.DocumentsSize,
ActivitySize: dbInfo.ActivitySize,
ProgressSize: dbInfo.ProgressSize,
DevicesSize: dbInfo.DevicesSize,
},
Streaks: StreaksResponse{
Streaks: convertStreaks(streaks),
},
GraphData: GraphDataResponse{
GraphData: convertGraphData(graphData),
},
UserStatistics: arrangeUserStatistics(userStats),
}
return GetHome200JSONResponse(response), nil
}
// GET /home/streaks
func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetStreaks401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
if err != nil {
log.Error("GetUserStreaks DB Error:", err)
return GetStreaks500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := StreaksResponse{
Streaks: convertStreaks(streaks),
}
return GetStreaks200JSONResponse(response), nil
}
// GET /home/graph
func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestObject) (GetGraphDataResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetGraphData401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
if err != nil {
log.Error("GetDailyReadStats DB Error:", err)
return GetGraphData500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := GraphDataResponse{
GraphData: convertGraphData(graphData),
}
return GetGraphData200JSONResponse(response), nil
}
// GET /home/statistics
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
userStats, err := s.db.Queries.GetUserStatistics(ctx)
if err != nil {
log.Error("GetUserStatistics DB Error:", err)
return GetUserStatistics500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := arrangeUserStatistics(userStats)
return GetUserStatistics200JSONResponse(response), nil
}
func convertStreaks(streaks []database.UserStreak) []UserStreak {
result := make([]UserStreak, len(streaks))
for i, streak := range streaks {
result[i] = UserStreak{
Window: streak.Window,
MaxStreak: streak.MaxStreak,
MaxStreakStartDate: streak.MaxStreakStartDate,
MaxStreakEndDate: streak.MaxStreakEndDate,
CurrentStreak: streak.CurrentStreak,
CurrentStreakStartDate: streak.CurrentStreakStartDate,
CurrentStreakEndDate: streak.CurrentStreakEndDate,
}
}
return result
}
func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoint {
result := make([]GraphDataPoint, len(graphData))
for i, data := range graphData {
result[i] = GraphDataPoint{
Date: data.Date,
MinutesRead: data.MinutesRead,
}
}
return result
}
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
// Sort by WPM for each period
sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)}
}
return result
}
// Sort by duration (seconds) for each period
sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
}
return result
}
// Sort by words for each period
sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
}
return result
}
return UserStatisticsResponse{
Wpm: LeaderboardData{
All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
},
Duration: LeaderboardData{
All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
},
Words: LeaderboardData{
All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
},
}
}
// GetSVGGraphData generates SVG bezier path for graph visualization
func GetSVGGraphData(inputData []GraphDataPoint, svgWidth int, svgHeight int) graph.SVGGraphData {
// Convert to int64 slice expected by graph package
intData := make([]int64, len(inputData))
for i, data := range inputData {
intData[i] = int64(data.MinutesRead)
}
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
}

View File

@@ -1,6 +0,0 @@
package: v1
generate:
std-http-server: true
strict-server: true
models: true
output: api.gen.go

File diff suppressed because it is too large Load Diff

View File

@@ -1,124 +0,0 @@
package v1
import (
"context"
"math"
"reichard.io/antholume/database"
log "github.com/sirupsen/logrus"
)
// GET /progress
func (s *Server) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetProgressList401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
page := int64(1)
if request.Params.Page != nil {
page = *request.Params.Page
}
limit := int64(15)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
filter := database.GetProgressParams{
UserID: auth.UserName,
Offset: (page - 1) * limit,
Limit: limit,
}
if request.Params.Document != nil && *request.Params.Document != "" {
filter.DocFilter = true
filter.DocumentID = *request.Params.Document
}
progress, err := s.db.Queries.GetProgress(ctx, filter)
if err != nil {
log.Error("GetProgress DB Error:", err)
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
}
total := int64(len(progress))
var nextPage *int64
var previousPage *int64
// Calculate total pages
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
if page < totalPages {
nextPage = ptrOf(page + 1)
}
if page > 1 {
previousPage = ptrOf(page - 1)
}
apiProgress := make([]Progress, len(progress))
for i, row := range progress {
apiProgress[i] = Progress{
Title: row.Title,
Author: row.Author,
DeviceName: &row.DeviceName,
Percentage: &row.Percentage,
DocumentId: &row.DocumentID,
UserId: &row.UserID,
CreatedAt: parseTimePtr(row.CreatedAt),
}
}
response := ProgressListResponse{
Progress: &apiProgress,
Page: &page,
Limit: &limit,
NextPage: nextPage,
PreviousPage: previousPage,
Total: &total,
}
return GetProgressList200JSONResponse(response), nil
}
// GET /progress/{id}
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
filter := database.GetProgressParams{
UserID: auth.UserName,
DocFilter: true,
DocumentID: request.Id,
Offset: 0,
Limit: 1,
}
progress, err := s.db.Queries.GetProgress(ctx, filter)
if err != nil {
log.Error("GetProgress DB Error:", err)
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
}
if len(progress) == 0 {
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
}
row := progress[0]
apiProgress := Progress{
Title: row.Title,
Author: row.Author,
DeviceName: &row.DeviceName,
Percentage: &row.Percentage,
DocumentId: &row.DocumentID,
UserId: &row.UserID,
CreatedAt: parseTimePtr(row.CreatedAt),
}
response := ProgressResponse{
Progress: &apiProgress,
}
return GetProgress200JSONResponse(response), nil
}

View File

@@ -1,59 +0,0 @@
package v1
import (
"context"
"reichard.io/antholume/search"
log "github.com/sirupsen/logrus"
)
// GET /search
func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject) (GetSearchResponseObject, error) {
if request.Params.Query == "" {
return GetSearch400JSONResponse{Code: 400, Message: "Invalid query"}, nil
}
query := request.Params.Query
source := string(request.Params.Source)
// Validate source
if source != "LibGen" && source != "Annas Archive" {
return GetSearch400JSONResponse{Code: 400, Message: "Invalid source"}, nil
}
searchResults, err := search.SearchBook(query, search.Source(source))
if err != nil {
log.Error("Search Error:", err)
return GetSearch500JSONResponse{Code: 500, Message: "Search error"}, nil
}
apiResults := make([]SearchItem, len(searchResults))
for i, item := range searchResults {
apiResults[i] = SearchItem{
Id: ptrOf(item.ID),
Title: ptrOf(item.Title),
Author: ptrOf(item.Author),
Language: ptrOf(item.Language),
Series: ptrOf(item.Series),
FileType: ptrOf(item.FileType),
FileSize: ptrOf(item.FileSize),
UploadDate: ptrOf(item.UploadDate),
}
}
response := SearchResponse{
Results: apiResults,
Source: source,
Query: query,
}
return GetSearch200JSONResponse(response), nil
}
// POST /search
func (s *Server) PostSearch(ctx context.Context, request PostSearchRequestObject) (PostSearchResponseObject, error) {
// This endpoint is used by the SSR template to queue a download
// For the API, we just return success - the actual download happens via /documents POST
return PostSearch200Response{}, nil
}

View File

@@ -1,99 +0,0 @@
package v1
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
var _ StrictServerInterface = (*Server)(nil)
type Server struct {
mux *http.ServeMux
db *database.DBManager
cfg *config.Config
assets fs.FS
}
// NewServer creates a new native HTTP server
func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
s := &Server{
mux: http.NewServeMux(),
db: db,
cfg: cfg,
assets: assets,
}
// Create strict handler with authentication middleware
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
s.mux = HandlerFromMuxWithBaseURL(strictHandler, s.mux, "/api/v1").(*http.ServeMux)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// authMiddleware adds authentication context to requests
func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
// Store request and response in context for all handlers
ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w)
// Skip auth for public auth and info endpoints - cover and file require auth via cookies
if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
return handler(ctx, w, r, request)
}
auth, ok := s.getSession(r)
if !ok {
// Write 401 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
json.NewEncoder(w).Encode(ErrorResponse{Code: 401, Message: "Unauthorized"})
return nil, nil
}
// Check admin status for admin-only endpoints
adminEndpoints := []string{
"GetAdmin",
"PostAdminAction",
"GetUsers",
"UpdateUser",
"GetImportDirectory",
"PostImport",
"GetImportResults",
"GetLogs",
}
for _, adminEndpoint := range adminEndpoints {
if operationID == adminEndpoint && !auth.IsAdmin {
// Write 403 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(403)
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
return nil, nil
}
}
// Store auth in context for handlers to access
ctx = context.WithValue(ctx, "auth", auth)
return handler(ctx, w, r, request)
}
}
// GetInfo returns server information
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
return GetInfo200JSONResponse{
Version: s.cfg.Version,
SearchEnabled: s.cfg.SearchEnabled,
RegistrationEnabled: s.cfg.RegistrationEnabled,
}, nil
}

View File

@@ -1,58 +0,0 @@
package v1
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
type ServerTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func TestServer(t *testing.T) {
suite.Run(t, new(ServerTestSuite))
}
func (suite *ServerTestSuite) SetupTest() {
suite.cfg = &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *ServerTestSuite) TestNewServer() {
suite.NotNil(suite.srv)
suite.NotNil(suite.srv.mux)
suite.NotNil(suite.srv.db)
suite.NotNil(suite.srv.cfg)
}
func (suite *ServerTestSuite) TestServerServeHTTP() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -1,157 +0,0 @@
package v1
import (
"context"
"crypto/md5"
"fmt"
"reichard.io/antholume/database"
argon2id "github.com/alexedwards/argon2id"
)
// GET /settings
func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObject) (GetSettingsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
if err != nil {
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiDevices := make([]Device, len(devices))
for i, device := range devices {
apiDevices[i] = Device{
Id: &device.ID,
DeviceName: &device.DeviceName,
CreatedAt: parseTimePtr(device.CreatedAt),
LastSynced: parseTimePtr(device.LastSynced),
}
}
response := SettingsResponse{
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
Devices: &apiDevices,
}
return GetSettings200JSONResponse(response), nil
}
// authorizeCredentials verifies if credentials are valid
func (s *Server) authorizeCredentials(ctx context.Context, username string, password string) bool {
user, err := s.db.Queries.GetUser(ctx, username)
if err != nil {
return false
}
// Try argon2 hash comparison
if match, err := argon2id.ComparePasswordAndHash(password, *user.Pass); err == nil && match {
return true
}
return false
}
// PUT /settings
func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsRequestObject) (UpdateSettingsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UpdateSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "Request body is required"}, nil
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
updateParams := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Update password if provided
if request.Body.NewPassword != nil {
if request.Body.Password == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "Current password is required to set new password"}, nil
}
// Verify current password - first try bcrypt (new format), then argon2, then MD5 (legacy format)
currentPasswordMatched := false
// Try argon2 (current format)
if !currentPasswordMatched {
currentPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.Password)))
if match, err := argon2id.ComparePasswordAndHash(currentPassword, *user.Pass); err == nil && match {
currentPasswordMatched = true
}
}
if !currentPasswordMatched {
return UpdateSettings400JSONResponse{Code: 400, Message: "Invalid current password"}, nil
}
// Hash new password with argon2
newPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.NewPassword)))
hashedPassword, err := argon2id.CreateHash(newPassword, argon2id.DefaultParams)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: "Failed to hash password"}, nil
}
updateParams.Password = &hashedPassword
}
// Update timezone if provided
if request.Body.Timezone != nil {
updateParams.Timezone = request.Body.Timezone
}
// If nothing to update, return error
if request.Body.NewPassword == nil && request.Body.Timezone == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "At least one field must be provided"}, nil
}
// Update user
_, err = s.db.Queries.UpdateUser(ctx, updateParams)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
// Get updated settings to return
user, err = s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiDevices := make([]Device, len(devices))
for i, device := range devices {
apiDevices[i] = Device{
Id: &device.ID,
DeviceName: &device.DeviceName,
CreatedAt: parseTimePtr(device.CreatedAt),
LastSynced: parseTimePtr(device.LastSynced),
}
}
response := SettingsResponse{
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
Devices: &apiDevices,
}
return UpdateSettings200JSONResponse(response), nil
}

View File

@@ -1,84 +0,0 @@
package v1
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"time"
)
// writeJSON writes a JSON response (deprecated - used by tests only)
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to encode response")
}
}
// writeJSONError writes a JSON error response (deprecated - used by tests only)
func writeJSONError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, ErrorResponse{
Code: status,
Message: message,
})
}
// QueryParams represents parsed query parameters (deprecated - used by tests only)
type QueryParams struct {
Page int64
Limit int64
Search *string
}
// parseQueryParams parses URL query parameters (deprecated - used by tests only)
func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
if page == 0 {
page = 1
}
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
if limit == 0 {
limit = defaultLimit
}
search := query.Get("search")
var searchPtr *string
if search != "" {
searchPtr = ptrOf("%" + search + "%")
}
return QueryParams{
Page: page,
Limit: limit,
Search: searchPtr,
}
}
// ptrOf returns a pointer to the given value
func ptrOf[T any](v T) *T {
return &v
}
// parseTime parses a string to time.Time
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
if t.IsZero() {
t, _ = time.Parse("2006-01-02T15:04:05", s)
}
return t
}
// parseTimePtr parses an interface{} (from SQL) to *time.Time
func parseTimePtr(v interface{}) *time.Time {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
t := parseTime(s)
if t.IsZero() {
return nil
}
return &t
}
return nil
}

View File

@@ -1,76 +0,0 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
)
type UtilsTestSuite struct {
suite.Suite
}
func TestUtils(t *testing.T) {
suite.Run(t, new(UtilsTestSuite))
}
func (suite *UtilsTestSuite) TestWriteJSON() {
w := httptest.NewRecorder()
data := map[string]string{"test": "value"}
writeJSON(w, http.StatusOK, data)
suite.Equal("application/json", w.Header().Get("Content-Type"))
suite.Equal(http.StatusOK, w.Code)
var resp map[string]string
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("value", resp["test"])
}
func (suite *UtilsTestSuite) TestWriteJSONError() {
w := httptest.NewRecorder()
writeJSONError(w, http.StatusBadRequest, "test error")
suite.Equal(http.StatusBadRequest, w.Code)
var resp ErrorResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(http.StatusBadRequest, resp.Code)
suite.Equal("test error", resp.Message)
}
func (suite *UtilsTestSuite) TestParseQueryParams() {
query := make(map[string][]string)
query["page"] = []string{"2"}
query["limit"] = []string{"15"}
query["search"] = []string{"test"}
params := parseQueryParams(query, 9)
suite.Equal(int64(2), params.Page)
suite.Equal(int64(15), params.Limit)
suite.NotNil(params.Search)
}
func (suite *UtilsTestSuite) TestParseQueryParamsDefaults() {
query := make(map[string][]string)
params := parseQueryParams(query, 9)
suite.Equal(int64(1), params.Page)
suite.Equal(int64(9), params.Limit)
suite.Nil(params.Search)
}
func (suite *UtilsTestSuite) TestPtrOf() {
value := "test"
ptr := ptrOf(value)
suite.NotNil(ptr)
suite.Equal("test", *ptr)
}

View File

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

View File

@@ -66,56 +66,6 @@ function populateMetadata(data) {
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.
* Responsible for handling gesture / clicks, flushing progress & activity,
@@ -489,7 +439,6 @@ class EBookReader {
// ------------------------------------------------ //
// ----------------- Swipe Helpers ---------------- //
// ------------------------------------------------ //
let disablePagination = false;
let touchStartX,
touchStartY,
touchEndX,
@@ -510,38 +459,25 @@ class EBookReader {
}
// Swipe Left
if (!disablePagination && touchEndX + drasticity < touchStartX) {
if (touchEndX + drasticity < touchStartX) {
nextPage();
}
// Swipe Right
if (!disablePagination && touchEndX - drasticity > touchStartX) {
if (touchEndX - drasticity > touchStartX) {
prevPage();
}
}
function handleSwipeDown() {
if (bottomBar.classList.contains("bottom-0")) {
if (bottomBar.classList.contains("bottom-0"))
bottomBar.classList.remove("bottom-0");
disablePagination = false;
} else {
topBar.classList.add("top-0");
populateTOC()
disablePagination = true;
}
else topBar.classList.add("top-0");
}
function handleSwipeUp() {
if (topBar.classList.contains("top-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;
}
if (topBar.classList.contains("top-0")) topBar.classList.remove("top-0");
else bottomBar.classList.add("bottom-0");
}
this.rendition.hooks.render.register(function (doc, data) {
@@ -587,8 +523,8 @@ class EBookReader {
// Handle Event
if (yCoord < top) handleSwipeDown();
else if (yCoord > bottom) handleSwipeUp();
else if (!disablePagination && xCoord < left) prevPage();
else if (!disablePagination && xCoord > right) nextPage();
else if (xCoord < left) prevPage();
else if (xCoord > right) nextPage();
else {
bottomBar.classList.remove("bottom-0");
topBar.classList.remove("top-0");
@@ -734,9 +670,6 @@ class EBookReader {
// Close Top Bar
document.querySelector(".close-top-bar").addEventListener("click", () => {
topBar.classList.remove("top-0");
const tocEl = document.querySelector("#toc");
if (tocEl) tocEl.innerHTML = "";
});
}
@@ -1016,16 +949,10 @@ class EBookReader {
**/
async getXPathFromCFI(cfi) {
// Get DocFragment (Spine Index)
let cfiBaseMatch = cfi.match(/\(([^!]+)/);
if (!cfiBaseMatch) {
console.error("[getXPathFromCFI] No CFI Match");
return {};
}
let startCFI = cfiBaseMatch[1];
let startCFI = cfi.replace("epubcfi(", "");
let docFragmentIndex =
this.book.spine.spineItems.find((item) =>
item.cfiBase == startCFI
startCFI.startsWith(item.cfiBase),
).index + 1;
// Base Progress
@@ -1102,6 +1029,10 @@ class EBookReader {
return {};
}
// Match Item Index
let indexMatch = xpath.match(/\.(\d+)$/);
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
// Get Spine Item
let spinePosition = parseInt(fragMatch[1]) - 1;
let sectionItem = this.book.spine.get(spinePosition);
@@ -1193,11 +1124,6 @@ class EBookReader {
let element = docSearch.iterateNext() || derivedSelectorElement;
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 };
}
@@ -1317,7 +1243,7 @@ class EBookReader {
let spineWC = await Promise.all(
this.book.spine.spineItems.map(async (item) => {
let newDoc = await item.load(this.book.load.bind(this.book));
let spineWords = (newDoc.innerText || "").trim().split(/\s+/).length;
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
item.wordCount = spineWords;
return spineWords;
}),
@@ -1345,3 +1271,14 @@ class EBookReader {
}
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

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"database/sql/driver"
"embed"
_ "embed"
"errors"
"fmt"
"path/filepath"
@@ -19,6 +20,7 @@ import (
type DBManager struct {
DB *sql.DB
Ctx context.Context
Queries *Queries
cfg *config.Config
}
@@ -52,9 +54,12 @@ func init() {
// NewMgr Returns an initialized manager
func NewMgr(c *config.Config) *DBManager {
// Create Manager
dbm := &DBManager{cfg: c}
dbm := &DBManager{
Ctx: context.Background(),
cfg: c,
}
if err := dbm.init(context.Background()); err != nil {
if err := dbm.init(); err != nil {
log.Panic("Unable to init DB")
}
@@ -62,7 +67,7 @@ func NewMgr(c *config.Config) *DBManager {
}
// init loads the DB manager
func (dbm *DBManager) init(ctx context.Context) error {
func (dbm *DBManager) init() error {
// Build DB Location
var dbLocation string
switch dbm.cfg.DBType {
@@ -108,14 +113,14 @@ func (dbm *DBManager) init(ctx context.Context) error {
}
// Update settings
err = dbm.updateSettings(ctx)
err = dbm.updateSettings()
if err != nil {
log.Panicf("Error running DB settings update: %v", err)
return err
}
// Cache tables
if err := dbm.CacheTempTables(ctx); err != nil {
if err := dbm.CacheTempTables(); err != nil {
log.Warn("Refreshing temp table cache failed: ", err)
}
@@ -123,7 +128,7 @@ func (dbm *DBManager) init(ctx context.Context) error {
}
// Reload closes the DB & reinits
func (dbm *DBManager) Reload(ctx context.Context) error {
func (dbm *DBManager) Reload() error {
// Close handle
err := dbm.DB.Close()
if err != nil {
@@ -131,7 +136,7 @@ func (dbm *DBManager) Reload(ctx context.Context) error {
}
// Reinit DB
if err := dbm.init(ctx); err != nil {
if err := dbm.init(); err != nil {
return err
}
@@ -139,15 +144,15 @@ func (dbm *DBManager) Reload(ctx context.Context) error {
}
// CacheTempTables clears existing statistics and recalculates
func (dbm *DBManager) CacheTempTables(ctx context.Context) error {
func (dbm *DBManager) CacheTempTables() error {
start := time.Now()
if _, err := dbm.DB.ExecContext(ctx, user_streaks); err != nil {
if _, err := dbm.DB.ExecContext(dbm.Ctx, user_streaks); err != nil {
return err
}
log.Debug("Cached 'user_streaks' in: ", time.Since(start))
start = time.Now()
if _, err := dbm.DB.ExecContext(ctx, document_user_statistics); err != nil {
if _, err := dbm.DB.ExecContext(dbm.Ctx, document_user_statistics); err != nil {
return err
}
log.Debug("Cached 'document_user_statistics' in: ", time.Since(start))
@@ -157,7 +162,7 @@ func (dbm *DBManager) CacheTempTables(ctx context.Context) error {
// updateSettings ensures that we're enforcing foreign keys and enable journal
// mode.
func (dbm *DBManager) updateSettings(ctx context.Context) error {
func (dbm *DBManager) updateSettings() error {
// Set SQLite PRAGMA Settings
pragmaQuery := `
PRAGMA foreign_keys = ON;
@@ -169,7 +174,7 @@ func (dbm *DBManager) updateSettings(ctx context.Context) error {
}
// Update Antholume Version in DB
if _, err := dbm.Queries.UpdateSettings(ctx, UpdateSettingsParams{
if _, err := dbm.Queries.UpdateSettings(dbm.Ctx, UpdateSettingsParams{
Name: "version",
Value: dbm.cfg.Version,
}); err != nil {

View File

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

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package database
@@ -78,7 +78,7 @@ type DocumentUserStatistic struct {
WeeklyWpm float64 `json:"weekly_wpm"`
}
type Metadata struct {
type Metadatum struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Title *string `json:"title"`

View File

@@ -163,6 +163,42 @@ ORDER BY
DESC
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
SELECT * FROM documents
ORDER BY created_at DESC
@@ -200,25 +236,26 @@ SELECT
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
ROUND(
CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
)
END AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE
(docs.id = sqlc.narg('id') OR $id IS NULL)
AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL)
AND (
(
docs.title LIKE sqlc.narg('query') OR
docs.deleted = false AND (
$query IS NULL OR (
docs.title LIKE $query OR
docs.author LIKE $query
) OR $query IS NULL
)
)
ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT $limit

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: query.sql
package database
@@ -85,7 +85,7 @@ type AddMetadataParams struct {
Isbn13 *string `json:"isbn13"`
}
func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metadata, error) {
func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metadatum, error) {
row := q.db.QueryRowContext(ctx, addMetadata,
arg.DocumentID,
arg.Title,
@@ -96,7 +96,7 @@ func (q *Queries) AddMetadata(ctx context.Context, arg AddMetadataParams) (Metad
arg.Isbn10,
arg.Isbn13,
)
var i Metadata
var i Metadatum
err := row.Scan(
&i.ID,
&i.DocumentID,
@@ -543,6 +543,87 @@ func (q *Queries) GetDocumentProgress(ctx context.Context, arg GetDocumentProgre
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
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
@@ -638,36 +719,35 @@ SELECT
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
ROUND(
CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
)
END AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = ?1
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = ?1
WHERE
(docs.id = ?2 OR ?2 IS NULL)
AND (docs.deleted = ?3 OR ?3 IS NULL)
AND (
(
docs.title LIKE ?4 OR
docs.author LIKE ?4
) OR ?4 IS NULL
docs.deleted = false AND (
?2 IS NULL OR (
docs.title LIKE ?2 OR
docs.author LIKE ?2
)
)
ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT ?6
OFFSET ?5
LIMIT ?4
OFFSET ?3
`
type GetDocumentsWithStatsParams struct {
UserID string `json:"user_id"`
ID *string `json:"id"`
Deleted *bool `json:"-"`
Query *string `json:"query"`
Query interface{} `json:"query"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
@@ -686,14 +766,12 @@ type GetDocumentsWithStatsRow struct {
TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead interface{} `json:"last_read"`
Percentage float64 `json:"percentage"`
SecondsPerPercent int64 `json:"seconds_per_percent"`
SecondsPerPercent interface{} `json:"seconds_per_percent"`
}
func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWithStatsParams) ([]GetDocumentsWithStatsRow, error) {
rows, err := q.db.QueryContext(ctx, getDocumentsWithStats,
arg.UserID,
arg.ID,
arg.Deleted,
arg.Query,
arg.Offset,
arg.Limit,

View File

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

61
flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"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": 1773524153,
"narHash": "sha256-Jms57zzlFf64ayKzzBWSE2SGvJmK+NGt8Gli71d9kmY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e9f278faa1d0c2fc835bd331d4666b59b505a410",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"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
}

View File

@@ -1,37 +0,0 @@
{
description = "Development Environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
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
gopls
bun
nodejs
tailwindcss
];
shellHook = ''
export PATH=$PATH:~/go/bin
'';
};
}
);
}

1
frontend/.gitignore vendored
View File

@@ -1 +0,0 @@
node_modules

View File

@@ -1,2 +0,0 @@
# Generated API code
src/generated/**/*

View File

@@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

View File

@@ -1,111 +0,0 @@
# AnthoLume Frontend
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
## Tech Stack
- **React 19** - UI framework
- **TypeScript** - Type safety
- **React Query (TanStack Query)** - Server state management
- **Orval** - API client generation from OpenAPI spec
- **React Router** - Navigation
- **Tailwind CSS** - Styling
- **Vite** - Build tool
- **Axios** - HTTP client with auth interceptors
## Authentication
The frontend includes a complete authentication system:
### Auth Context
- `AuthProvider` - Manages authentication state globally
- `useAuth()` - Hook to access auth state and methods
- Token stored in `localStorage`
- Axios interceptors automatically attach Bearer token to API requests
### Protected Routes
- All main routes are wrapped in `ProtectedRoute`
- Unauthenticated users are redirected to `/login`
- Layout redirects to login if not authenticated
### Login Flow
1. User enters credentials on `/login`
2. POST to `/api/v1/auth/login`
3. Token stored in localStorage
4. Redirect to home page
5. Axios interceptor includes token in subsequent requests
### Logout Flow
1. User clicks "Logout" in dropdown menu
2. POST to `/api/v1/auth/logout`
3. Token cleared from localStorage
4. Redirect to `/login`
### 401 Handling
- Axios response interceptor clears token on 401 errors
- Prevents stale auth state
## Architecture
The frontend mirrors the existing SSR templates structure:
### Pages
- `HomePage` - Landing page with recent documents
- `DocumentsPage` - Document listing with search and pagination
- `DocumentPage` - Single document view with details
- `ProgressPage` - Reading progress table
- `ActivityPage` - User activity log
- `SearchPage` - Search interface
- `SettingsPage` - User settings
- `LoginPage` - Authentication
### Components
- `Layout` - Main layout with navigation sidebar and header
- Generated API hooks from `api/v1/openapi.yaml`
## API Integration
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
```bash
npm run generate:api
```
This generates:
- Type definitions for all API schemas
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
- Mutation hooks (`useLogin`, `useLogout`)
## Development
```bash
# Install dependencies
npm install
# Generate API types (if OpenAPI spec changes)
npm run generate:api
# Start development server
npm run dev
# Build for production
npm run build
```
## Deployment
The built output is in `dist/` and can be served by the Go backend or deployed separately.
## Migration from SSR
The frontend replicates the functionality of the following SSR templates:
- `templates/pages/home.tmpl``HomePage.tsx`
- `templates/pages/documents.tmpl``DocumentsPage.tsx`
- `templates/pages/document.tmpl``DocumentPage.tsx`
- `templates/pages/progress.tmpl``ProgressPage.tsx`
- `templates/pages/activity.tmpl``ActivityPage.tsx`
- `templates/pages/search.tmpl``SearchPage.tsx`
- `templates/pages/settings.tmpl``SettingsPage.tsx`
- `templates/pages/login.tmpl``LoginPage.tsx`
The styling follows the same Tailwind CSS classes as the original templates for consistency.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-C7Wct-hD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Co--bktJ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,82 +0,0 @@
import js from "@eslint/js";
import typescriptParser from "@typescript-eslint/parser";
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import tailwindcss from "eslint-plugin-tailwindcss";
import prettier from "eslint-plugin-prettier";
import eslintConfigPrettier from "eslint-config-prettier";
export default [
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
ignores: ["**/generated/**"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
projectService: true,
},
globals: {
localStorage: "readonly",
sessionStorage: "readonly",
document: "readonly",
window: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
HTMLElement: "readonly",
HTMLDivElement: "readonly",
HTMLButtonElement: "readonly",
HTMLAnchorElement: "readonly",
MouseEvent: "readonly",
Node: "readonly",
File: "readonly",
Blob: "readonly",
FormData: "readonly",
alert: "readonly",
confirm: "readonly",
prompt: "readonly",
React: "readonly",
},
},
plugins: {
"@typescript-eslint": typescriptPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
tailwindcss,
prettier,
},
rules: {
...eslintConfigPrettier.rules,
...tailwindcss.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"no-useless-catch": "off",
},
settings: {
react: {
version: "detect",
},
},
},
];

View File

@@ -1,31 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,21 +0,0 @@
import { defineConfig } from 'orval';
export default defineConfig({
antholume: {
output: {
mode: 'split',
baseUrl: '/api/v1',
target: 'src/generated',
schemas: 'src/generated/model',
client: 'react-query',
mock: false,
override: {
useQuery: true,
mutations: true,
},
},
input: {
target: '../api/v1/openapi.yaml',
},
},
});

View File

@@ -1,47 +0,0 @@
{
"name": "antholume-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"generate:api": "orval",
"lint": "eslint src --max-warnings=0",
"lint:fix": "eslint src --fix",
"format": "prettier --check src",
"format:fix": "prettier --write src"
},
"dependencies": {
"@tanstack/react-query": "^5.62.16",
"ajv": "^8.18.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"orval": "8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.8",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.5"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,12 +0,0 @@
import { AuthProvider } from './auth/AuthContext';
import { Routes } from './Routes';
function App() {
return (
<AuthProvider>
<Routes />
</AuthProvider>
);
}
export default App;

View File

@@ -1,125 +0,0 @@
import { Route, Routes as ReactRoutes } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/HomePage';
import DocumentsPage from './pages/DocumentsPage';
import DocumentPage from './pages/DocumentPage';
import ProgressPage from './pages/ProgressPage';
import ActivityPage from './pages/ActivityPage';
import SearchPage from './pages/SearchPage';
import SettingsPage from './pages/SettingsPage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import AdminPage from './pages/AdminPage';
import AdminImportPage from './pages/AdminImportPage';
import AdminImportResultsPage from './pages/AdminImportResultsPage';
import AdminUsersPage from './pages/AdminUsersPage';
import AdminLogsPage from './pages/AdminLogsPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
export function Routes() {
return (
<ReactRoutes>
<Route path="/" element={<Layout />}>
<Route
index
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>
<Route
path="documents"
element={
<ProtectedRoute>
<DocumentsPage />
</ProtectedRoute>
}
/>
<Route
path="documents/:id"
element={
<ProtectedRoute>
<DocumentPage />
</ProtectedRoute>
}
/>
<Route
path="progress"
element={
<ProtectedRoute>
<ProgressPage />
</ProtectedRoute>
}
/>
<Route
path="activity"
element={
<ProtectedRoute>
<ActivityPage />
</ProtectedRoute>
}
/>
<Route
path="search"
element={
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import"
element={
<ProtectedRoute>
<AdminImportPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import-results"
element={
<ProtectedRoute>
<AdminImportResultsPage />
</ProtectedRoute>
}
/>
<Route
path="admin/users"
element={
<ProtectedRoute>
<AdminUsersPage />
</ProtectedRoute>
}
/>
<Route
path="admin/logs"
element={
<ProtectedRoute>
<AdminLogsPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</ReactRoutes>
);
}

View File

@@ -1,170 +0,0 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
getGetMeQueryKey,
useLogin,
useLogout,
useGetMe,
useRegister,
} from '../generated/anthoLumeAPIV1';
interface AuthState {
isAuthenticated: boolean;
user: { username: string; is_admin: boolean } | null;
isCheckingAuth: boolean;
}
interface AuthContextType extends AuthState {
login: (_username: string, _password: string) => Promise<void>;
register: (_username: string, _password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
user: null,
isCheckingAuth: true,
});
const loginMutation = useLogin();
const registerMutation = useRegister();
const logoutMutation = useLogout();
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
const queryClient = useQueryClient();
const navigate = useNavigate();
useEffect(() => {
setAuthState(prev => {
if (meLoading) {
return { ...prev, isCheckingAuth: true };
} else if (meData?.data && meData.status === 200) {
const userData = 'username' in meData.data ? meData.data : null;
return {
isAuthenticated: true,
user: userData as { username: string; is_admin: boolean } | null,
isCheckingAuth: false,
};
} else if (meError || (meData && meData.status === 401)) {
return {
isAuthenticated: false,
user: null,
isCheckingAuth: false,
};
}
return { ...prev, isCheckingAuth: false };
});
}, [meData, meError, meLoading]);
const login = useCallback(
async (username: string, password: string) => {
try {
const response = await loginMutation.mutateAsync({
data: {
username,
password,
},
});
if (response.status !== 200 || !('username' in response.data)) {
setAuthState({
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Login failed');
}
setAuthState({
isAuthenticated: true,
user: response.data as { username: string; is_admin: boolean },
isCheckingAuth: false,
});
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/');
} catch (_error) {
setAuthState({
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Login failed');
}
},
[loginMutation, navigate, queryClient]
);
const register = useCallback(
async (username: string, password: string) => {
try {
const response = await registerMutation.mutateAsync({
data: {
username,
password,
},
});
if (response.status !== 201 || !('username' in response.data)) {
setAuthState({
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Registration failed');
}
setAuthState({
isAuthenticated: true,
user: response.data as { username: string; is_admin: boolean },
isCheckingAuth: false,
});
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/');
} catch (_error) {
setAuthState({
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
throw new Error('Registration failed');
}
},
[navigate, queryClient, registerMutation]
);
const logout = useCallback(() => {
logoutMutation.mutate(undefined, {
onSuccess: async () => {
setAuthState({
isAuthenticated: false,
user: null,
isCheckingAuth: false,
});
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
navigate('/login');
},
});
}, [logoutMutation, navigate, queryClient]);
return (
<AuthContext.Provider value={{ ...authState, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,23 +0,0 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isCheckingAuth } = useAuth();
const location = useLocation();
// Show loading while checking authentication status
if (isCheckingAuth) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
if (!isAuthenticated) {
// Redirect to login with the current location saved
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}

View File

@@ -1,35 +0,0 @@
import axios from 'axios';
const TOKEN_KEY = 'antholume_token';
// Request interceptor to add auth token to requests
axios.interceptors.request.use(
config => {
const token = localStorage.getItem(TOKEN_KEY);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// Response interceptor to handle auth errors
axios.interceptors.response.use(
response => {
return response;
},
error => {
if (error.response?.status === 401) {
// Clear token on auth failure
localStorage.removeItem(TOKEN_KEY);
// Optionally redirect to login
// window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default axios;

View File

@@ -1,45 +0,0 @@
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
interface BaseButtonProps {
variant?: 'default' | 'secondary';
children: React.ReactNode;
className?: string;
}
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass =
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white';
if (variant === 'secondary') {
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`;
}
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100`;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', children, className = '', ...props }, ref) => {
return (
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
{children}
</button>
);
}
);
Button.displayName = 'Button';
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
({ variant = 'default', children, className = '', ...props }, ref) => {
return (
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
{children}
</a>
);
}
);
ButtonLink.displayName = 'ButtonLink';

View File

@@ -1,41 +0,0 @@
import { ReactNode } from 'react';
interface FieldProps {
label: ReactNode;
children: ReactNode;
isEditing?: boolean;
}
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
return (
<div className="relative rounded">
<div className="relative inline-flex gap-2 text-gray-500">{label}</div>
{children}
</div>
);
}
interface FieldLabelProps {
children: ReactNode;
}
export function FieldLabel({ children }: FieldLabelProps) {
return <p>{children}</p>;
}
interface FieldValueProps {
children: ReactNode;
className?: string;
}
export function FieldValue({ children, className = '' }: FieldValueProps) {
return <p className={`text-lg font-medium ${className}`}>{children}</p>;
}
interface FieldActionsProps {
children: ReactNode;
}
export function FieldActions({ children }: FieldActionsProps) {
return <div className="inline-flex gap-2">{children}</div>;
}

View File

@@ -1,193 +0,0 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
interface NavItem {
path: string;
label: string;
icon: React.ElementType;
title: string;
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
];
const adminSubItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
];
// Helper function to check if pathname has a prefix
function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix);
}
export default function HamburgerMenu() {
const location = useLocation();
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const isAdmin = user?.is_admin ?? false;
// Fetch server info for version
const { data: infoData } = useGetInfo({
query: {
staleTime: Infinity, // Info doesn't change frequently
},
});
const version =
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
? infoData.data.version
: 'v1.0.0';
return (
<div className="relative z-40 ml-6 flex flex-col">
{/* Checkbox input for state management */}
<input
type="checkbox"
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
id="mobile-nav-checkbox"
checked={isOpen}
onChange={e => setIsOpen(e.target.checked)}
/>
{/* Hamburger icon lines with CSS animations - hidden on desktop */}
<span
className="z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{
transformOrigin: '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',
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{
transformOrigin: '0% 100%',
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',
opacity: isOpen ? 0 : 1,
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{
transformOrigin: '0% 0%',
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',
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
}}
/>
{/* Navigation menu with slide animation */}
<div
id="menu"
className="fixed -ml-6 h-full w-56 bg-white shadow-lg lg:w-48 dark:bg-gray-700"
style={{
top: 0,
paddingTop: 'env(safe-area-inset-top)',
transformOrigin: '0% 0%',
// On desktop (lg), always show the menu via CSS class
// On mobile, control via state
transform: isOpen ? 'none' : 'translate(-100%, 0)',
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
}}
>
{/* Desktop override - always visible */}
<style>{`
@media (min-width: 1024px) {
#menu {
transform: none !important;
}
}
`}</style>
<div className="flex h-16 justify-end lg:justify-around">
<p className="my-auto pr-8 text-right text-xl font-bold lg:pr-0 dark:text-white">
AnthoLume
</p>
</div>
<nav>
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
onClick={() => setIsOpen(false)}
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
location.pathname === item.path
? 'border-purple-500 dark:text-white'
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
}`}
>
<item.icon size={20} />
<span className="mx-4 text-sm font-normal">{item.label}</span>
</Link>
))}
{/* Admin section - only visible for admins */}
{isAdmin && (
<div
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
hasPrefix(location.pathname, '/admin')
? 'border-purple-500 dark:text-white'
: 'border-transparent text-gray-400'
}`}
>
{/* Admin header - always shown */}
<Link
to="/admin"
onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
? 'dark:text-white'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
}`}
>
<SettingsIcon size={20} />
<span className="mx-4 text-sm font-normal">Admin</span>
</Link>
{hasPrefix(location.pathname, '/admin') && (
<div className="flex flex-col gap-4">
{adminSubItems.map(item => (
<Link
key={item.path}
to={item.path}
onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${
location.pathname === item.path
? 'dark:text-white'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
}`}
style={{ paddingLeft: '1.75em' }}
>
<span className="mx-4 text-sm font-normal">{item.label}</span>
</Link>
))}
</div>
)}
</div>
)}
</nav>
<a
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white"
target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer"
>
<GitIcon size={20} />
<span className="text-xs">{version}</span>
</a>
</div>
</div>
);
}

View File

@@ -1,157 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons';
import HamburgerMenu from './HamburgerMenu';
export default function Layout() {
const location = useLocation();
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
const { data } = useGetMe(isAuthenticated ? {} : undefined);
const fetchedUser =
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
const userData = user ?? fetchedUser;
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleLogout = () => {
logout();
setIsUserDropdownOpen(false);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsUserDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Get current page title
const navItems = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
{ path: '/admin/users', title: 'Admin - Users' },
{ path: '/admin/logs', title: 'Admin - Logs' },
{ path: '/admin', title: 'Admin - General' },
{ path: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
];
const currentPageTitle =
navItems.find(item =>
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
)?.title || 'Home';
useEffect(() => {
document.title = `AnthoLume - ${currentPageTitle}`;
}, [currentPageTitle]);
// Show loading while checking authentication status
if (isCheckingAuth) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-800">
{/* Header */}
<div className="flex h-16 w-full items-center justify-between">
{/* Mobile Navigation Button with CSS animations */}
<HamburgerMenu />
{/* Header Title */}
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
{currentPageTitle}
</h1>
{/* User Dropdown */}
<div
className="relative flex w-full items-center justify-end space-x-4 p-4"
ref={dropdownRef}
>
<button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="relative block text-gray-800 dark:text-gray-200"
>
<UserIcon size={20} />
</button>
{isUserDropdownOpen && (
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<Link
to="/settings"
onClick={() => setIsUserDropdownOpen(false)}
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>
<span className="flex flex-col">
<span>Settings</span>
</span>
</Link>
<button
onClick={handleLogout}
className="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>
<span className="flex flex-col">
<span>Logout</span>
</span>
</button>
</div>
</div>
</div>
)}
<button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
>
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
<span
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<DropdownIcon size={20} />
</span>
</button>
</div>
</div>
{/* Main Content */}
<main
className="relative overflow-hidden"
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
>
<div
id="container"
className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48"
style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}
>
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -1,203 +0,0 @@
# UI Components
This directory contains reusable UI components for the AnthoLume application.
## Toast Notifications
### Usage
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
```tsx
import { useToasts } from './components/ToastContext';
function MyComponent() {
const { showInfo, showWarning, showError, showToast } = useToasts();
const handleAction = async () => {
try {
// Do something
showInfo('Operation completed successfully!');
} catch (error) {
showError('An error occurred while processing your request.');
}
};
return <button onClick={handleAction}>Click me</button>;
}
```
### API
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
- Shows a toast notification
- Returns the toast ID for manual removal
- Default type: 'info'
- Default duration: 5000ms (0 = no auto-dismiss)
- `showInfo(message: string, duration?: number): string`
- Shortcut for showing an info toast
- `showWarning(message: string, duration?: number): string`
- Shortcut for showing a warning toast
- `showError(message: string, duration?: number): string`
- Shortcut for showing an error toast
- `removeToast(id: string): void`
- Manually remove a toast by ID
- `clearToasts(): void`
- Clear all active toasts
### Examples
```tsx
// Info toast (auto-dismisses after 5 seconds)
showInfo('Document saved successfully!');
// Warning toast (auto-dismisses after 10 seconds)
showWarning('Low disk space warning', 10000);
// Error toast (no auto-dismiss)
showError('Failed to load data', 0);
// Generic toast
showToast('Custom message', 'warning', 3000);
```
## Skeleton Loading
### Usage
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
### Components
#### `Skeleton`
Basic skeleton element with various variants:
```tsx
import { Skeleton } from './components/Skeleton';
// Default (rounded rectangle)
<Skeleton className="w-full h-8" />
// Text variant
<Skeleton variant="text" className="w-3/4" />
// Circular variant (for avatars)
<Skeleton variant="circular" width={40} height={40} />
// Rectangular variant
<Skeleton variant="rectangular" width="100%" height={200} />
```
#### `SkeletonText`
Multiple lines of text skeleton:
```tsx
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
```
#### `SkeletonAvatar`
Avatar placeholder:
```tsx
<SkeletonAvatar size="md" />
<SkeletonAvatar size={56} />
```
#### `SkeletonCard`
Card placeholder with optional elements:
```tsx
// Default card
<SkeletonCard />
// With avatar
<SkeletonCard showAvatar />
// Custom configuration
<SkeletonCard
showAvatar
showTitle
showText
textLines={4}
className="max-w-sm"
/>
```
#### `SkeletonTable`
Table placeholder:
```tsx
<SkeletonTable rows={5} columns={4} />
<SkeletonTable rows={10} columns={6} showHeader={false} />
```
#### `SkeletonButton`
Button placeholder:
```tsx
<SkeletonButton width={120} />
<SkeletonButton className="w-full" />
```
#### `PageLoader`
Full-page loading indicator:
```tsx
<PageLoader message="Loading your documents..." />
```
#### `InlineLoader`
Small inline loading spinner:
```tsx
<InlineLoader size="sm" />
<InlineLoader size="md" />
<InlineLoader size="lg" />
```
## Integration with Table Component
The Table component now supports skeleton loading:
```tsx
import { Table, SkeletonTable } from './components/Table';
function DocumentList() {
const { data, isLoading } = useGetDocuments();
if (isLoading) {
return <SkeletonTable rows={10} columns={5} />;
}
return <Table columns={columns} data={data?.documents || []} />;
}
```
## Theme Support
All components automatically adapt to the current theme:
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
## Dependencies
- `clsx` - Utility for constructing className strings
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
- `lucide-react` - Icon library used by Toast component

View File

@@ -1,52 +0,0 @@
import { getSVGGraphData } from './ReadingHistoryGraph';
// Test data matching Go test exactly
const testInput = [
{ date: '2024-01-01', minutes_read: 10 },
{ date: '2024-01-02', minutes_read: 90 },
{ date: '2024-01-03', minutes_read: 50 },
{ date: '2024-01-04', minutes_read: 5 },
{ date: '2024-01-05', minutes_read: 10 },
{ date: '2024-01-06', minutes_read: 5 },
{ date: '2024-01-07', minutes_read: 70 },
{ date: '2024-01-08', minutes_read: 60 },
{ date: '2024-01-09', minutes_read: 50 },
{ date: '2024-01-10', minutes_read: 90 },
];
const svgWidth = 500;
const svgHeight = 100;
describe('ReadingHistoryGraph', () => {
describe('getSVGGraphData', () => {
it('should match exactly', () => {
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
// Expected values from Go test
const expectedBezierPath =
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
const expectedBezierFill = 'L 500,98 L 50,98 Z';
const expectedWidth = 500;
const expectedHeight = 100;
const expectedOffset = 50;
expect(result.BezierPath).toBe(expectedBezierPath);
expect(result.BezierFill).toBe(expectedBezierFill);
expect(svgWidth).toBe(expectedWidth);
expect(svgHeight).toBe(expectedHeight);
expect(result.Offset).toBe(expectedOffset);
// Verify line points are integers like Go
result.LinePoints.forEach((p, _i) => {
expect(Number.isInteger(p.x)).toBe(true);
expect(Number.isInteger(p.y)).toBe(true);
});
// Expected line points from Go calculation:
// idx 0: itemSize=5, itemY=95, lineX=50
// idx 1: itemSize=45, itemY=55, lineX=100
// idx 2: itemSize=25, itemY=75, lineX=150
// ...and so on
});
});
});

View File

@@ -1,253 +0,0 @@
import type { GraphDataPoint } from '../generated/model';
interface ReadingHistoryGraphProps {
data: GraphDataPoint[];
}
export interface SVGPoint {
x: number;
y: number;
}
/**
* Generates bezier control points for smooth curves
*/
function getSVGBezierOpposedLine(
pointA: SVGPoint,
pointB: SVGPoint
): { Length: number; Angle: number } {
const lengthX = pointB.x - pointA.x;
const lengthY = pointB.y - pointA.y;
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
return {
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
};
}
function getBezierControlPoint(
currentPoint: SVGPoint,
prevPoint: SVGPoint | null,
nextPoint: SVGPoint | null,
isReverse: boolean
): SVGPoint {
// First / Last Point
let pPrev = prevPoint;
let pNext = nextPoint;
if (!pPrev) {
pPrev = currentPoint;
}
if (!pNext) {
pNext = currentPoint;
}
// Modifiers
const smoothingRatio: number = 0.2;
const directionModifier: number = isReverse ? Math.PI : 0;
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
const lineAngle: number = opposingLine.Angle + directionModifier;
const lineLength: number = opposingLine.Length * smoothingRatio;
// Calculate Control Point - Go converts everything to int
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
return {
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
};
}
/**
* Generates the bezier path for the graph
*/
function getSVGBezierPath(points: SVGPoint[]): string {
if (points.length === 0) {
return '';
}
let bezierSVGPath: string = '';
for (let index = 0; index < points.length; index++) {
const point = points[index];
if (index === 0) {
bezierSVGPath += `M ${point.x},${point.y}`;
} else {
const pointPlusOne = points[index + 1];
const pointMinusOne = points[index - 1];
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
const startControlPoint: SVGPoint = getBezierControlPoint(
pointMinusOne,
pointMinusTwo,
point,
false
);
const endControlPoint: SVGPoint = getBezierControlPoint(
point,
pointMinusOne,
pointPlusOne || point,
true
);
// Go converts all coordinates to int
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
}
}
return bezierSVGPath;
}
export interface SVGGraphData {
LinePoints: SVGPoint[];
BezierPath: string;
BezierFill: string;
Offset: number;
}
/**
* Get SVG Graph Data
*/
export function getSVGGraphData(
inputData: GraphDataPoint[],
svgWidth: number,
svgHeight: number
): SVGGraphData {
// Derive Height
let maxHeight: number = 0;
for (const item of inputData) {
if (item.minutes_read > maxHeight) {
maxHeight = item.minutes_read;
}
}
// Vertical Graph Real Estate
const sizePercentage: number = 0.5;
// Scale Ratio -> Desired Height
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
// Point Block Offset
const blockOffset: number = Math.floor(svgWidth / inputData.length);
// Line & Bar Points
const linePoints: SVGPoint[] = [];
// Bezier Fill Coordinates (Max X, Min X, Max Y)
let maxBX: number = 0;
let maxBY: number = 0;
let minBX: number = 0;
for (let idx = 0; idx < inputData.length; idx++) {
// Go uses int conversion
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
const itemY = svgHeight - itemSize;
const lineX = (idx + 1) * blockOffset;
linePoints.push({
x: lineX,
y: itemY,
});
if (lineX > maxBX) {
maxBX = lineX;
}
if (lineX < minBX) {
minBX = lineX;
}
if (itemY > maxBY) {
maxBY = itemY;
}
}
// Return Data
return {
LinePoints: linePoints,
BezierPath: getSVGBezierPath(linePoints),
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
Offset: blockOffset,
};
}
/**
* Formats a date string to YYYY-MM-DD format (ISO-like)
* Note: The date string from the API is already in YYYY-MM-DD format,
* but since JavaScript Date parsing can add timezone offsets, we use UTC
* methods to ensure we get the correct date.
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
// Use UTC methods to avoid timezone offset issues
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ReadingHistoryGraph component
*
* Displays a bezier curve graph of daily reading totals with hover tooltips.
* Exact copy of Go template implementation.
*/
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
const svgWidth = 800;
const svgHeight = 70;
if (!data || data.length < 2) {
return (
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
<p className="text-gray-400 dark:text-gray-300">No data available</p>
</div>
);
}
const {
BezierPath,
BezierFill,
LinePoints: _linePoints,
} = getSVGGraphData(data, svgWidth, svgHeight);
return (
<div className="relative">
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
<path fill="none" stroke="#316BBE" d={BezierPath} />
</svg>
<div
className="absolute top-0 flex size-full"
style={{
width: 'calc(100% * 31 / 30)',
transform: 'translateX(-50%)',
left: '50%',
}}
>
{data.map((point, i) => (
<div
key={i}
onClick
className="w-full opacity-0 hover:opacity-100"
style={{
background:
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
}}
>
<div
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
style={{
transform: 'translateX(-50%)',
left: '50%',
backgroundColor: 'rgba(128, 128, 128, 0.2)',
}}
>
<span>{formatDate(point.date)}</span>
<span>{point.minutes_read} minutes</span>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,221 +0,0 @@
import { cn } from '../utils/cn';
interface SkeletonProps {
className?: string;
variant?: 'default' | 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'wave' | 'none';
}
export function Skeleton({
className = '',
variant = 'default',
width,
height,
animation = 'pulse',
}: SkeletonProps) {
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
const variantClasses = {
default: 'rounded',
text: 'rounded-md h-4',
circular: 'rounded-full',
rectangular: 'rounded-none',
};
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-wave',
none: '',
};
const style = {
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
height:
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
};
return (
<div
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
style={style}
/>
);
}
interface SkeletonTextProps {
lines?: number;
className?: string;
lineClassName?: string;
}
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
/>
))}
</div>
);
}
interface SkeletonAvatarProps {
size?: number | 'sm' | 'md' | 'lg';
className?: string;
}
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
const sizeMap = {
sm: 32,
md: 40,
lg: 56,
};
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
}
interface SkeletonCardProps {
className?: string;
showAvatar?: boolean;
showTitle?: boolean;
showText?: boolean;
textLines?: number;
}
export function SkeletonCard({
className = '',
showAvatar = false,
showTitle = true,
showText = true,
textLines = 3,
}: SkeletonCardProps) {
return (
<div
className={cn(
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
className
)}
>
{showAvatar && (
<div className="mb-4 flex items-start gap-4">
<SkeletonAvatar />
<div className="flex-1">
<Skeleton variant="text" className="mb-2 w-3/4" />
<Skeleton variant="text" className="w-1/2" />
</div>
</div>
)}
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
{showText && <SkeletonText lines={textLines} />}
</div>
);
}
interface SkeletonTableProps {
rows?: number;
columns?: number;
className?: string;
showHeader?: boolean;
}
export function SkeletonTable({
rows = 5,
columns = 4,
className = '',
showHeader = true,
}: SkeletonTableProps) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
{showHeader && (
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
)}
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
interface SkeletonButtonProps {
className?: string;
width?: string | number;
}
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
return (
<Skeleton
variant="rectangular"
height={36}
width={width || '100%'}
className={cn('rounded', className)}
/>
);
}
interface PageLoaderProps {
message?: string;
className?: string;
}
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return (
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
<div className="relative">
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500 dark:border-gray-600" />
</div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{message}</p>
</div>
);
}
interface InlineLoaderProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-3',
lg: 'w-8 h-8 border-4',
};
return (
<div className={cn('flex items-center justify-center', className)}>
<div
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`}
/>
</div>
);
}
// Re-export SkeletonTable for backward compatibility
export { SkeletonTable as SkeletonTableExport };

View File

@@ -1,127 +0,0 @@
import React from 'react';
import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn';
export interface Column<T extends Record<string, unknown>> {
key: keyof T;
header: string;
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
className?: string;
}
export interface TableProps<T extends Record<string, unknown>> {
columns: Column<T>[];
data: T[];
loading?: boolean;
emptyMessage?: string;
rowKey?: keyof T | ((row: T) => string);
}
// Skeleton table component for loading state
function SkeletonTable({
rows = 5,
columns = 4,
className = '',
}: {
rows?: number;
columns?: number;
className?: string;
}) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function Table<T extends Record<string, unknown>>({
columns,
data,
loading = false,
emptyMessage = 'No Results',
rowKey,
}: TableProps<T>) {
const getRowKey = (_row: T, index: number): string => {
if (typeof rowKey === 'function') {
return rowKey(_row);
}
if (rowKey) {
return String(_row[rowKey] ?? index);
}
return `row-${index}`;
};
if (loading) {
return <SkeletonTable rows={5} columns={columns.length} />;
}
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<thead>
<tr className="border-b dark:border-gray-600">
{columns.map(column => (
<th
key={String(column.key)}
className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="p-3 text-center text-gray-700 dark:text-gray-300"
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, index) => (
<tr key={getRowKey(row, index)} className="border-b dark:border-gray-600">
{columns.map(column => (
<td
key={`${getRowKey(row, index)}-${String(column.key)}`}
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
>
{column.render ? column.render(row[column.key], row, index) : row[column.key]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useState } from 'react';
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
export type ToastType = 'info' | 'warning' | 'error';
export interface ToastProps {
id: string;
type: ToastType;
message: string;
duration?: number;
onClose?: (id: string) => void;
}
const getToastStyles = (_type: ToastType) => {
const baseStyles =
'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
const typeStyles = {
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
};
const iconStyles = {
info: 'text-blue-600 dark:text-blue-400',
warning: 'text-yellow-600 dark:text-yellow-400',
error: 'text-red-600 dark:text-red-400',
};
const textStyles = {
info: 'text-blue-800 dark:text-blue-200',
warning: 'text-yellow-800 dark:text-yellow-200',
error: 'text-red-800 dark:text-red-200',
};
return { baseStyles, typeStyles, iconStyles, textStyles };
};
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
const handleClose = () => {
setIsAnimatingOut(true);
setTimeout(() => {
setIsVisible(false);
onClose?.(id);
}, 300);
};
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(handleClose, duration);
return () => clearTimeout(timer);
}
}, [duration]);
if (!isVisible) {
return null;
}
const icons = {
info: <InfoIcon size={20} className={iconStyles[type]} />,
warning: <WarningIcon size={20} className={iconStyles[type]} />,
error: <ErrorIcon size={20} className={iconStyles[type]} />,
};
return (
<div
className={`${baseStyles} ${typeStyles[type]} ${
isAnimatingOut ? 'translate-x-full opacity-0' : 'animate-slideInRight opacity-100'
}`}
>
{icons[type]}
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>{message}</p>
<button
onClick={handleClose}
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}
aria-label="Close"
>
<CloseIcon size={18} />
</button>
</div>
);
}

View File

@@ -1,95 +0,0 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Toast, ToastType, ToastProps } from './Toast';
interface ToastContextType {
showToast: (message: string, type?: ToastType, duration?: number) => string;
showInfo: (message: string, duration?: number) => string;
showWarning: (message: string, duration?: number) => string;
showError: (message: string, duration?: number) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const showToast = useCallback(
(message: string, _type: ToastType = 'info', _duration?: number): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
setToasts(prev => [
...prev,
{ id, type: _type, message, duration: _duration, onClose: removeToast },
]);
return id;
},
[removeToast]
);
const showInfo = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'info', _duration);
},
[showToast]
);
const showWarning = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'warning', _duration);
},
[showToast]
);
const showError = useCallback(
(message: string, _duration?: number) => {
return showToast(message, 'error', _duration);
},
[showToast]
);
const clearToasts = useCallback(() => {
setToasts([]);
}, []);
return (
<ToastContext.Provider
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
>
{children}
<ToastContainer toasts={toasts} />
</ToastContext.Provider>
);
}
interface ToastContainerProps {
toasts: (ToastProps & { id: string })[];
}
function ToastContainer({ toasts }: ToastContainerProps) {
if (toasts.length === 0) {
return null;
}
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
{toasts.map(toast => (
<div key={toast.id} className="pointer-events-auto">
<Toast {...toast} />
</div>
))}
</div>
);
}
export function useToasts() {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToasts must be used within a ToastProvider');
}
return context;
}

View File

@@ -1,22 +0,0 @@
// Reading History Graph
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
// Toast components
export { Toast } from './Toast';
export { ToastProvider, useToasts } from './ToastContext';
export type { ToastType, ToastProps } from './Toast';
// Skeleton components
export {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader,
} from './Skeleton';
// Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Activity {
document_id: string;
device_id: string;
start_time: string;
title?: string;
author?: string;
duration: number;
start_percentage: number;
end_percentage: number;
read_percentage: number;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Activity } from './activity';
export interface ActivityResponse {
activities: Activity[];
}

View File

@@ -1,15 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type BackupType = typeof BackupType[keyof typeof BackupType];
export const BackupType = {
COVERS: 'COVERS',
DOCUMENTS: 'DOCUMENTS',
} as const;

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface ConfigResponse {
version: string;
search_enabled: boolean;
registration_enabled: boolean;
}

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type CreateDocumentBody = {
document_file: Blob;
};

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface DatabaseInfo {
documents_size: number;
activity_size: number;
progress_size: number;
devices_size: number;
}

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Device {
id?: string;
device_name?: string;
created_at?: string;
last_synced?: string;
}

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface DirectoryItem {
name?: string;
path?: string;
}

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { DirectoryItem } from './directoryItem';
export interface DirectoryListResponse {
current_path?: string;
items?: DirectoryItem[];
}

View File

@@ -1,26 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface Document {
id: string;
title: string;
author: string;
description?: string;
isbn10?: string;
isbn13?: string;
created_at: string;
updated_at: string;
deleted: boolean;
words?: number;
filepath?: string;
percentage?: number;
total_time_seconds?: number;
wpm?: number;
seconds_per_percent?: number;
last_read?: string;
}

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Document } from './document';
import type { Progress } from './progress';
export interface DocumentResponse {
document: Document;
progress?: Progress;
}

View File

@@ -1,22 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { Document } from './document';
import type { UserData } from './userData';
import type { WordCount } from './wordCount';
export interface DocumentsResponse {
documents: Document[];
total: number;
page: number;
limit: number;
next_page?: number;
previous_page?: number;
search?: string;
user: UserData;
word_counts: WordCount[];
}

View File

@@ -1,16 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type EditDocumentBody = {
title?: string;
author?: string;
description?: string;
isbn10?: string;
isbn13?: string;
cover_gbid?: string;
};

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface ErrorResponse {
code: number;
message: string;
}

View File

@@ -1,14 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetActivityParams = {
doc_filter?: boolean;
document_id?: string;
offset?: number;
limit?: number;
};

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { DatabaseInfo } from './databaseInfo';
export type GetAdmin200 = {
database_info?: DatabaseInfo;
};

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetDocumentsParams = {
page?: number;
limit?: number;
search?: string;
};

View File

@@ -1,12 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetImportDirectoryParams = {
directory?: string;
select?: string;
};

View File

@@ -1,11 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetLogsParams = {
filter?: string;
};

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export type GetProgressListParams = {
page?: number;
limit?: number;
document?: string;
};

View File

@@ -1,13 +0,0 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
import type { GetSearchSource } from './getSearchSource';
export type GetSearchParams = {
query: string;
source: GetSearchSource;
};

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