refactor: remove dead code across server, templ pages, and client modules

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.
This commit is contained in:
John O'Keefe committed 2026-10-25 11:06:08 -04:00
1 parent 8733e83f1f
commit 8631aa00bf
107 files changed
+689 -3443

No files matched your search

-94
View File
@@ -14,7 +14,6 @@ import (
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -837,31 +836,6 @@ func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
})
}
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
func normalizePath(path string) string {
fmt.Printf("normalizePath input: '%s'\n", path)
var cleaned string
// Handle home directory expansion (~)
if strings.HasPrefix(path, "~/") {
// Keep the original path for ~ to preserve user's formatting
// Just normalize separators and that's it
cleaned = strings.ReplaceAll(path, "\\", "/")
} else {
// Clean the path to remove redundant separators, ., .. etc.
cleaned = filepath.Clean(path)
// Convert to consistent path separators (use forward slashes for storage)
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
// Remove trailing slash unless it's root path
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
cleaned = strings.TrimSuffix(cleaned, "/")
}
}
fmt.Printf("normalizePath output: '%s'\n", cleaned)
return cleaned
}
type UpdateThemeRequest struct {
Theme string `json:"theme" validate:"required"`
}
@@ -889,74 +863,6 @@ func (h *AuthHandler) UpdateTheme(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
}
type UpdateUsernameRequest struct {
Username string `json:"username" validate:"required,min=3,max=50"`
}
// UpdateUsername handles PUT /api/auth/username
func (h *AuthHandler) UpdateUsername(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req UpdateUsernameRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if username is already taken by another user
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
}
// Update username
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
ID: user.ID,
Username: req.Username,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
}
type UpdateEmailRequest struct {
Email string `json:"email" validate:"required,email"`
}
// UpdateEmail handles PUT /api/auth/email
func (h *AuthHandler) UpdateEmail(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req UpdateEmailRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Check if email is already taken by another user
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
}
// Update email
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
ID: user.ID,
Email: req.Email,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
}
type UpdatePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
-407
View File
@@ -3,9 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -39,201 +37,6 @@ func toFloat8(f float64) pgtype.Float8 {
return result
}
// QueryBooks handles POST /api/sync/books/query
func (h *Handler) QueryBooks(c *echo.Context) error {
matchingService := h.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/sync/link-book
func (h *Handler) LinkBook(c *echo.Context) error {
matchingService := h.getMatchingService()
var req services.LinkBookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
// Get device ID from context (set by auth middleware)
deviceIDStr := c.Param("deviceId")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid device ID",
})
}
// Ownership: the device must belong to the caller.
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
// Family-lens: the link target must sit in a library the caller can
// see — a hidden library's book can never be linked.
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
if itemErr != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Media item not found",
})
}
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Book is not available to you",
})
}
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/sync/unlinked-books
func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
matchingService := h.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",
})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "user not authenticated",
})
}
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
UserID: user.ID,
})
if devErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to verify device",
})
}
if !deviceOwned {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "Device not found",
})
}
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 (h *Handler) 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 := h.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",
})
}
// Convert to response format
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),
})
}
// BulkLinkBooks handles POST /api/sync/bulk-link-books
func (h *Handler) BulkLinkBooks(c *echo.Context) error {
ctx := c.Request().Context()
@@ -427,213 +230,3 @@ func (h *Handler) GetUnlinkedBookSuggestions(c *echo.Context) error {
"action": matches.Action,
})
}
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
func (h *Handler) 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 := h.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 (h *Handler) 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",
})
}
// Build update parameters
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 := h.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 (h *Handler) 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 = h.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",
})
}
// visibleLibrarySetFor resolves the requesting user's visible-library set
// (family-lens) from the request context identity.
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
}
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
c.Request().Context(), user.ID)
return set, err
}
// GetBookMatches handles GET /api/books/match
func (h *Handler) GetBookMatches(c *echo.Context) error {
matchingService := h.getMatchingService()
// Family-lens: restrict matches to libraries the requester can see
// (absent-from-results — a hidden library's book simply never matches).
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to resolve visible libraries",
})
}
visibleIDs := make([]string, 0, len(visibleSet))
for id := range visibleSet {
visibleIDs = append(visibleIDs, id)
}
// Get query parameters
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,
VisibleLibraryIDs: visibleIDs,
}
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)
}
-10
View File
@@ -814,16 +814,6 @@ func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
}
func (h *CollectionHandler) GetDeviceMappingsData(c *echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
}
func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database.Collections, error) {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
}
// userVisibleLibraryIDs loads the requester's visible-library set (the
// family lens). Smart-collection evaluation funnels every book candidate
// through it so hidden libraries contribute nothing.
-5
View File
@@ -746,8 +746,3 @@ func generateDeviceToken() (string, error) {
return fmt.Sprintf("dev_%s", token), nil
}
func ValidateDeviceToken(hashedToken string, plainToken string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedToken), []byte(plainToken))
return err == nil
}
-21
View File
@@ -200,24 +200,3 @@ func TestDeviceUpdateRequest_Validation(t *testing.T) {
})
}
}
func TestValidateDeviceToken(t *testing.T) {
hashedToken := []byte("$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi")
plainToken := "password"
result := ValidateDeviceToken(string(hashedToken), plainToken)
assert.True(t, result, "Valid token should return true")
}
func TestValidateDeviceToken_Invalid(t *testing.T) {
hashedToken := []byte("$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi")
plainToken := "wrongpassword"
result := ValidateDeviceToken(string(hashedToken), plainToken)
assert.False(t, result, "Invalid token should return false")
}
func TestValidateDeviceToken_Empty(t *testing.T) {
result := ValidateDeviceToken("", "anypassword")
assert.False(t, result, "Empty hash should return false")
}
-4
View File
@@ -389,10 +389,6 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
})
}
func (h *KoboHandler) LibrarySync(c *echo.Context) error {
return h.Initialization(c)
}
func (h *KoboHandler) Markup(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
+2 -1
View File
@@ -541,7 +541,8 @@ func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUI
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
ctx := c.Request().Context()
// Priority 1: UUID provided (highest confidence - 1.0)
if book.UUID != "" {
-10
View File
@@ -323,16 +323,6 @@ func parseUUID(uuidStr string) (pgtype.UUID, error) {
return pgtype.UUID{Bytes: parsedUUID, Valid: true}, nil
}
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
func (h *LibraryHandler) GetUserVisibleLibrariesData(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
return h.libraryService.GetUserVisibleLibraries(ctx, userID)
}
// GetLibraryTypeData returns all library types for SSR (not JSON response)
func (h *LibraryHandler) GetLibraryTypeData(ctx context.Context) ([]database.LibraryTypes, error) {
return h.libraryService.GetLibraryTypes(ctx)
}
// ListLibrariesData returns all libraries for SSR (admin only, not JSON response)
func (h *LibraryHandler) ListLibrariesData(ctx context.Context) ([]database.ListLibrariesRow, error) {
return h.libraryService.ListLibraries(ctx)
-194
View File
@@ -343,197 +343,3 @@ func (mh *MatchingHandler) GetBookMatches(c *echo.Context) error {
return c.JSON(http.StatusOK, response)
}
// BulkLinkBooks handles POST /api/sync/bulk-link-books
func (mh *MatchingHandler) BulkLinkBooks(c *echo.Context) error {
ctx := c.Request().Context()
var req BulkLinkBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
results := make([]map[string]interface{}, 0, len(req.Links))
for _, link := range req.Links {
unlinkedBook, err := mh.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "error",
"error": "Unlinked book not found",
})
continue
}
_, err = mh.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
DeviceID: unlinkedBook.DeviceID,
FilePath: unlinkedBook.FilePath.String,
FileSha256: pgtype.Text{String: "", Valid: false},
ConfidenceScore: toFloat8(link.ConfidenceScore),
})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "error",
"error": err.Error(),
})
continue
}
_, err = mh.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
ID: pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true},
ConfidenceScore: toFloat8(link.ConfidenceScore),
})
if err != nil {
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "warning",
"error": "Linked but failed to mark as resolved",
})
continue
}
results = append(results, map[string]interface{}{
"unlinked_book_id": link.UnlinkedBookID.String(),
"status": "success",
"media_item_id": link.MediaItemID.String(),
})
}
successfulCount := 0
for _, r := range results {
if r["status"] == "success" {
successfulCount++
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(req.Links),
"successful": successfulCount,
"failed": len(req.Links) - successfulCount,
})
}
// AutoLinkBooks handles POST /api/sync/auto-link-books
func (mh *MatchingHandler) AutoLinkBooks(c *echo.Context) error {
ctx := c.Request().Context()
matchingService := mh.getMatchingService()
var req AutoLinkBooksRequest
if err := c.Bind(&req); err != nil {
req.ConfidenceThreshold = 0.8
req.Limit = 50
}
if req.ConfidenceThreshold == 0 {
req.ConfidenceThreshold = 0.8
}
if req.Limit == 0 {
req.Limit = 50
}
offset := 0
unlinked, err := mh.db.ListUnresolvedUnlinkedBooks(ctx, database.ListUnresolvedUnlinkedBooksParams{
Limit: int32(req.Limit),
Offset: int32(offset),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get unlinked books",
})
}
results := make([]map[string]interface{}, 0)
for _, book := range unlinked {
queryReq := &services.BookQueryRequest{
Title: book.Title.String,
}
match, err := matchingService.QueryBooks(ctx, queryReq)
if err != nil {
continue
}
if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold {
bestMatch := match.Matches[0]
_, err := mh.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
DeviceID: book.DeviceID,
FilePath: book.FilePath.String,
FileSha256: pgtype.Text{String: "", Valid: false},
ConfidenceScore: toFloat8(bestMatch.Confidence),
})
if err == nil {
mh.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{
ID: book.ID,
MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true},
ConfidenceScore: toFloat8(bestMatch.Confidence),
})
results = append(results, map[string]interface{}{
"unlinked_book_id": uuid.UUID(book.ID.Bytes).String(),
"title": book.Title.String,
"matched_media_item_id": bestMatch.BookhoardUUID.String(),
"confidence": bestMatch.Confidence,
"match_method": bestMatch.MatchMethod,
})
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"auto_linked": len(results),
"results": results,
})
}
// GetUnlinkedBookSuggestions handles GET /api/sync/unlinked-books/:id/suggestions
func (mh *MatchingHandler) GetUnlinkedBookSuggestions(c *echo.Context) error {
ctx := c.Request().Context()
matchingService := mh.getMatchingService()
unlinkedBookID := c.Param("id")
unlinkedUUID, err := uuid.Parse(unlinkedBookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid unlinked book ID",
})
}
unlinked, err := mh.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: unlinkedUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Unlinked book not found",
})
}
matches, err := matchingService.QueryBooks(ctx, &services.BookQueryRequest{
Title: unlinked.Title.String,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to query matches",
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"unlinked_book_id": unlinkedBookID,
"title_from_device": unlinked.Title.String,
"sha256": "",
"suggestions": matches.Matches,
"total_suggestions": len(matches.Matches),
"action": matches.Action,
})
}
-56
View File
@@ -5,8 +5,6 @@ import (
"bookhoard/internal/opds"
"bookhoard/internal/services"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
@@ -1219,57 +1217,3 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
"formats": formatList,
})
}
// RegisterOPDS registers a device for OPDS access
func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
deviceID := c.Param("deviceId")
// Parse device ID
deviceUUID, err := uuid.Parse(deviceID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
}
// Verify device exists
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
}
// Get base URLs
_, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get config"})
}
// Generate OPDS token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
tokenBytes = []byte(deviceID + "-" + uuid.New().String())
}
token := hex.EncodeToString(tokenBytes)[:64]
// Create OPDS token record (valid for 1 year)
expiresAt := time.Now().AddDate(1, 0, 0)
_, err = h.db.CreateOpdsToken(c.Request().Context(), database.CreateOpdsTokenParams{
DeviceID: pgtype.UUID{Bytes: deviceUUID, Valid: true},
Token: token,
TokenType: pgtype.Text{String: "device", Valid: true},
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
}
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
return c.JSON(http.StatusOK, map[string]interface{}{
"opds_token": map[string]interface{}{
"token": token,
"token_type": "device",
"expires_at": expiresAt.Format(time.RFC3339),
},
"opds_catalog_url": catalogURL,
"refresh_interval": 3600,
})
}
-86
View File
@@ -3,7 +3,6 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
@@ -54,91 +53,6 @@ type ReaderMetadata struct {
ChapterCount int `json:"chapter_count"`
}
// ShowReader renders the reader page (SSR)
func (h *ReaderHandler) ShowReader(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
}
// Get user from context (set by JWT middleware)
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
}
userData := user.(database.Users)
// Family-lens gate (H4.5 canonical resolver): the media item's
// library must be in the requester's visible set — 403, never a
// silent pass.
visibleSet, _, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), userData.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
}
visible := visibleSet[uuid.UUID(mediaItem.LibraryID.Bytes).String()]
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
// Get library with type information for panel detection decision
libraryWithType, err := h.db.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get library info"})
}
// Determine if panel detection should be enabled
// Same comic-likeness predicate as treatAsComic below — one function
// on purpose (see ShouldTreatAsComic's doc).
enablePanelDetection := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
// Get reading progress
var progress database.ReadingProgress
progress, err = h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: userData.ID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
progress = database.ReadingProgress{}
}
// Get bookmarks
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: userData.ID,
})
// Return JSON response instead of rendering template
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"title": mediaItem.Title,
"author": utils.PgTextString(mediaItem.Author),
"cover_image_path": utils.PgTextString(mediaItem.CoverImagePath),
"library_type": mediaItem.FormatGroup,
"mime_type": utils.PgTextString(mediaItem.MimeType),
"file_path": mediaItem.FilePath,
"total_pages": mediaItem.PageCount,
"chapter_count": mediaItem.ChapterCount,
"progress": progress,
"bookmarks": bookmarks,
"enable_panel_detection": enablePanelDetection,
"treat_as_comic": treatAsComic,
"format_group": mediaItem.FormatGroup,
"manga_type": mediaItem.MangaType,
"reading_direction": mediaItem.ReadingDirection,
})
}
// GetPage returns a specific page for lazy loading
func (h *ReaderHandler) GetPage(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
-23
View File
@@ -184,29 +184,6 @@ type ScanSettingsResponse struct {
Message string `json:"message,omitempty"`
}
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
h.reload(c)
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
var req UpdateScanSettingsRequest
if err := c.Bind(&req); err != nil {