Deadcode-verified removals (RTA reachability from main, cross-checked with -test and textual xrefs): Go server: - whole files: middleware/transaction.go, middleware/error_handler.go, middleware/password_validator.go (live registration machinery kept), handlers/sync.go, sync/offline.go - unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler, ReaderHandler.ShowReader, JSON-data twins on collections/library - duplicate book-matching handlers (book_matching.go Handler twins, matching.go MatchingHandler twins), superseded helpers (router.parseUUID, handlers.ValidateDeviceToken, normalizePath, userExists, generateLockID) Templ/client: - never-routed pages: api_explorer.templ, queue.templ and their client modules api-explorer.ts, api-explorer-docs.ts, queue.ts - fully orphaned wrapper modules events.ts, dom.ts - broken clearSyncQueue (called a nonexistent endpoint) and the hidden, never-populated sync-queue section on the devices page - 166+ unused exports demoted to private (knip-verified); ~85 truly dead declarations removed via tsc noUnusedLocals fixpoint - stale 'interactive API Explorer' claims dropped from docs The docs drift-gate package (internal/docs), templ-bound modules (dashboard, stats, header, index, login, profile*, bookshelf, collections, toast-error), sqlc-generated code, and web/static are intentionally untouched.
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
// MatchingHandler handles book matching, linking, and file alias operations
|
|
type MatchingHandler struct {
|
|
db *database.Queries
|
|
connManager *wsync.ConnectionManager
|
|
}
|
|
|
|
// NewMatchingHandler creates a new matching handler
|
|
func NewMatchingHandler(db *database.Queries, connManager *wsync.ConnectionManager) *MatchingHandler {
|
|
return &MatchingHandler{
|
|
db: db,
|
|
connManager: connManager,
|
|
}
|
|
}
|
|
|
|
// getMatchingService creates a new book matching service
|
|
func (mh *MatchingHandler) getMatchingService() *services.BookMatchingService {
|
|
return services.NewBookMatchingService(mh.db)
|
|
}
|
|
|
|
// QueryBooks handles POST /api/sync/books/query
|
|
func (mh *MatchingHandler) QueryBooks(c *echo.Context) error {
|
|
matchingService := mh.getMatchingService()
|
|
|
|
var req services.BookQueryRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
response, err := matchingService.QueryBooks(c.Request().Context(), &req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to query books",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// LinkBook handles POST /api/devices/:deviceId/sync/link-book
|
|
func (mh *MatchingHandler) LinkBook(c *echo.Context) error {
|
|
matchingService := mh.getMatchingService()
|
|
|
|
var req services.LinkBookRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
deviceIDStr := c.Param("deviceId")
|
|
deviceID, err := uuid.Parse(deviceIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid device ID",
|
|
})
|
|
}
|
|
|
|
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": fmt.Sprintf("Failed to link book: %v", err),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "linked",
|
|
"device_file_alias": map[string]interface{}{
|
|
"id": uuid.UUID(alias.ID.Bytes).String(),
|
|
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
|
"device_id": uuid.UUID(alias.DeviceID.Bytes).String(),
|
|
"file_path": alias.FilePath,
|
|
"file_sha256": alias.FileSha256.String,
|
|
"confidence_score": alias.ConfidenceScore.Float64,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetUnlinkedBooks handles GET /api/devices/:deviceId/sync/unlinked-books
|
|
func (mh *MatchingHandler) GetUnlinkedBooks(c *echo.Context) error {
|
|
matchingService := mh.getMatchingService()
|
|
|
|
deviceIDStr := c.Param("deviceId")
|
|
deviceID, err := uuid.Parse(deviceIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid device ID",
|
|
})
|
|
}
|
|
|
|
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get unlinked books",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"unlinked": unlinked,
|
|
"total": len(unlinked),
|
|
})
|
|
}
|
|
|
|
// GetDeviceFileAliases handles GET /api/devices/:id/file-aliases
|
|
func (mh *MatchingHandler) GetDeviceFileAliases(c *echo.Context) error {
|
|
deviceIDStr := c.Param("id")
|
|
deviceID, err := uuid.Parse(deviceIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid device ID",
|
|
})
|
|
}
|
|
|
|
aliases, err := mh.db.GetDeviceFileAliasesByDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to get file aliases",
|
|
})
|
|
}
|
|
|
|
type AliasResponse struct {
|
|
ID string `json:"id"`
|
|
MediaItemID string `json:"media_item_id"`
|
|
FilePath string `json:"file_path"`
|
|
FileSHA256 string `json:"file_sha256"`
|
|
ConfidenceScore float64 `json:"confidence_score"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
}
|
|
|
|
response := make([]AliasResponse, len(aliases))
|
|
for i, alias := range aliases {
|
|
response[i] = AliasResponse{
|
|
ID: uuid.UUID(alias.ID.Bytes).String(),
|
|
MediaItemID: uuid.UUID(alias.MediaItemID.Bytes).String(),
|
|
FilePath: alias.FilePath,
|
|
FileSHA256: alias.FileSha256.String,
|
|
ConfidenceScore: alias.ConfidenceScore.Float64,
|
|
LastSeenAt: alias.LastSeenAt.Time.String(),
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"device_id": deviceIDStr,
|
|
"aliases": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
|
|
func (mh *MatchingHandler) CreateDeviceFileAlias(c *echo.Context) error {
|
|
deviceIDStr := c.Param("id")
|
|
deviceID, err := uuid.Parse(deviceIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid device ID",
|
|
})
|
|
}
|
|
|
|
var req struct {
|
|
MediaItemID string `json:"media_item_id"`
|
|
FilePath string `json:"file_path"`
|
|
FileSHA256 string `json:"file_sha256"`
|
|
ConfidenceScore float64 `json:"confidence_score"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
mediaItemID, err := uuid.Parse(req.MediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid media item ID",
|
|
})
|
|
}
|
|
|
|
alias, err := mh.db.CreateDeviceFileAlias(c.Request().Context(), database.CreateDeviceFileAliasParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
FilePath: req.FilePath,
|
|
FileSha256: pgtype.Text{String: req.FileSHA256, Valid: req.FileSHA256 != ""},
|
|
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": fmt.Sprintf("Failed to create file alias: %v", err),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
"id": uuid.UUID(alias.ID.Bytes).String(),
|
|
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
|
"device_id": deviceIDStr,
|
|
"file_path": alias.FilePath,
|
|
"file_sha256": alias.FileSha256.String,
|
|
"confidence_score": alias.ConfidenceScore.Float64,
|
|
"last_seen_at": alias.LastSeenAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
// UpdateDeviceFileAlias handles PUT /api/devices/:id/file-aliases/:aliasId
|
|
func (mh *MatchingHandler) UpdateDeviceFileAlias(c *echo.Context) error {
|
|
aliasIDStr := c.Param("aliasId")
|
|
aliasID, err := uuid.Parse(aliasIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid alias ID",
|
|
})
|
|
}
|
|
|
|
var req struct {
|
|
MediaItemID *string `json:"media_item_id"`
|
|
FileSHA256 *string `json:"file_sha256"`
|
|
ConfidenceScore *float64 `json:"confidence_score"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
var mediaItemID pgtype.UUID
|
|
if req.MediaItemID != nil {
|
|
parsedID, err := uuid.Parse(*req.MediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid media item ID",
|
|
})
|
|
}
|
|
mediaItemID = pgtype.UUID{Bytes: parsedID, Valid: true}
|
|
}
|
|
|
|
var fileSHA256 pgtype.Text
|
|
if req.FileSHA256 != nil {
|
|
fileSHA256 = pgtype.Text{String: *req.FileSHA256, Valid: true}
|
|
}
|
|
|
|
var confidenceScore pgtype.Float8
|
|
if req.ConfidenceScore != nil {
|
|
confidenceScore = pgtype.Float8{Float64: *req.ConfidenceScore, Valid: true}
|
|
}
|
|
|
|
alias, err := mh.db.UpdateDeviceFileAlias(c.Request().Context(), database.UpdateDeviceFileAliasParams{
|
|
ID: pgtype.UUID{Bytes: aliasID, Valid: true},
|
|
MediaItemID: mediaItemID,
|
|
FileSha256: fileSHA256,
|
|
ConfidenceScore: confidenceScore,
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": fmt.Sprintf("Failed to update file alias: %v", err),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"id": uuid.UUID(alias.ID.Bytes).String(),
|
|
"media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(),
|
|
"file_path": alias.FilePath,
|
|
"file_sha256": alias.FileSha256.String,
|
|
"confidence_score": alias.ConfidenceScore.Float64,
|
|
"last_seen_at": alias.LastSeenAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
// DeleteDeviceFileAlias handles DELETE /api/devices/:id/file-aliases/:aliasId
|
|
func (mh *MatchingHandler) DeleteDeviceFileAlias(c *echo.Context) error {
|
|
aliasIDStr := c.Param("aliasId")
|
|
aliasID, err := uuid.Parse(aliasIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid alias ID",
|
|
})
|
|
}
|
|
|
|
err = mh.db.DeleteDeviceFileAlias(c.Request().Context(), pgtype.UUID{Bytes: aliasID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to delete file alias",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"message": "File alias deleted successfully",
|
|
})
|
|
}
|
|
|
|
// GetBookMatches handles GET /api/books/match
|
|
func (mh *MatchingHandler) GetBookMatches(c *echo.Context) error {
|
|
matchingService := mh.getMatchingService()
|
|
|
|
identifiers := c.QueryParams()["identifier"]
|
|
sha256 := c.QueryParam("sha256")
|
|
title := c.QueryParam("title")
|
|
author := c.QueryParam("author")
|
|
fileSizeStr := c.QueryParam("file_size")
|
|
|
|
var fileSize int64
|
|
if fileSizeStr != "" {
|
|
size, err := strconv.ParseInt(fileSizeStr, 10, 64)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid file_size parameter",
|
|
})
|
|
}
|
|
fileSize = size
|
|
}
|
|
|
|
req := &services.BookQueryRequest{
|
|
Identifiers: identifiers,
|
|
SHA256: sha256,
|
|
Title: title,
|
|
Author: author,
|
|
FileSize: fileSize,
|
|
}
|
|
|
|
response, err := matchingService.QueryBooks(c.Request().Context(), req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "Failed to query books",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|