wip 3
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,15 @@ package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/metadata"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GET /documents
|
||||
@@ -56,10 +63,13 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
wordCounts := make([]WordCount, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
apiDocuments[i] = Document{
|
||||
Id: row.ID,
|
||||
Title: *row.Title,
|
||||
Author: *row.Author,
|
||||
Words: row.Words,
|
||||
Id: row.ID,
|
||||
Title: *row.Title,
|
||||
Author: *row.Author,
|
||||
Words: row.Words,
|
||||
Filepath: row.Filepath,
|
||||
Percentage: ptrOf(float32(row.Percentage)),
|
||||
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
|
||||
}
|
||||
if row.Words != nil {
|
||||
wordCounts = append(wordCounts, WordCount{
|
||||
@@ -102,12 +112,11 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
var progress *Progress
|
||||
if err == nil {
|
||||
progress = &Progress{
|
||||
UserId: progressRow.UserID,
|
||||
DocumentId: progressRow.DocumentID,
|
||||
DeviceId: progressRow.DeviceID,
|
||||
Percentage: progressRow.Percentage,
|
||||
Progress: progressRow.Progress,
|
||||
CreatedAt: parseTime(progressRow.CreatedAt),
|
||||
UserId: &progressRow.UserID,
|
||||
DocumentId: &progressRow.DocumentID,
|
||||
DeviceName: &progressRow.DeviceName,
|
||||
Percentage: &progressRow.Percentage,
|
||||
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,3 +137,158 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
||||
}
|
||||
return GetDocument200JSONResponse(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))
|
||||
}
|
||||
|
||||
// POST /documents
|
||||
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
||||
auth, 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
||||
Deleted: existingDoc.Deleted,
|
||||
Words: existingDoc.Words,
|
||||
}
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
}
|
||||
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,
|
||||
CreatedAt: parseTime(doc.CreatedAt),
|
||||
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||
Deleted: doc.Deleted,
|
||||
Words: doc.Words,
|
||||
}
|
||||
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
}
|
||||
|
||||
return CreateDocument200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
251
api/v1/home.go
Normal file
251
api/v1/home.go
Normal file
@@ -0,0 +1,251 @@
|
||||
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),
|
||||
User: UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
},
|
||||
},
|
||||
GraphData: GraphDataResponse{
|
||||
GraphData: convertGraphData(graphData),
|
||||
User: UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
},
|
||||
},
|
||||
UserStatistics: arrangeUserStatistics(userStats),
|
||||
User: UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
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),
|
||||
User: UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
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),
|
||||
User: UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
return GetGraphData200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
// GET /home/statistics
|
||||
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
||||
auth, 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)
|
||||
response.User = UserData{
|
||||
Username: auth.UserName,
|
||||
IsAdmin: auth.IsAdmin,
|
||||
}
|
||||
|
||||
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 helper - sort by WPM
|
||||
sortByWPM := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].TotalWpm > sorted[j].TotalWpm
|
||||
})
|
||||
|
||||
result := make([]LeaderboardEntry, len(sorted))
|
||||
for i, item := range sorted {
|
||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: int64(item.TotalWpm)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Sort by duration (seconds)
|
||||
sortByDuration := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].TotalSeconds > sorted[j].TotalSeconds
|
||||
})
|
||||
|
||||
result := make([]LeaderboardEntry, len(sorted))
|
||||
for i, item := range sorted {
|
||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalSeconds}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Sort by words
|
||||
sortByWords := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].TotalWordsRead > sorted[j].TotalWordsRead
|
||||
})
|
||||
|
||||
result := make([]LeaderboardEntry, len(sorted))
|
||||
for i, item := range sorted {
|
||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalWordsRead}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return UserStatisticsResponse{
|
||||
Wpm: LeaderboardData{
|
||||
All: sortByWPM(userStatistics),
|
||||
Year: sortByWPM(userStatistics),
|
||||
Month: sortByWPM(userStatistics),
|
||||
Week: sortByWPM(userStatistics),
|
||||
},
|
||||
Duration: LeaderboardData{
|
||||
All: sortByDuration(userStatistics),
|
||||
Year: sortByDuration(userStatistics),
|
||||
Month: sortByDuration(userStatistics),
|
||||
Week: sortByDuration(userStatistics),
|
||||
},
|
||||
Words: LeaderboardData{
|
||||
All: sortByWords(userStatistics),
|
||||
Year: sortByWords(userStatistics),
|
||||
Month: sortByWords(userStatistics),
|
||||
Week: sortByWords(userStatistics),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -29,6 +29,14 @@ components:
|
||||
words:
|
||||
type: integer
|
||||
format: int64
|
||||
filepath:
|
||||
type: string
|
||||
percentage:
|
||||
type: number
|
||||
format: float
|
||||
total_time_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
@@ -63,27 +71,22 @@ components:
|
||||
Progress:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
title:
|
||||
type: string
|
||||
document_id:
|
||||
author:
|
||||
type: string
|
||||
device_id:
|
||||
device_name:
|
||||
type: string
|
||||
percentage:
|
||||
type: number
|
||||
format: double
|
||||
progress:
|
||||
document_id:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- user_id
|
||||
- document_id
|
||||
- device_id
|
||||
- percentage
|
||||
- progress
|
||||
- created_at
|
||||
|
||||
Activity:
|
||||
type: object
|
||||
@@ -106,6 +109,42 @@ components:
|
||||
- activity_type
|
||||
- timestamp
|
||||
|
||||
SearchItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
author:
|
||||
type: string
|
||||
language:
|
||||
type: string
|
||||
series:
|
||||
type: string
|
||||
file_type:
|
||||
type: string
|
||||
file_size:
|
||||
type: string
|
||||
upload_date:
|
||||
type: string
|
||||
|
||||
SearchResponse:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SearchItem'
|
||||
source:
|
||||
type: string
|
||||
query:
|
||||
type: string
|
||||
required:
|
||||
- results
|
||||
- source
|
||||
- query
|
||||
|
||||
Setting:
|
||||
type: object
|
||||
properties:
|
||||
@@ -174,8 +213,38 @@ components:
|
||||
- document
|
||||
- user
|
||||
|
||||
ProgressListResponse:
|
||||
type: object
|
||||
properties:
|
||||
progress:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
page:
|
||||
type: integer
|
||||
format: int64
|
||||
limit:
|
||||
type: integer
|
||||
format: int64
|
||||
next_page:
|
||||
type: integer
|
||||
format: int64
|
||||
previous_page:
|
||||
type: integer
|
||||
format: int64
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
ProgressResponse:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
type: object
|
||||
properties:
|
||||
progress:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
|
||||
ActivityResponse:
|
||||
type: object
|
||||
@@ -190,17 +259,31 @@ components:
|
||||
- activities
|
||||
- user
|
||||
|
||||
Device:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
last_synced:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
SettingsResponse:
|
||||
type: object
|
||||
properties:
|
||||
settings:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Setting'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
timezone:
|
||||
type: string
|
||||
devices:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Device'
|
||||
required:
|
||||
- settings
|
||||
- user
|
||||
@@ -238,6 +321,167 @@ components:
|
||||
- code
|
||||
- message
|
||||
|
||||
DatabaseInfo:
|
||||
type: object
|
||||
properties:
|
||||
documents_size:
|
||||
type: integer
|
||||
format: int64
|
||||
activity_size:
|
||||
type: integer
|
||||
format: int64
|
||||
progress_size:
|
||||
type: integer
|
||||
format: int64
|
||||
devices_size:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- documents_size
|
||||
- activity_size
|
||||
- progress_size
|
||||
- devices_size
|
||||
|
||||
UserStreak:
|
||||
type: object
|
||||
properties:
|
||||
window:
|
||||
type: string
|
||||
max_streak:
|
||||
type: integer
|
||||
format: int64
|
||||
max_streak_start_date:
|
||||
type: string
|
||||
max_streak_end_date:
|
||||
type: string
|
||||
current_streak:
|
||||
type: integer
|
||||
format: int64
|
||||
current_streak_start_date:
|
||||
type: string
|
||||
current_streak_end_date:
|
||||
type: string
|
||||
required:
|
||||
- window
|
||||
- max_streak
|
||||
- max_streak_start_date
|
||||
- max_streak_end_date
|
||||
- current_streak
|
||||
- current_streak_start_date
|
||||
- current_streak_end_date
|
||||
|
||||
StreaksResponse:
|
||||
type: object
|
||||
properties:
|
||||
streaks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UserStreak'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
required:
|
||||
- streaks
|
||||
- user
|
||||
|
||||
GraphDataPoint:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
minutes_read:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- date
|
||||
- minutes_read
|
||||
|
||||
GraphDataResponse:
|
||||
type: object
|
||||
properties:
|
||||
graph_data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GraphDataPoint'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
required:
|
||||
- graph_data
|
||||
- user
|
||||
|
||||
LeaderboardEntry:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
value:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- user_id
|
||||
- value
|
||||
|
||||
LeaderboardData:
|
||||
type: object
|
||||
properties:
|
||||
all:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LeaderboardEntry'
|
||||
year:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LeaderboardEntry'
|
||||
month:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LeaderboardEntry'
|
||||
week:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LeaderboardEntry'
|
||||
required:
|
||||
- all
|
||||
- year
|
||||
- month
|
||||
- week
|
||||
|
||||
UserStatisticsResponse:
|
||||
type: object
|
||||
properties:
|
||||
wpm:
|
||||
$ref: '#/components/schemas/LeaderboardData'
|
||||
duration:
|
||||
$ref: '#/components/schemas/LeaderboardData'
|
||||
words:
|
||||
$ref: '#/components/schemas/LeaderboardData'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
required:
|
||||
- wpm
|
||||
- duration
|
||||
- words
|
||||
- user
|
||||
|
||||
HomeResponse:
|
||||
type: object
|
||||
properties:
|
||||
database_info:
|
||||
$ref: '#/components/schemas/DatabaseInfo'
|
||||
streaks:
|
||||
$ref: '#/components/schemas/StreaksResponse'
|
||||
graph_data:
|
||||
$ref: '#/components/schemas/GraphDataResponse'
|
||||
user_statistics:
|
||||
$ref: '#/components/schemas/UserStatisticsResponse'
|
||||
user:
|
||||
$ref: '#/components/schemas/UserData'
|
||||
required:
|
||||
- database_info
|
||||
- streaks
|
||||
- graph_data
|
||||
- user_statistics
|
||||
- user
|
||||
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
@@ -288,6 +532,50 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
post:
|
||||
summary: Upload a new document
|
||||
operationId: createDocument
|
||||
tags:
|
||||
- Documents
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
document_file:
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- document_file
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DocumentResponse'
|
||||
400:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/documents/{id}:
|
||||
get:
|
||||
@@ -329,6 +617,51 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/progress:
|
||||
get:
|
||||
summary: List progress records
|
||||
operationId: getProgressList
|
||||
tags:
|
||||
- Progress
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
default: 15
|
||||
- name: document
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProgressListResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/progress/{id}:
|
||||
get:
|
||||
summary: Get document progress
|
||||
@@ -520,6 +853,207 @@ paths:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/home:
|
||||
get:
|
||||
summary: Get home page data
|
||||
operationId: getHome
|
||||
tags:
|
||||
- Home
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/home/streaks:
|
||||
get:
|
||||
summary: Get user streaks
|
||||
operationId: getStreaks
|
||||
tags:
|
||||
- Home
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StreaksResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/home/graph:
|
||||
get:
|
||||
summary: Get daily read stats graph data
|
||||
operationId: getGraphData
|
||||
tags:
|
||||
- Home
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GraphDataResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/home/statistics:
|
||||
get:
|
||||
summary: Get user statistics (leaderboards)
|
||||
operationId: getUserStatistics
|
||||
tags:
|
||||
- Home
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserStatisticsResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/search:
|
||||
get:
|
||||
summary: Search external book sources
|
||||
operationId: getSearch
|
||||
tags:
|
||||
- Search
|
||||
parameters:
|
||||
- name: query
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: source
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [LibGen, Annas Archive]
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SearchResponse'
|
||||
400:
|
||||
description: Invalid query
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Search error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
post:
|
||||
summary: Download search result
|
||||
operationId: postSearch
|
||||
tags:
|
||||
- Search
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
author:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- source
|
||||
- title
|
||||
- author
|
||||
- id
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
200:
|
||||
description: Download initiated
|
||||
401:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Download error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -2,10 +2,85 @@ 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,
|
||||
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
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)
|
||||
@@ -13,26 +88,39 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
||||
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
if request.Id == "" {
|
||||
return GetProgress404JSONResponse{Code: 404, Message: "Document ID required"}, nil
|
||||
filter := database.GetProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocFilter: true,
|
||||
DocumentID: request.Id,
|
||||
Offset: 0,
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
||||
UserID: auth.UserName,
|
||||
DocumentID: request.Id,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
response := Progress{
|
||||
UserId: progressRow.UserID,
|
||||
DocumentId: progressRow.DocumentID,
|
||||
DeviceId: progressRow.DeviceID,
|
||||
Percentage: progressRow.Percentage,
|
||||
Progress: progressRow.Progress,
|
||||
CreatedAt: parseTime(progressRow.CreatedAt),
|
||||
if len(progress) == 0 {
|
||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||
}
|
||||
return GetProgress200JSONResponse(response), 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,
|
||||
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
}
|
||||
|
||||
return GetProgress200JSONResponse(response), nil
|
||||
}
|
||||
59
api/v1/search.go
Normal file
59
api/v1/search.go
Normal file
@@ -0,0 +1,59 @@
|
||||
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
|
||||
}
|
||||
@@ -16,10 +16,25 @@ func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObje
|
||||
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{
|
||||
Settings: []Setting{},
|
||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||
Timezone: user.Timezone,
|
||||
Devices: &apiDevices,
|
||||
}
|
||||
return GetSettings200JSONResponse(response), nil
|
||||
}
|
||||
|
||||
@@ -66,4 +66,19 @@ func parseTime(s string) time.Time {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user