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:
John O'Keefe committed 2026-10-25 11:05:37 -04:00
1 parent dd2ab4ea96
commit 8733e83f1f
29 files changed
+91 -1725

No files matched your search

-154
View File
@@ -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",
})
}
-94
View File
@@ -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.
-53
View File
@@ -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
}
+3 -3
View File
@@ -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
+15 -15
View File
@@ -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
+4 -4
View File
@@ -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 {
+4 -4
View File
@@ -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
}
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+5 -5
View File
@@ -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")
-257
View File
@@ -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
}
-13
View File
@@ -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)
}