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

+1 -1
View File
@@ -98,7 +98,7 @@ func TestAudioPlaybackSpeed(t *testing.T) {
defer closer() defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct { var data struct {
PlaybackSpeed float64 `json:"playback_speed"` PlaybackSpeed float64 `json:"playback_speed"`
DefaultPlaybackSpeed float64 `json:"default_playback_speed"` DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
} }
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
+1 -1
View File
@@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)), Title: "Preview Test Book " + string(rune('A'+i)),
Authors: []string{"Test Author " + string(rune('A'+i))}, Authors: []string{"Test Author " + string(rune('A'+i))},
Genre: pgtype.Text{String: genre, Valid: true}, Genre: pgtype.Text{String: genre, Valid: true},
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub", FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+1 -1
View File
@@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title, Title: title,
Authors: []string{"Smart Rule Author"}, Authors: []string{"Smart Rule Author"},
Genre: pgtype.Text{String: genre, Valid: true}, Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+1 -1
View File
@@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle, Title: itemTitle,
Authors: []string{"Test Contract Author"}, Authors: []string{"Test Contract Author"},
Series: pgtype.Text{String: seriesName, Valid: true}, Series: pgtype.Text{String: seriesName, Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true}, Genre: pgtype.Text{String: "Science Fiction", Valid: true},
FilePath: "/tmp/contract-test-item.epub", FilePath: "/tmp/contract-test-item.epub",
+9 -9
View File
@@ -560,15 +560,15 @@ func TestFieldValuesAllLibraries(t *testing.T) {
name string name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} { }{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: lib.id, LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name), Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"}, Authors: []string{"Verdana Scribe"},
Genre: pgtype.Text{String: "verdanafantasy", Valid: true}, Genre: pgtype.Text{String: "verdanafantasy", Valid: true},
Series: pgtype.Text{String: "Verdana Saga", Valid: true}, Series: pgtype.Text{String: "Verdana Saga", Valid: true},
Language: pgtype.Text{String: "verdanish", Valid: true}, Language: pgtype.Text{String: "verdanish", Valid: true},
FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
}) })
require.NoError(t, err) require.NoError(t, err)
} }
+10 -10
View File
@@ -95,9 +95,9 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true}, Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true}, Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true},
Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true}, Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true},
Series: pgtype.Text{String: "Voss Saga", Valid: true}, Series: pgtype.Text{String: "Voss Saga", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true}, SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
Language: pgtype.Text{String: "en", Valid: true}, Language: pgtype.Text{String: "en", Valid: true},
DatePublished: datePublished, CopyrightYear: year, DatePublished: datePublished, CopyrightYear: year,
FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
@@ -107,11 +107,11 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
LibraryID: library.ID, Title: name + " Stone", LibraryID: library.ID, Title: name + " Stone",
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true}, Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true},
Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true}, Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true},
Series: pgtype.Text{String: "Voss Saga", Valid: true}, Series: pgtype.Text{String: "Voss Saga", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 2, Valid: true}, SeriesNumber: pgtype.Float8{Float64: 2, Valid: true},
FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 2048, Valid: true}, FileSize: pgtype.Int8{Int64: 2048, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
}, },
{ {
LibraryID: library.ID, Title: name + " Fen", LibraryID: library.ID, Title: name + " Fen",
@@ -126,9 +126,9 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
LibraryID: library.ID, Title: name + " Wick", LibraryID: library.ID, Title: name + " Wick",
Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true}, Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true},
PageCount: pgtype.Int4{Int32: 700, Valid: true}, PageCount: pgtype.Int4{Int32: 700, Valid: true},
FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()), FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 4096, Valid: true}, FileSize: pgtype.Int8{Int64: 4096, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
}, },
} }
for i := range books { for i := range books {
+1 -1
View File
@@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID, LibraryID: library.ID,
Title: "Test Book for Queue", Title: "Test Book for Queue",
Authors: []string{"Test Author"}, Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub", FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+2 -2
View File
@@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID, LibraryID: libraryID,
Title: "Sync Test Book", Title: "Sync Test Book",
Authors: []string{"Test Author"}, Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub", FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true}, FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
@@ -235,7 +235,7 @@ func TestSyncConflictDetection(t *testing.T) {
// Verify both exist (conflict detection would happen during processing) // Verify both exist (conflict detection would happen during processing)
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{ items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID, DeviceID: deviceID,
RowLimit: int32(10), RowLimit: int32(10),
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 1, len(items), "Should have one queue item") assert.Equal(t, 1, len(items), "Should have one queue item")
-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"` Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"` MatchMethod string `json:"match_method"`
// Display fields, filled during the family-lens pass in QueryBooks. // Display fields, filled during the family-lens pass in QueryBooks.
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"` CoverImagePath string `json:"cover_image_path"`
} }
// BookQueryRequest represents a book query from a device // BookQueryRequest represents a book query from a device
+15 -15
View File
@@ -61,10 +61,10 @@ const (
KindBool FilterKind = "bool" KindBool FilterKind = "bool"
KindNumber FilterKind = "number" KindNumber FilterKind = "number"
KindDate FilterKind = "date" KindDate FilterKind = "date"
KindPresence FilterKind = "presence" // has / missing KindPresence FilterKind = "presence" // has / missing
KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows) KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
KindStatus FilterKind = "status" // unread / reading / finished KindStatus FilterKind = "status" // unread / reading / finished
KindLibrary FilterKind = "library" KindLibrary FilterKind = "library"
KindCollection FilterKind = "collection" KindCollection FilterKind = "collection"
) )
@@ -81,13 +81,13 @@ const (
suggestContrib SuggestSource = "contributor" suggestContrib SuggestSource = "contributor"
suggestPublisher SuggestSource = "publisher" suggestPublisher SuggestSource = "publisher"
suggestTags SuggestSource = "tags" suggestTags SuggestSource = "tags"
suggestColumn SuggestSource = "column" // the field's own scalar column suggestColumn SuggestSource = "column" // the field's own scalar column
suggestSeries SuggestSource = "series" // primary + alternate_info suggestSeries SuggestSource = "series" // primary + alternate_info
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
suggestHighlight SuggestSource = "highlight_text" suggestHighlight SuggestSource = "highlight_text"
suggestLibrary SuggestSource = "library" // visibility-scoped libraries suggestLibrary SuggestSource = "library" // visibility-scoped libraries
suggestCollection SuggestSource = "collection" // the viewer's collections suggestCollection SuggestSource = "collection" // the viewer's collections
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
) )
// Filter menu groups (display order for the field menu). // Filter menu groups (display order for the field menu).
@@ -127,12 +127,12 @@ type FilterChild struct {
// FilterField is one registry entry. // FilterField is one registry entry.
type FilterField struct { type FilterField struct {
Key string // wire name (the qf "field" value) Key string // wire name (the qf "field" value)
Label string // menu label Label string // menu label
Group string // menu group Group string // menu group
Kind FilterKind Kind FilterKind
Ops []string // allowed operators, display order Ops []string // allowed operators, display order
DefaultOp string // operator a new rule starts on (field-switch reset) DefaultOp string // operator a new rule starts on (field-switch reset)
Clause func(r FilterRule, b *filterBuilder) string Clause func(r FilterRule, b *filterBuilder) string
Suggest SuggestSource Suggest SuggestSource
} }
@@ -146,7 +146,7 @@ var opsNoValue = map[string]bool{
// fixedDomains are the operator value domains that are STATIC (validated // fixedDomains are the operator value domains that are STATIC (validated
// here) rather than data-driven suggestions. // here) rather than data-driven suggestions.
var fixedDomains = map[string][]string{ var fixedDomains = map[string][]string{
"status": {"unread", "reading", "finished"}, "status": {"unread", "reading", "finished"},
"file_availability": {"available", "missing"}, "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. // single-file path — folder-of-tracks books carry the same naming.
applyTitleDerivedSeries(metadata) applyTitleDerivedSeries(metadata)
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{ created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID, LibraryID: libraryID,
Title: metadata.Title, Title: metadata.Title,
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""}, Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)), Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
AuthorsSearch: utils.NormalizeTagsSearch(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 != ""}, Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""}, Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
FilePath: relPath, FilePath: relPath,
FileSize: pgtype.Int8{Int64: totalSize, Valid: true}, FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""}, MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""}, CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""}, Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0}, SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)), Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
PublishersSearch: utils.NormalizeTagsSearch(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()}, DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""}, Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: utils.NormalizeContributors(metadata.Contributors), Contributors: utils.NormalizeContributors(metadata.Contributors),
Narrators: nilToEmpty(metadata.Narrators), Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators), NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged, Abridged: metadata.Abridged,
Explicit: metadata.Explicit, Explicit: metadata.Explicit,
Tags: utils.NormalizeTags(metadata.Tags), Tags: utils.NormalizeTags(metadata.Tags),
AddedByAdminID: s.adminID, AddedByAdminID: s.adminID,
CreatedAt: modTime, CreatedAt: modTime,
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta), AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
}) })
if err != nil { if err != nil {
// Ledger (Phase 11): row-less import failure keyed by // Ledger (Phase 11): row-less import failure keyed by
+4 -4
View File
@@ -18,10 +18,10 @@ func TestManifestEvNormalization(t *testing.T) {
raw string raw string
want int want int
}{ }{
{`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown {`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown
{`{"v":1,"ev":0}`, 1}, // explicitly unset → same {`{"v":1,"ev":0}`, 1}, // explicitly unset → same
{`{"v":1,"ev":1}`, 1}, // baseline parser stamp {`{"v":1,"ev":1}`, 1}, // baseline parser stamp
{`{"v":1,"ev":2}`, 2}, // post-bump stamp {`{"v":1,"ev":2}`, 2}, // post-bump stamp
{`{"v":1,"entries":[],"ev":3}`, 3}, // sidecar-less ev home {`{"v":1,"entries":[],"ev":3}`, 3}, // sidecar-less ev home
} }
for _, tc := range cases { for _, tc := range cases {
+4 -4
View File
@@ -17,10 +17,10 @@ import (
// SeriesCandidate is one parsed series membership from any source. // SeriesCandidate is one parsed series membership from any source.
type SeriesCandidate struct { type SeriesCandidate struct {
Name string Name string
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2") Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
Number float64 // leading number of Sequence (0 = none parsed) Number float64 // leading number of Sequence (0 = none parsed)
HasNum bool HasNum bool
FromSidecar bool FromSidecar bool
} }
+1 -1
View File
@@ -29,7 +29,7 @@ var (
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheComplete bool = true cacheComplete bool = true
cacheExpiry time.Time cacheExpiry time.Time
cacheTTL = 10 * time.Second cacheTTL = 10 * time.Second
) )
// IsSetupComplete reports whether setup is complete. Setup is complete when at // 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 // standard CFI: the anchor block's cssSelector, the character offset
// within that block's text, and the spine document's href. // within that block's text, and the spine document's href.
type ProgressAnchor struct { type ProgressAnchor struct {
CSSSelector string CSSSelector string
CharOffset int CharOffset int
Href string Href string
HealedCFI string HealedCFI string
Healed bool Healed bool
HealedPct *float64 HealedPct *float64
} }
// VerifyProgressAnchor resolves a client-submitted standard CFI against // 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) w := zip.NewWriter(f)
files := map[string]string{ files := map[string]string{
"mimetype": "application/epub+zip", "mimetype": "application/epub+zip",
"META-INF/container.xml": containerXML, "META-INF/container.xml": containerXML,
"OEBPS/content.opf": contentOPF, "OEBPS/content.opf": contentOPF,
"OEBPS/nav.xhtml": navXHTML, "OEBPS/nav.xhtml": navXHTML,
"OEBPS/chapter1.xhtml": ch1Content, "OEBPS/chapter1.xhtml": ch1Content,
"OEBPS/chapter2.xhtml": ch2Content, "OEBPS/chapter2.xhtml": ch2Content,
} }
mimetype, _ := w.Create("mimetype") 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)
}
-114
View File
@@ -1,114 +0,0 @@
package templates
templ APIExplorer(explorer APIExplorerData) {
if !explorer.IsLoggedIn {
<div x-data="devices" class="card p-6 mt-8 space-y-5">
<div class="flex items-center gap-2 flex-wrap">
if explorer.Endpoint.Method == "GET" {
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "DELETE" {
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "POST" {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
} else {
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
}
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
</div>
<div class="flex gap-2">
<button class="btn btn-primary">Mock Data</button>
<button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
</div>
<div>
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body (Example)</p>
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.RequestBody }</code></pre>
</div>
<div>
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Response (Mock)</p>
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.Response }</code></pre>
</div>
<div class="flex gap-2">
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")" } class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Request
</button>
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")" } class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Response
</button>
</div>
</div>
} else {
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init={ "initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")" }>
<div class="flex items-center gap-2 flex-wrap">
if explorer.Endpoint.Method == "GET" {
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "DELETE" {
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "POST" {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
} else {
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
}
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
</div>
<div class="flex gap-2">
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
</div>
<div>
<label for="http-method" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Method</label>
<select id="http-method" class="input">
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
</select>
</div>
<div>
<label for="request-body" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body</label>
<textarea id="request-body" placeholder="Edit request body…" class="input min-h-[150px] font-mono">{ explorer.Endpoint.RequestBody }</textarea>
</div>
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary">
@Icon("play", "h-4 w-4")
Try It Out
</button>
<div class="api-response hidden space-y-2">
<div class="flex justify-between text-sm">
<span id="response-status" class="font-semibold" style="color: var(--text-primary);"></span>
<span id="response-time" style="color: var(--text-secondary);"></span>
</div>
<pre class="card p-4 overflow-x-auto"><code id="response-body" class="text-sm" style="color: var(--text-primary);"></code></pre>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Request
</button>
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Response
</button>
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">
@Icon("external", "h-4 w-4")
Generate cURL
</button>
</div>
</div>
}
}
// APIExplorerData holds data for the API explorer component
type APIExplorerData struct {
Endpoint EndpointInfo
IsLoggedIn bool
}
// EndpointInfo holds information about an API endpoint
type EndpointInfo struct {
Method string
Path string
RequestBody string
Response string
Description string
}
-178
View File
@@ -1,178 +0,0 @@
package templates
import (
"bookhoard/internal/handlers"
"fmt"
)
templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.QueueStatsResponse) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Queue - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="queue" class={ "theme-" + user.Theme }>
@Header(user, "/queue")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("clock", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
</div>
<div class="flex space-x-3">
<button @click="processPendingItems()" class="btn btn-primary">
@Icon("play", "h-4 w-4")
Process Queue
</button>
<button @click="clearFailedItems()" class="btn btn-secondary">Clear Failed</button>
<button @click="clearAllItems()" class="btn btn-danger">Clear All</button>
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-warning);">{ stats.PendingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Pending</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-info);">{ stats.ProcessingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Processing</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-success);">{ stats.CompletedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Completed</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-danger);">{ stats.FailedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Failed</div>
</div>
</div>
</div>
<div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Status Filter</label>
<select
id="status-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Items</option>
<option value="pending" selected>Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Type</label>
<select
id="type-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Types</option>
<option value="progress">Progress</option>
<option value="note">Notes</option>
<option value="highlight">Highlights</option>
<option value="bookmark">Bookmarks</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device</label>
<select
id="device-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Devices</option>
</select>
</div>
<div class="flex-1"></div>
<button @click="refreshQueue()" class="btn btn-secondary">
@Icon("refresh", "h-4 w-4")
Refresh
</button>
</div>
</div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading queue...</p>
</div>
<div id="empty-state" class="hidden card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
<div id="queue-list" class="space-y-3">
if len(queueItems) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
}
for _, item := range queueItems {
<div class="card p-4 transition-colors" style="cursor: default;">
<div class="flex justify-between items-start mb-2 gap-2">
<div class="flex items-center gap-2 flex-wrap">
<span class={ "badge status-" + item.Status }>{ item.Status }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
</div>
<span class={ fmt.Sprintf("badge priority-%d", item.Priority) }>P{ item.Priority }</span>
</div>
<div class="text-sm mb-2" style="color: var(--text-secondary)">
Device ID: { item.DeviceID }
if item.MediaTitle != nil {
| Book: { *item.MediaTitle }
}
</div>
<div class="flex justify-between items-center text-sm" style="color: var(--text-secondary)">
<span>Attempts: { item.Attempts }/{ item.MaxAttempts }</span>
<span>{ item.CreatedAtDisplay }</span>
</div>
if item.ErrorMessage != nil {
<div class="mt-2 text-sm flex items-start gap-2" style="color: var(--status-danger);">
@Icon("alert", "h-4 w-4 shrink-0 mt-0.5")
<span>Error: { *item.ErrorMessage }</span>
</div>
}
</div>
}
</div>
<div id="queue-item-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-2xl my-8" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button @click="hideQueueItemModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div id="queue-item-details"></div>
<div class="flex justify-end space-x-3 mt-6">
<button @click="hideQueueItemModal()" class="btn btn-secondary">Close</button>
<button @click="retryQueueItem()" class="btn btn-primary">
@Icon("refresh", "h-4 w-4")
Retry
</button>
</div>
</div>
</div>
</div>
</body>
</html>
}
-160
View File
@@ -1,160 +0,0 @@
import { Alpine } from "./alpine";
import { getToken } from "./storage";
let endpointPath = "";
let exampleResponse: unknown = null;
function initAPIExplorerDoc(path: string, response: string): void {
endpointPath = path;
exampleResponse = JSON.parse(response);
}
function showDocMode(mode: "mock" | "real"): void {
const mockBtn = document.getElementById("mock-btn");
const realBtn = document.getElementById("real-btn");
const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response");
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const tryItOut = document.getElementById("try-it-out");
if (
!mockBtn ||
!realBtn ||
!responseBody ||
!responseStatus ||
!responseTime ||
!apiResponse ||
!requestBody ||
!tryItOut
)
return;
if (mode === "mock") {
mockBtn.classList.add("bg-accent", "text-white");
mockBtn.classList.remove("bg-background-primary", "text-text-primary");
realBtn.classList.remove("bg-accent", "text-white");
realBtn.classList.add("bg-background-primary", "text-text-primary");
apiResponse.classList.remove("hidden");
requestBody.readOnly = true;
tryItOut.classList.add("hidden");
responseBody.textContent = JSON.stringify(exampleResponse, null, 2);
responseStatus.textContent = "200 OK";
responseTime.textContent = "Mock";
} else {
realBtn.classList.add("bg-accent", "text-white");
realBtn.classList.remove("bg-background-primary", "text-text-primary");
mockBtn.classList.remove("bg-accent", "text-white");
mockBtn.classList.add("bg-background-primary", "text-text-primary");
apiResponse.classList.add("hidden");
requestBody.readOnly = false;
tryItOut.classList.remove("hidden");
}
}
async function tryDocEndpoint(): Promise<void> {
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response");
if (
!methodSelect ||
!requestBody ||
!responseBody ||
!responseStatus ||
!responseTime ||
!apiResponse
)
return;
const method = methodSelect.value;
const body = requestBody.value;
const startTime = Date.now();
try {
const token = getToken();
if (!token) {
throw new Error("No authentication token found");
}
const response = await fetch(endpointPath, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: ["GET", "DELETE"].includes(method) ? undefined : body,
});
const duration = Date.now() - startTime;
const data = await response.json();
responseStatus.textContent = `${response.status} (${response.statusText})`;
responseTime.textContent = `${duration}ms`;
responseBody.textContent = JSON.stringify(data, null, 2);
apiResponse.classList.remove("hidden");
} catch (error) {
responseStatus.textContent = "Error";
responseBody.textContent = (error as Error).message;
apiResponse.classList.remove("hidden");
}
}
function copyDocRequest(): void {
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (requestBody) {
navigator.clipboard.writeText(requestBody.value);
}
}
function copyDocResponse(): void {
const responseBody = document.getElementById("response-body");
if (responseBody) {
navigator.clipboard.writeText(responseBody.textContent || "");
}
}
function generateDocCURL(): void {
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (!methodSelect || !requestBody) return;
const method = methodSelect.value;
const body = requestBody.value;
const token = getToken();
let curl = `curl -X ${method} \\n -H "Content-Type: application/json" \\n -H "Authorization: Bearer ${token}"`;
if (!["GET", "DELETE"].includes(method) && body.trim()) {
curl += ` \\n -d '${body}'`;
}
curl += ` \\n ${endpointPath}`;
navigator.clipboard.writeText(curl);
}
export {
copyDocRequest,
copyDocResponse,
generateDocCURL,
initAPIExplorerDoc,
showDocMode,
tryDocEndpoint,
};
Alpine.data("apiExplorerDoc", () => ({
copyDocRequest,
copyDocResponse,
generateDocCURL,
initAPIExplorerDoc,
showDocMode,
tryDocEndpoint,
}));
-174
View File
@@ -1,174 +0,0 @@
import { showToast } from "./toast";
interface ApiExplorerRequest {
method: string;
endpoint: string;
headers: Record<string, string>;
body?: string;
}
const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void {
const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET";
const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const token = localStorage.getItem("token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const request: ApiExplorerRequest = {
method,
endpoint,
headers,
body: bodyText || undefined,
};
addToHistory(request);
const startTime = performance.now();
fetch(endpoint, {
method,
headers,
body: bodyText || undefined,
})
.then(async (response) => {
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
const responseText = await response.text();
let responseData: unknown;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
displayResponse(response, responseData, duration);
generateCurl(request);
})
.catch((error) => {
displayError(error);
});
}
function displayResponse(response: Response, data: unknown, duration: number): void {
const container = document.getElementById("api-response");
if (!container) return;
const statusColor = response.ok ? "var(--accent)" : "var(--error)";
container.innerHTML = `
<div class="mb-4">
<span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)">
${response.status} ${response.statusText}
</span>
<span class="text-sm ml-2" style="color: var(--text-secondary)">${duration}ms</span>
</div>
<pre class="p-4 rounded overflow-auto max-h-96" style="background-color: var(--bg-primary); color: var(--text-primary)">${JSON.stringify(data, null, 2)}</pre>
`;
}
function displayError(error: Error): void {
const container = document.getElementById("api-response");
if (!container) return;
container.innerHTML = `
<div class="p-4 rounded" style="background-color: var(--bg-primary)">
<p style="color: var(--error)">Error: ${error.message}</p>
</div>
`;
}
function generateCurl(request: ApiExplorerRequest): void {
const container = document.getElementById("curl-command");
if (!container) return;
let curl = `curl -X ${request.method} '${request.endpoint}'`;
Object.entries(request.headers).forEach(([key, value]) => {
curl += ` \\\n -H '${key}: ${value}'`;
});
if (request.body) {
curl += ` \\\n -d '${request.body}'`;
}
container.textContent = curl;
}
function addToHistory(request: ApiExplorerRequest): void {
requestHistory.unshift(request);
if (requestHistory.length > 20) {
requestHistory.pop();
}
renderHistory();
}
function renderHistory(): void {
const container = document.getElementById("request-history");
if (!container) return;
if (requestHistory.length === 0) {
container.innerHTML =
'<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
return;
}
container.innerHTML = requestHistory
.slice(0, 10)
.map(
(req, i) => `
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
style="background-color: var(--bg-secondary)"
onclick="window.loadFromHistory(${i})">
<span class="text-xs font-mono" style="color: ${req.method === "GET" ? "var(--accent)" : req.method === "POST" ? "var(--success)" : req.method === "DELETE" ? "var(--error)" : "var(--text-primary)"}">${req.method}</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
</div>
`,
)
.join("");
}
function loadFromHistory(index: number): void {
const request = requestHistory[index];
if (!request) return;
const methodSelect = document.getElementById("api-method") as HTMLSelectElement;
const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement;
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method;
if (endpointInput) endpointInput.value = request.endpoint;
if (bodyInput) bodyInput.value = request.body || "";
}
function copyCurl(): void {
const curl = document.getElementById("curl-command")?.textContent;
if (curl) {
navigator.clipboard.writeText(curl);
showToast("cURL copied to clipboard", "success");
}
}
function formatJson(): void {
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (!bodyInput) return;
try {
const parsed = JSON.parse(bodyInput.value);
bodyInput.value = JSON.stringify(parsed, null, 2);
} catch {
showToast("Invalid JSON", "error");
}
}
export { sendApiRequest, loadFromHistory, copyCurl, formatJson };
-120
View File
@@ -1,120 +0,0 @@
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function querySelector<T extends Element>(selector: string): T | null {
return document.querySelector<T>(selector);
}
function querySelectorAll<T extends Element>(selector: string): NodeListOf<T> {
return document.querySelectorAll<T>(selector);
}
function getElementById<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
attributes?: Record<string, string>,
children?: (string | Node)[],
): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName);
if (attributes) {
Object.entries(attributes).forEach(([key, value]) => {
if (key === "className") {
element.className = value;
} else if (key === "dataset") {
Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => {
element.dataset[dataKey] = String(dataValue);
});
} else {
element.setAttribute(key, value);
}
});
}
if (children) {
children.forEach((child) => {
if (typeof child === "string") {
element.appendChild(document.createTextNode(child));
} else {
element.appendChild(child);
}
});
}
return element;
}
function showElement(element: HTMLElement | null): void {
if (element) {
element.classList.remove("hidden");
}
}
function hideElement(element: HTMLElement | null): void {
if (element) {
element.classList.add("hidden");
}
}
function toggleElement(element: HTMLElement | null): void {
if (element) {
element.classList.toggle("hidden");
}
}
function setTextContent(element: HTMLElement | null, text: string): void {
if (element) {
element.textContent = text;
}
}
function setInnerHTML(element: HTMLElement | null, html: string): void {
if (element) {
element.innerHTML = html;
}
}
function addClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.add(className);
}
}
function removeClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.remove(className);
}
}
function toggleClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.toggle(className);
}
}
function hasClass(element: HTMLElement | null, className: string): boolean {
return element ? element.classList.contains(className) : false;
}
export {
addClass,
createElement,
escapeHtml,
getElementById,
hasClass,
hideElement,
querySelector,
querySelectorAll,
removeClass,
setInnerHTML,
setTextContent,
showElement,
toggleClass,
toggleElement,
};
-113
View File
@@ -1,113 +0,0 @@
function onDelegatedClick(
selector: string,
handler: (element: HTMLElement, event: MouseEvent) => void,
): void {
document.addEventListener("click", (event: MouseEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedSubmit(
selector: string,
handler: (form: HTMLFormElement, event: Event) => void,
): void {
document.addEventListener("submit", (event: Event) => {
const target = event.target as HTMLElement;
const form = target.closest(selector) as HTMLFormElement | null;
if (form) {
handler(form, event);
}
});
}
function onDelegatedChange(
selector: string,
handler: (element: HTMLElement, event: Event) => void,
): void {
document.addEventListener("change", (event: Event) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedKeydown(
selector: string,
handler: (element: HTMLElement, event: KeyboardEvent) => void,
): void {
document.addEventListener("keydown", (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
return element.dataset[name];
}
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
element.dataset[name] = value;
}
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
if (element) {
element.addEventListener("click", handler);
}
}
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("submit", handler);
}
}
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("change", handler);
}
}
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
if (element) {
element.addEventListener("keydown", handler);
}
}
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("input", handler);
}
}
function preventDefault(event: Event): void {
event.preventDefault();
}
function stopPropagation(event: Event): void {
event.stopPropagation();
}
export {
getDataAttribute,
onChange,
onClick,
onDelegatedChange,
onDelegatedClick,
onDelegatedKeydown,
onDelegatedSubmit,
onInput,
onKeydown,
onSubmit,
preventDefault,
setDataAttribute,
stopPropagation,
};
-204
View File
@@ -1,204 +0,0 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshQueue(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/queue/all", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
renderQueueItems(data.items || []);
}
} catch (error) {
console.error("Failed to refresh queue:", error);
}
}
async function processPendingItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/queue/process", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Processing queue items", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to process queue:", error);
showToast("Failed to process queue", "error");
}
}
async function clearFailedItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (!confirm("Are you sure you want to clear all failed items?")) return;
try {
const response = await fetch("/api/queue/failed", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Failed items cleared", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to clear failed items:", error);
showToast("Failed to clear items", "error");
}
}
async function clearAllItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (!confirm("Are you sure you want to clear all queue items?")) return;
try {
const response = await fetch("/api/queue/all", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Queue cleared", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to clear queue:", error);
showToast("Failed to clear queue", "error");
}
}
async function retryQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Item queued for retry", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to retry item:", error);
showToast("Failed to retry item", "error");
}
}
async function deleteQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Item deleted", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to delete item:", error);
showToast("Failed to delete item", "error");
}
}
function renderQueueItems(items: QueueItemResponse[]): void {
const container = document.getElementById("queue-items");
if (!container) return;
if (items.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
return;
}
container.innerHTML = items
.map(
(item) => `
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center">
<div>
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || "Unknown"}</p>
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
</div>
<div class="flex space-x-2">
${item.status === "failed" ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ""}
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div>
</div>
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ""}
</div>
`,
)
.join("");
}
function showQueueItemModal(): void {
const modal = document.getElementById("queue-item-modal");
const detailsContainer = document.getElementById("queue-item-details");
if (modal && detailsContainer) {
// TODO: Fetch and display item details
detailsContainer.innerHTML = "<p>Loading item details...</p>";
modal.classList.remove("hidden");
}
}
function hideQueueItemModal(): void {
const modal = document.getElementById("queue-item-modal");
if (modal) {
modal.classList.add("hidden");
}
}
function filterQueue(status: string): void {
const filterValue = status || "all";
// TODO: Implement filtering logic
console.log("Filter queue by:", filterValue);
}
export {
clearAllItems,
clearFailedItems,
deleteQueueItem,
processPendingItems,
refreshQueue,
retryQueueItem,
showQueueItemModal,
hideQueueItemModal,
filterQueue,
};
Alpine.data("queue", () => ({
clearAllItems,
clearFailedItems,
deleteQueueItem,
processPendingItems,
refreshQueue,
retryQueueItem,
showQueueItemModal,
hideQueueItemModal,
filterQueue,
}));