This commit is contained in:
2026-03-15 21:01:29 -04:00
parent d40f8fc375
commit 4306d86080
73 changed files with 13106 additions and 63 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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)
}

View File

@@ -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:

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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
}