style: apply gofmt across Go sources
Repo-wide gofmt sweep: const-block alignment, doc-comment spacing, statement-after-brace fix in media.go, trailing blank lines. No semantic changes — whitespace-insensitive diff is empty for every file in this commit.
This commit is contained in:
1 parent
dd2ab4ea96
commit
8733e83f1f
29 files changed
+91
-1725
No files matched your search
@@ -1,154 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SyncHandler struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
func NewSyncHandler(db *database.Queries) *SyncHandler {
|
||||
return &SyncHandler{db: db}
|
||||
}
|
||||
|
||||
type UnlinkedBookResponse struct {
|
||||
Unlinked []UnlinkedBookItem `json:"unlinked"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type UnlinkedBookItem struct {
|
||||
UnlinkedBookID string `json:"unlinked_book_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
ContentId string `json:"content_id"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
ConfidenceScore float64 `json:"confidence_score"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type LinkBookRequest struct {
|
||||
UnlinkedBookID string `json:"unlinked_book_id"`
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
ConfidenceScore float64 `json:"confidence_score"`
|
||||
}
|
||||
|
||||
type LinkBookResponse struct {
|
||||
Status string `json:"status"`
|
||||
UnlinkedBookID string `json:"unlinked_book_id"`
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetUnlinkedBooks returns all unlinked books for a user's devices
|
||||
// GET /api/sync/unlinked-books
|
||||
func (h *SyncHandler) GetUnlinkedBooks(c *echo.Context) error {
|
||||
// Get user from context (assuming auth middleware sets this)
|
||||
user := c.Get("user")
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
}
|
||||
|
||||
userID := user.(database.Users).ID
|
||||
|
||||
// Get all devices for this user
|
||||
devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgtype.UUID{Bytes: userID.Bytes, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to fetch devices",
|
||||
})
|
||||
}
|
||||
|
||||
var allUnlinkedBooks []UnlinkedBookItem
|
||||
|
||||
// For each device, get unlinked books
|
||||
for _, device := range devices {
|
||||
unlinkedBooks, err := h.db.GetUnlinkedBooksByDevice(c.Request().Context(), pgtype.UUID{Bytes: device.ID.Bytes, Valid: true})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ub := range unlinkedBooks {
|
||||
allUnlinkedBooks = append(allUnlinkedBooks, UnlinkedBookItem{
|
||||
UnlinkedBookID: uuid.UUID(ub.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(ub.DeviceID.Bytes).String(),
|
||||
DeviceName: ub.DeviceName,
|
||||
DeviceType: ub.DeviceType,
|
||||
ContentId: ub.ContentID,
|
||||
FilePath: ub.FilePath.String,
|
||||
Title: ub.Title.String,
|
||||
Author: ub.Author.String,
|
||||
ConfidenceScore: ub.ConfidenceScore.Float64,
|
||||
LastSeenAt: ub.LastSeenAt.Time,
|
||||
CreatedAt: ub.CreatedAt.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, UnlinkedBookResponse{
|
||||
Unlinked: allUnlinkedBooks,
|
||||
Total: len(allUnlinkedBooks),
|
||||
})
|
||||
}
|
||||
|
||||
// LinkBook manually links an unlinked book to a media item
|
||||
// POST /api/sync/link-book
|
||||
func (h *SyncHandler) LinkBook(c *echo.Context) error {
|
||||
var req LinkBookRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse UUIDs
|
||||
unlinkedBookID, err := uuid.Parse(req.UnlinkedBookID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid unlinked_book_id",
|
||||
})
|
||||
}
|
||||
|
||||
mediaItemID, err := uuid.Parse(req.MediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid media_item_id",
|
||||
})
|
||||
}
|
||||
|
||||
// Get the unlinked books to find the one we're looking for
|
||||
// We need to use GetAllUnlinkedBooks or create a new query
|
||||
// For now, let's link directly using LinkUnlinkedBook
|
||||
_, err = h.db.LinkUnlinkedBook(c.Request().Context(), database.LinkUnlinkedBookParams{
|
||||
ID: pgtype.UUID{Bytes: unlinkedBookID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to link book",
|
||||
})
|
||||
}
|
||||
|
||||
// Note: We can't create a device catalog entry without knowing the device_id and content_id
|
||||
// In a real implementation, we'd first fetch the unlinked book, then create the catalog entry
|
||||
|
||||
return c.JSON(http.StatusOK, LinkBookResponse{
|
||||
Status: "linked",
|
||||
UnlinkedBookID: req.UnlinkedBookID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
Message: "Book successfully linked",
|
||||
})
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// ErrorResponse represents a standardized error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPError represents an HTTP error with additional context
|
||||
type HTTPError struct {
|
||||
Code int
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *HTTPError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Err)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Common error types
|
||||
var (
|
||||
ErrBadRequest = &HTTPError{Code: http.StatusBadRequest, Message: "Bad request"}
|
||||
ErrUnauthorized = &HTTPError{Code: http.StatusUnauthorized, Message: "Unauthorized"}
|
||||
ErrForbidden = &HTTPError{Code: http.StatusForbidden, Message: "Forbidden"}
|
||||
ErrNotFound = &HTTPError{Code: http.StatusNotFound, Message: "Resource not found"}
|
||||
ErrConflict = &HTTPError{Code: http.StatusConflict, Message: "Resource conflict"}
|
||||
ErrTooManyRequest = &HTTPError{Code: http.StatusTooManyRequests, Message: "Too many requests"}
|
||||
ErrInternal = &HTTPError{Code: http.StatusInternalServerError, Message: "Internal server error"}
|
||||
)
|
||||
|
||||
// NewHTTPError creates a new HTTP error
|
||||
func NewHTTPError(code int, message string, err error) *HTTPError {
|
||||
return &HTTPError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// RespondWithError sends a standardized error response
|
||||
func RespondWithError(c *echo.Context, code int, message string, err error) error {
|
||||
response := ErrorResponse{
|
||||
Error: message,
|
||||
Message: "",
|
||||
Code: "",
|
||||
}
|
||||
|
||||
// Include original error message in development mode if available
|
||||
if err != nil {
|
||||
response.Message = err.Error()
|
||||
}
|
||||
|
||||
return c.JSON(code, response)
|
||||
}
|
||||
|
||||
// RespondWithHTTPError sends an HTTPError as JSON
|
||||
func RespondWithHTTPError(c *echo.Context, httpErr *HTTPError) error {
|
||||
response := ErrorResponse{
|
||||
Error: httpErr.Message,
|
||||
}
|
||||
|
||||
if httpErr.Err != nil {
|
||||
response.Message = httpErr.Err.Error()
|
||||
}
|
||||
|
||||
return c.JSON(httpErr.Code, response)
|
||||
}
|
||||
|
||||
// WrapHandler wraps an echo.HandlerFunc to return standardized errors
|
||||
func WrapHandler(fn func(*echo.Context) error) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
err := fn(c)
|
||||
if err != nil {
|
||||
if httpErr, ok := errors.AsType[*HTTPError](err); ok {
|
||||
return RespondWithHTTPError(c, httpErr)
|
||||
}
|
||||
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,53 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TransactionManager handles database transactions
|
||||
type TransactionManager struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewTransactionManager creates a new transaction manager
|
||||
func NewTransactionManager(pool *pgxpool.Pool) *TransactionManager {
|
||||
return &TransactionManager{pool: pool}
|
||||
}
|
||||
|
||||
// WithTx runs a function within a database transaction
|
||||
// If the function returns an error, the transaction is rolled back
|
||||
// If the function succeeds, the transaction is committed
|
||||
func (tm *TransactionManager) WithTx(ctx context.Context, fn func(pgx.Tx) error) error {
|
||||
tx, err := tm.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the transaction is handled properly
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
// A panic occurred, rollback and re-panic
|
||||
_ = tx.Rollback(ctx)
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
// Function returned error, rollback
|
||||
if rbErr := tx.Rollback(ctx); rbErr != nil {
|
||||
return fmt.Errorf("function error: %v, rollback error: %w", err, rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Success, commit
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -19,9 +19,9 @@ type BookMatch struct {
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchMethod string `json:"match_method"`
|
||||
// Display fields, filled during the family-lens pass in QueryBooks.
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
// BookQueryRequest represents a book query from a device
|
||||
|
||||
@@ -61,10 +61,10 @@ const (
|
||||
KindBool FilterKind = "bool"
|
||||
KindNumber FilterKind = "number"
|
||||
KindDate FilterKind = "date"
|
||||
KindPresence FilterKind = "presence" // has / missing
|
||||
KindPresence FilterKind = "presence" // has / missing
|
||||
KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
|
||||
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
|
||||
KindStatus FilterKind = "status" // unread / reading / finished
|
||||
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
|
||||
KindStatus FilterKind = "status" // unread / reading / finished
|
||||
KindLibrary FilterKind = "library"
|
||||
KindCollection FilterKind = "collection"
|
||||
)
|
||||
@@ -81,13 +81,13 @@ const (
|
||||
suggestContrib SuggestSource = "contributor"
|
||||
suggestPublisher SuggestSource = "publisher"
|
||||
suggestTags SuggestSource = "tags"
|
||||
suggestColumn SuggestSource = "column" // the field's own scalar column
|
||||
suggestSeries SuggestSource = "series" // primary + alternate_info
|
||||
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
|
||||
suggestColumn SuggestSource = "column" // the field's own scalar column
|
||||
suggestSeries SuggestSource = "series" // primary + alternate_info
|
||||
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
|
||||
suggestHighlight SuggestSource = "highlight_text"
|
||||
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
||||
suggestCollection SuggestSource = "collection" // the viewer's collections
|
||||
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
||||
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
||||
suggestCollection SuggestSource = "collection" // the viewer's collections
|
||||
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
||||
)
|
||||
|
||||
// Filter menu groups (display order for the field menu).
|
||||
@@ -127,12 +127,12 @@ type FilterChild struct {
|
||||
|
||||
// FilterField is one registry entry.
|
||||
type FilterField struct {
|
||||
Key string // wire name (the qf "field" value)
|
||||
Label string // menu label
|
||||
Group string // menu group
|
||||
Key string // wire name (the qf "field" value)
|
||||
Label string // menu label
|
||||
Group string // menu group
|
||||
Kind FilterKind
|
||||
Ops []string // allowed operators, display order
|
||||
DefaultOp string // operator a new rule starts on (field-switch reset)
|
||||
Ops []string // allowed operators, display order
|
||||
DefaultOp string // operator a new rule starts on (field-switch reset)
|
||||
Clause func(r FilterRule, b *filterBuilder) string
|
||||
Suggest SuggestSource
|
||||
}
|
||||
@@ -146,7 +146,7 @@ var opsNoValue = map[string]bool{
|
||||
// fixedDomains are the operator value domains that are STATIC (validated
|
||||
// here) rather than data-driven suggestions.
|
||||
var fixedDomains = map[string][]string{
|
||||
"status": {"unread", "reading", "finished"},
|
||||
"status": {"unread", "reading", "finished"},
|
||||
"file_availability": {"available", "missing"},
|
||||
}
|
||||
|
||||
|
||||
@@ -538,34 +538,34 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
// single-file path — folder-of-tracks books carry the same naming.
|
||||
applyTitleDerivedSeries(metadata)
|
||||
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
FilePath: relPath,
|
||||
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
||||
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
FilePath: relPath,
|
||||
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
||||
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: utils.NormalizeTags(metadata.Tags),
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: modTime,
|
||||
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: utils.NormalizeTags(metadata.Tags),
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: modTime,
|
||||
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
})
|
||||
if err != nil {
|
||||
// Ledger (Phase 11): row-less import failure keyed by
|
||||
|
||||
@@ -18,10 +18,10 @@ func TestManifestEvNormalization(t *testing.T) {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown
|
||||
{`{"v":1,"ev":0}`, 1}, // explicitly unset → same
|
||||
{`{"v":1,"ev":1}`, 1}, // baseline parser stamp
|
||||
{`{"v":1,"ev":2}`, 2}, // post-bump stamp
|
||||
{`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown
|
||||
{`{"v":1,"ev":0}`, 1}, // explicitly unset → same
|
||||
{`{"v":1,"ev":1}`, 1}, // baseline parser stamp
|
||||
{`{"v":1,"ev":2}`, 2}, // post-bump stamp
|
||||
{`{"v":1,"entries":[],"ev":3}`, 3}, // sidecar-less ev home
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
|
||||
// SeriesCandidate is one parsed series membership from any source.
|
||||
type SeriesCandidate struct {
|
||||
Name string
|
||||
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
|
||||
Number float64 // leading number of Sequence (0 = none parsed)
|
||||
HasNum bool
|
||||
Name string
|
||||
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
|
||||
Number float64 // leading number of Sequence (0 = none parsed)
|
||||
HasNum bool
|
||||
FromSidecar bool
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ var (
|
||||
cacheMu sync.RWMutex
|
||||
cacheComplete bool = true
|
||||
cacheExpiry time.Time
|
||||
cacheTTL = 10 * time.Second
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||
|
||||
@@ -167,12 +167,12 @@ func bookCharOffset(conv *CFIConverter, spineIndex int, node *html.Node, runeOff
|
||||
// standard CFI: the anchor block's cssSelector, the character offset
|
||||
// within that block's text, and the spine document's href.
|
||||
type ProgressAnchor struct {
|
||||
CSSSelector string
|
||||
CharOffset int
|
||||
Href string
|
||||
HealedCFI string
|
||||
Healed bool
|
||||
HealedPct *float64
|
||||
CSSSelector string
|
||||
CharOffset int
|
||||
Href string
|
||||
HealedCFI string
|
||||
Healed bool
|
||||
HealedPct *float64
|
||||
}
|
||||
|
||||
// VerifyProgressAnchor resolves a client-submitted standard CFI against
|
||||
|
||||
@@ -108,12 +108,12 @@ func createTestEPUB(t *testing.T, dir, name, ch1Content, ch2Content string) stri
|
||||
w := zip.NewWriter(f)
|
||||
|
||||
files := map[string]string{
|
||||
"mimetype": "application/epub+zip",
|
||||
"mimetype": "application/epub+zip",
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/content.opf": contentOPF,
|
||||
"OEBPS/nav.xhtml": navXHTML,
|
||||
"OEBPS/chapter1.xhtml": ch1Content,
|
||||
"OEBPS/chapter2.xhtml": ch2Content,
|
||||
"OEBPS/content.opf": contentOPF,
|
||||
"OEBPS/nav.xhtml": navXHTML,
|
||||
"OEBPS/chapter1.xhtml": ch1Content,
|
||||
"OEBPS/chapter2.xhtml": ch2Content,
|
||||
}
|
||||
|
||||
mimetype, _ := w.Create("mimetype")
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const (
|
||||
OnlineThreshold = 5 * time.Minute
|
||||
OfflineCheckInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
type OfflineDetector struct {
|
||||
db *database.Queries
|
||||
queue *SyncQueueProcessor
|
||||
checkInterval time.Duration
|
||||
onlineThreshold time.Duration
|
||||
}
|
||||
|
||||
type DeviceOnlineStatus struct {
|
||||
DeviceID pgtype.UUID
|
||||
DeviceName string
|
||||
DeviceType string
|
||||
IsOnline bool
|
||||
LastSeen time.Time
|
||||
UserID pgtype.UUID
|
||||
}
|
||||
|
||||
func NewOfflineDetector(db *database.Queries, queue *SyncQueueProcessor) *OfflineDetector {
|
||||
return &OfflineDetector{
|
||||
db: db,
|
||||
queue: queue,
|
||||
checkInterval: OfflineCheckInterval,
|
||||
onlineThreshold: OnlineThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) Start(ctx context.Context) {
|
||||
log.Printf("Starting offline detector (threshold: %v)", d.onlineThreshold)
|
||||
|
||||
ticker := time.NewTicker(d.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Offline detector stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.checkDeviceStatus(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) checkDeviceStatus(ctx context.Context) {
|
||||
users, err := d.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list users for offline check: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
if !device.SyncEnabled.Bool {
|
||||
continue
|
||||
}
|
||||
|
||||
status := d.getDeviceStatus(device)
|
||||
|
||||
if !status.IsOnline {
|
||||
d.handleOfflineDevice(ctx, status)
|
||||
} else if d.wasOffline(ctx, status.DeviceID) {
|
||||
d.handleReconnectedDevice(ctx, status)
|
||||
}
|
||||
|
||||
d.updateDeviceStatus(ctx, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) getDeviceStatus(device database.Devices) DeviceOnlineStatus {
|
||||
lastSeen := time.Now()
|
||||
if device.LastSeen.Valid {
|
||||
lastSeen = device.LastSeen.Time
|
||||
}
|
||||
|
||||
isOnline := time.Since(lastSeen) < d.onlineThreshold
|
||||
|
||||
return DeviceOnlineStatus{
|
||||
DeviceID: device.ID,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceType: device.DeviceType,
|
||||
IsOnline: isOnline,
|
||||
LastSeen: lastSeen,
|
||||
UserID: device.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) wasOffline(ctx context.Context, deviceID pgtype.UUID) bool {
|
||||
device, err := d.db.GetDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !device.LastSeen.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
timeSinceLastSeen := time.Since(device.LastSeen.Time)
|
||||
return timeSinceLastSeen >= d.onlineThreshold && timeSinceLastSeen < d.onlineThreshold*2
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) handleOfflineDevice(ctx context.Context, status DeviceOnlineStatus) {
|
||||
log.Printf("Device offline detected: %s (%s)", status.DeviceName, status.DeviceType)
|
||||
|
||||
d.setOfflineMode(ctx, status.DeviceID)
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) handleReconnectedDevice(ctx context.Context, status DeviceOnlineStatus) {
|
||||
log.Printf("Device reconnected: %s (%s)", status.DeviceName, status.DeviceType)
|
||||
|
||||
d.setOnlineMode(ctx, status.DeviceID)
|
||||
d.processQueuedItems(ctx, status.DeviceID)
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) setOfflineMode(ctx context.Context, deviceID pgtype.UUID) {
|
||||
// Targeted flip: the full-row UpdateDevice used here zeroed the
|
||||
// device name, auto_sync, frequency, and metadata on every
|
||||
// transition (pg zero values are not "leave alone").
|
||||
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
|
||||
ID: deviceID,
|
||||
SyncEnabled: pgtype.Bool{Bool: false, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to set offline mode for device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) setOnlineMode(ctx context.Context, deviceID pgtype.UUID) {
|
||||
// Targeted flip: the full-row UpdateDevice used here zeroed the
|
||||
// device name, auto_sync, frequency, and metadata on every
|
||||
// transition (pg zero values are not "leave alone").
|
||||
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
|
||||
ID: deviceID,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to set online mode for device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) updateDeviceStatus(ctx context.Context, status DeviceOnlineStatus) {
|
||||
_, err := d.db.UpdateDeviceLastSeen(ctx, status.DeviceID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update device last seen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) processQueuedItems(ctx context.Context, deviceID pgtype.UUID) {
|
||||
log.Printf("Processing queued items for reconnected device %s", deviceID.Bytes)
|
||||
|
||||
items, err := d.db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
|
||||
DeviceID: deviceID,
|
||||
RowLimit: 50,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to list queued items for device: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d queued items for device", len(items))
|
||||
|
||||
priorityItems := make([]database.SyncQueue, 0)
|
||||
for _, item := range items {
|
||||
if item.Priority.Int32 <= PriorityBookCompletion {
|
||||
priorityItems = append(priorityItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(priorityItems) > 0 {
|
||||
log.Printf("Processing %d priority items for device", len(priorityItems))
|
||||
|
||||
for _, item := range priorityItems {
|
||||
_, err := d.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
|
||||
ID: item.ID,
|
||||
Status: pgtype.Text{String: "pending", Valid: true},
|
||||
ErrorMessage: pgtype.Text{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to re-priority queue item: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) ForceReconnectDevice(ctx context.Context, deviceID pgtype.UUID) error {
|
||||
device, err := d.db.GetDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status := d.getDeviceStatus(device)
|
||||
status.IsOnline = true
|
||||
status.LastSeen = time.Now()
|
||||
|
||||
d.handleReconnectedDevice(ctx, status)
|
||||
d.updateDeviceStatus(ctx, status)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) GetDeviceStatus(ctx context.Context, deviceID pgtype.UUID) (*DeviceOnlineStatus, error) {
|
||||
device, err := d.db.GetDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceStatus := d.getDeviceStatus(device)
|
||||
return &deviceStatus, nil
|
||||
}
|
||||
|
||||
func (d *OfflineDetector) ListOfflineDevices(ctx context.Context) ([]DeviceOnlineStatus, error) {
|
||||
users, err := d.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var offlineDevices []DeviceOnlineStatus
|
||||
for _, user := range users {
|
||||
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
status := d.getDeviceStatus(device)
|
||||
if !status.IsOnline {
|
||||
offlineDevices = append(offlineDevices, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offlineDevices, nil
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOfflineDetector_ConstantValues(t *testing.T) {
|
||||
assert.Equal(t, 5*time.Minute, OnlineThreshold)
|
||||
assert.Equal(t, 2*time.Minute, OfflineCheckInterval)
|
||||
}
|
||||
Reference in new issue
Block a user