diff --git a/COMPLETION_PLAN.md b/COMPLETION_PLAN.md new file mode 100644 index 0000000..502f812 --- /dev/null +++ b/COMPLETION_PLAN.md @@ -0,0 +1,2151 @@ +# Bookmann Completion Plan: Final 5% Features + +## Executive Summary + +This plan implements the final 5% of features from the original IMPLEMENTATION_PLAN.md that were deferred during initial implementation. All features are designed to be atomic, independently testable, and fully integrated with existing code. + +**Key Design Principle**: All conversions MUST preserve hash integrity for book matching. When converting EPUB→KEPUB, store BOTH hashes in `media_item_formats` table to ensure cross-device matching still works. + +--- + +## Table of Contents + +1. [Phase 1: File Conversion Pipeline](#phase-1-file-conversion-pipeline) +2. [Phase 2: Advanced Unlinked Book Resolution](#phase-2-advanced-unlinked-book-resolution) +3. [Phase 3: Conflict Resolution UI & API](#phase-3-conflict-resolution-ui--api) +4. [Phase 4: Analytics & Reporting Dashboard](#phase-4-analytics--reporting-dashboard) +5. [Phase 5: Bulk Operations API](#phase-5-bulk-operations-api) +6. [Phase 6: WebSocket Real-time Updates](#phase-6-websocket-real-time-updates) +7. [Testing & Documentation](#testing--documentation) + +--- + +## Phase 1: File Conversion Pipeline + +### Overview + +Implement on-the-fly EPUB→KEPUB conversion with **dual hash storage** to ensure book matching continues to work after format conversion. This is CRITICAL for the cross-device sync system. + +### Key Requirements + +1. **Dual Hash Storage**: Store both original EPUB hash AND converted KEPUB hash in `media_item_formats` table +2. **On-Demand Conversion**: Convert EPUB→KEPUB when requested via OPDS with `?format=kepub` +3. **Hash Preservation**: After conversion, both hashes are queryable for book matching +4. **Conversion Caching**: Store converted files to avoid re-conversion +5. **Format Integrity**: Ensure converted KEPUB maintains all reading progress markers + +### Architecture + +``` +User requests book via OPDS with ?format=kepub + ↓ +Check media_item_formats table for existing KEPUB + ↓ +If KEPUB exists and is recent (< 24 hours): + → Serve pre-converted file + → Set X-Bookmann-KEPUB-SHA256 header + ↓ +If KEPUB doesn't exist or is stale: + → Convert EPUB→KEPUB on-the-fly + → Calculate SHA-256 of converted KEPUB + → Store in media_item_formats (with converted_from_format_id) + → Serve converted file + → Set X-Bookmann-KEPUB-SHA256 header + ↓ +Device downloads book with hash in response header + ↓ +Device syncs progress using hash for matching +``` + +### Database Schema + +**Existing Table** (already in schema.sql): +```sql +-- No changes needed - table already supports dual hash storage +CREATE TABLE media_item_formats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE, + format_type VARCHAR(10) NOT NULL, -- 'epub', 'kepub', 'pdf', 'cbz' + file_path VARCHAR(500), + file_sha256 CHAR(64), -- Hash for THIS format version + file_size_bytes BIGINT, + mime_type VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + converted_from_format_id UUID REFERENCES media_item_formats(id), -- Track conversion chain + UNIQUE(media_item_id, format_type) +); +``` + +**Example Data**: +``` +Row 1: media_item_id=uuid-123, format_type='epub', file_sha256='abc123...' +Row 2: media_item_id=uuid-123, format_type='kepub', file_sha256='xyz789...', converted_from_format_id=Row1.id +``` + +### Implementation Tasks + +#### 1.1 Create Conversion Service + +**File**: `internal/services/conversion_service.go` + +```go +package services + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "crypto/sha256" + "encoding/hex" + "io" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "bookmann/internal/database" +) + +type ConversionService struct { + db *database.Queries + cacheDir string // e.g., "/var/bookmann/cache/kepub" + conversionTool string // Path to conversion tool (ebook-convert, kepubify, etc.) +} + +func NewConversionService(db *database.Queries, cacheDir string) *ConversionService { + return &ConversionService{ + db: db, + cacheDir: cacheDir, + conversionTool: "/usr/bin/kepubify", // Or ebook-convert from Calibre + } +} + +// ConvertEPUBToKEPUB converts EPUB to KEPUB format with hash storage +func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) { + // Step 1: Check if KEPUB already exists and is recent + existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + if err == nil { + // Check if conversion is recent (< 24 hours) + if time.Since(existing.CreatedAt.Time) < 24*time.Hour { + return &ConvertedKEPUB{ + Path: existing.FilePath.String, + SHA256: existing.FileSha256.String, + Cached: true, + }, nil + } + } + + // Step 2: Perform conversion + kepubPath := filepath.Join(s.cacheDir, fmt.Sprintf("%s.kepub.epub", mediaItemID.String())) + + if err := s.convertEPUB(epubPath, kepubPath); err != nil { + return nil, fmt.Errorf("conversion failed: %w", err) + } + + // Step 3: Calculate SHA-256 of converted KEPUB + kepubSHA256, err := s.calculateSHA256(kepubPath) + if err != nil { + return nil, fmt.Errorf("hash calculation failed: %w", err) + } + + // Step 4: Get EPUB format_id for converted_from_format_id + epubFormat, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + }) + if err != nil { + return nil, fmt.Errorf("EPUB format not found: %w", err) + } + + // Step 5: Store converted format in database (dual hash storage) + fileinfo, _ := os.Stat(kepubPath) + _, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + FilePath: pgtype.Text{String: kepubPath, Valid: true}, + FileSha256: pgtype.Text{String: kepubSHA256, Valid: true}, + FileSizeBytes: fileinfo.Size(), + MimeType: pgtype.Text{String: "application/vnd.kobo+xml+zip", Valid: true}, + ConvertedFromFormatID: pgtype.UUID{Bytes: epubFormat.ID.Bytes, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("failed to store converted format: %w", err) + } + + return &ConvertedKEPUB{ + Path: kepubPath, + SHA256: kepubSHA256, + Cached: false, + }, nil +} + +// convertEPUB performs the actual EPUB→KEPUB conversion +func (s *ConversionService) convertEPUB(epubPath, kepubPath string) error { + // Option 1: Using kepubify (recommended for Kobo) + cmd := exec.Command(s.conversionTool, "-i", epubPath, "-o", kepubPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("kepubify failed: %w, output: %s", err, output) + } + + // Option 2: Using Calibre's ebook-convert (fallback) + // cmd := exec.Command("ebook-convert", epubPath, kepubPath, "--output-format", "kepub") + + return nil +} + +// calculateSHA256 calculates SHA-256 hash of file +func (s *ConversionService) calculateSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +type ConvertedKEPUB struct { + Path string + SHA256 string + Cached bool // True if served from cache, false if freshly converted +} +``` + +#### 1.2 Update OPDS Handler to Use Conversion Service + +**File**: `internal/handlers/opds.go` + +**Location**: In the download handler (around line 390-470) + +**Current Code** (simplified): +```go +func (h *Handler) HandleDownload(c echo.Context) error { + // ... existing code ... + if format == "kepub" { + kepubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + if err == nil { + // Serve pre-converted file + return c.File(kepubFormat.FilePath.String) + } + // Fallback to EPUB + } +} +``` + +**Updated Code**: +```go +func (h *Handler) HandleDownload(c echo.Context) error { + // ... existing validation code ... + + format := c.QueryParam("format") + if format == "" { + format = "epub" // Default format + } + + var filePath string + var fileSHA256 string + + switch format { + case "kepub": + // Step 1: Try to get existing KEPUB format + kepubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + + if err == nil && kepubFormat.FilePath.Valid { + // KEPUB exists - serve it + filePath = kepubFormat.FilePath.String + fileSHA256 = kepubFormat.FileSha256.String + } else { + // KEPUB doesn't exist - convert on-the-fly + epubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + }) + if err != nil { + return c.JSON(500, map[string]string{"error": "EPUB source not found"}) + } + + // Use conversion service + converted, err := h.conversionService.ConvertEPUBToKEPUB(ctx, mediaItemID, epubFormat.FilePath.String) + if err != nil { + return c.JSON(500, map[string]string{"error": fmt.Sprintf("Conversion failed: %v", err)}) + } + + filePath = converted.Path + fileSHA256 = converted.SHA256 + } + + case "epub", "pdf", "cbz": + // Serve original format directly + formatRecord, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: format, + }) + if err != nil { + return c.JSON(404, map[string]string{"error": "Format not found"}) + } + + filePath = formatRecord.FilePath.String + fileSHA256 = formatRecord.FileSha256.String + + default: + return c.JSON(400, map[string]string{"error": "Unsupported format"}) + } + + // Set response headers with format-specific hash + c.Response().Header().Set("Content-Type", getContentType(format)) + c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, title, format)) + c.Response().Header().Set("X-Bookmann-UUID", mediaItemID.String) + + if format == "kepub" { + c.Response().Header().Set("X-Bookmann-KEPUB-SHA256", fileSHA256) + } else { + c.Response().Header().Set("X-Bookmann-SHA256", fileSHA256) + } + + return c.Attachment(filePath, title) +} + +func getContentType(format string) string { + switch format { + case "epub": + return "application/epub+zip" + case "kepub": + return "application/vnd.kobo+xml+zip" + case "pdf": + return "application/pdf" + case "cbz": + return "application/x-cbr" + default: + return "application/octet-stream" + } +} +``` + +#### 1.3 Register Conversion Service in Dependency Injection + +**File**: `cmd/server/main.go` (or wherever handlers are initialized) + +**Add to initialization**: +```go +// After database queries initialization +conversionService := services.NewConversionService(queries, "/var/bookmann/cache/kepub") + +// Create handler with conversion service +opdsHandler := handlers.NewOPDSHandler(queries, conversionService, userService) +``` + +#### 1.4 Update Handler Constructor + +**File**: `internal/handlers/opds.go` + +**Update struct and constructor**: +```go +type Handler struct { + queries *database.Queries + conversionService *services.ConversionService + userService *services.UserService + // ... existing fields ... +} + +func NewOPDSHandler(queries *database.Queries, conversionService *services.ConversionService, userService *services.UserService) *Handler { + return &Handler{ + queries: queries, + conversionService: conversionService, + userService: userService, + // ... existing fields ... + } +} +``` + +### Testing + +**File**: `internal/services/conversion_service_test.go` + +```go +package services + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertEPUBToKEPUB(t *testing.T) { + // Setup + db := setupTestDB(t) + service := NewConversionService(db, t.TempDir()) + + ctx := context.Background() + + // Create test EPUB + testEPUB := createTestEPUB(t, "test-book.epub") + + // Create media item and EPUB format + mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true} + _, err := db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + FilePath: pgtype.Text{String: testEPUB, Valid: true}, + FileSha256: pgtype.Text{String: "abc123", Valid: true}, + }) + require.NoError(t, err) + + // Test conversion + result, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB) + + // Assertions + require.NoError(t, err) + assert.NotEmpty(t, result.Path) + assert.NotEmpty(t, result.SHA256) + assert.FileExists(t, result.Path) + assert.False(t, result.Cached) // First conversion should not be cached + + // Verify dual hash storage + epubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + }) + assert.Equal(t, "abc123", epubFormat.FileSha256.String) + + kepubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + assert.NotEqual(t, "abc123", kepubFormat.FileSha256.String) // Different hash for KEPUB + assert.Equal(t, epubFormat.ID, kepubFormat.ConvertedFromFormatID) // Conversion chain +} + +func TestConvertCaching(t *testing.T) { + // Test that subsequent conversions use cache + db := setupTestDB(t) + service := NewConversionService(db, t.TempDir()) + + ctx := context.Background() + mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true} + testEPUB := createTestEPUB(t, "cached-book.epub") + + // First conversion + result1, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB) + require.NoError(t, err) + assert.False(t, result1.Cached) + + // Second conversion (should use cache) + result2, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB) + require.NoError(t, err) + assert.True(t, result2.Cached) + assert.Equal(t, result1.SHA256, result2.SHA256) +} + +func TestConversionChain(t *testing.T) { + // Test that conversion chain is preserved + db := setupTestDB(t) + service := NewConversionService(db, t.TempDir()) + + ctx := context.Background() + mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true} + testEPUB := createTestEPUB(t, "chain-test.epub") + + // Create EPUB format + epubFormat, err := db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + FilePath: pgtype.Text{String: testEPUB, Valid: true}, + FileSha256: pgtype.Text{String: "original-epub-hash", Valid: true}, + }) + require.NoError(t, err) + + // Convert + result, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB) + require.NoError(t, err) + + // Verify conversion chain + kepubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + + assert.Equal(t, epubFormat.ID, kepubFormat.ConvertedFromFormatID) + assert.NotEqual(t, "original-epub-hash", result.SHA256) // Different hash after conversion +} +``` + +### Configuration + +**Environment Variables** (add to `.env` or `system_config`): +```bash +# Conversion service configuration +BOOKMANN_CONVERSION_CACHE_DIR=/var/bookmann/cache/kepub +BOOKMANN_CONVERSION_TOOL=/usr/bin/kepubify # or /usr/bin/ebook-convert +BOOKMANN_CONVERSION_CACHE_TTL=24h +``` + +**Dockerfile Updates** (if using kepubify): +```dockerfile +# Install kepubify for EPUB→KEPUB conversion +RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ + && chmod +x /usr/bin/kepubify + +# Or install Calibre for ebook-convert +# RUN apt-get update && apt-get install -y calibre +``` + +### Bruno API Tests + +**File**: `bruno/opds/Download Book KEPUB (On-the-fly Conversion).bru` + +```json +{ + "meta": { + "name": "Download Book KEPUB (On-the-fly Conversion)", + "type": "http", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Test format-specific hash header", + "const kepubHash = resp.headers.get('X-Bookmann-KEPUB-SHA256');", + "if (kepubHash) {", + " tests['KEPUB hash present'] = true;", + " tests['Hash is 64 chars'] = kepubHash.length === 64;", + "} else {", + " tests['KEPUB hash present'] = false;", + "}" + ] + } + } + ] + }, + "req": { + "url": "{{baseUrl}}/opds/devices/{{deviceId}}/download/{{mediaItemId}}?format=kepub", + "method": "GET" + } +} +``` + +--- + +## Phase 2: Advanced Unlinked Book Resolution + +### Overview + +Enhance the unlinked book resolution workflow with bulk operations, better matching UI, and automated suggestions. The backend already stores unlinked books - this phase adds user-friendly workflows. + +### Current State + +- ✅ Backend stores `unlinked_books` table +- ✅ Manual linking endpoint exists: `POST /api/sync/link-book` +- ✅ Query endpoint exists: `POST /api/sync/books/query` +- ❌ No bulk resolution workflow +- ❌ No automated matching suggestions +- ❌ UI is basic (templates exist but workflow incomplete) + +### Implementation Tasks + +#### 2.1 Add Bulk Resolution API + +**File**: `internal/handlers/book_matching.go` + +**Add new endpoints**: + +```go +// POST /api/sync/bulk-link-books +// Bulk link multiple unlinked books at once +func (h *BookMatchingHandler) HandleBulkLinkBooks(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + Links []struct { + ProgressID pgtype.UUID `json:"progress_id"` + MediaItemID pgtype.UUID `json:"media_item_id"` + ConfidenceScore float64 `json:"confidence_score"` + } `json:"links"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + results := make([]map[string]interface{}, 0, len(req.Links)) + + for _, link := range req.Links { + // Get unlinked record + unlinked, err := h.queries.GetUnlinkedBookByProgressID(ctx, link.ProgressID) + if err != nil { + results = append(results, map[string]interface{}{ + "progress_id": link.ProgressID, + "status": "error", + "error": "Unlinked record not found", + }) + continue + } + + // Create device file alias + _, err = h.queries.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ + MediaItemID: link.MediaItemID, + DeviceID: unlinked.DeviceID, + FilePath: unlinked.FilePath.String, + FileSha256: unlinked.FileSha256, + ConfidenceScore: link.ConfidenceScore, + }) + + if err != nil { + results = append(results, map[string]interface{}{ + "progress_id": link.ProgressID, + "status": "error", + "error": err.Error(), + }) + continue + } + + // Delete from unlinked_books + err = h.queries.DeleteUnlinkedBook(ctx, link.ProgressID) + if err != nil { + results = append(results, map[string]interface{}{ + "progress_id": link.ProgressID, + "status": "warning", + "error": "Linked but failed to delete unlinked record", + }) + continue + } + + results = append(results, map[string]interface{}{ + "progress_id": link.ProgressID, + "status": "success", + "media_item_id": link.MediaItemID, + }) + } + + return c.JSON(200, map[string]interface{}{ + "results": results, + "total": len(req.Links), + "successful": countSuccessful(results), + "failed": countFailed(results), + }) +} + +// POST /api/sync/auto-link-books +// Automatically attempt to link unlinked books using matching algorithm +func (h *BookMatchingHandler) HandleAutoLinkBooks(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + ConfidenceThreshold float64 `json:"confidence_threshold"` // e.g., 0.8 + Limit int `json:"limit"` // Max books to process + } + + if err := c.Bind(&req); err != nil { + req.ConfidenceThreshold = 0.8 // Default + req.Limit = 50 // Default + } + + // Get unlinked books + unlinked, err := h.queries.ListUnlinkedBooks(ctx, database.ListUnlinkedBooksParams{ + UserID: userID, + Limit: int32(req.Limit), + }) + if err != nil { + return c.JSON(500, map[string]string{"error": err.Error()}) + } + + results := make([]map[string]interface{}, 0) + + for _, book := range unlinked { + // Try to match using multiple identifiers + match, err := h.bookMatchingService.QueryBooks(ctx, services.BookQueryRequest{ + SHA256: book.FileSha256.String, + Title: book.TitleFromDevice.String, + Author: "", // Not available in unlinked_books + Identifiers: []string{}, + }) + + if err != nil { + continue + } + + // Check if best match meets confidence threshold + if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold { + bestMatch := match.Matches[0] + + // Auto-link + _, err := h.queries.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ + MediaItemID: bestMatch.MediaItemID, + DeviceID: book.DeviceID, + FilePath: book.FilePath.String, + FileSha256: book.FileSha256, + ConfidenceScore: bestMatch.Confidence, + }) + + if err == nil { + // Delete from unlinked + h.queries.DeleteUnlinkedBook(ctx, book.ProgressID) + + results = append(results, map[string]interface{}{ + "progress_id": book.ProgressID, + "title": book.TitleFromDevice.String, + "matched_media_item_id": bestMatch.MediaItemID, + "confidence": bestMatch.Confidence, + "match_method": bestMatch.MatchMethod, + }) + } + } + } + + return c.JSON(200, map[string]interface{}{ + "auto_linked": len(results), + "results": results, + }) +} + +// GET /api/sync/unlinked-books/suggestions +// Get matching suggestions for unlinked books +func (h *BookMatchingHandler) HandleGetSuggestions(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + progressID := c.Param("progressId") + + // Get unlinked book details + unlinked, err := h.queries.GetUnlinkedBookByProgressID(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: true}) + if err != nil { + return c.JSON(404, map[string]string{"error": "Unlinked book not found"}) + } + + // Query for matches + matches, err := h.bookMatchingService.QueryBooks(ctx, services.BookQueryRequest{ + SHA256: unlinked.FileSha256.String, + Title: unlinked.TitleFromDevice.String, + }) + + if err != nil { + return c.JSON(500, map[string]string{"error": err.Error()}) + } + + // Return suggestions with confidence scores + return c.JSON(200, map[string]interface{}{ + "progress_id": progressID, + "title_from_device": unlinked.TitleFromDevice.String, + "sha256": unlinked.FileSha256.String, + "suggestions": matches.Matches, + "total_suggestions": len(matches.Matches), + }) +} +``` + +#### 2.2 Update Frontend Template + +**File**: `templates/unlinked_books.templ` + +**Add bulk operations UI** (enhance existing template): + +```html + +
+
+ + +
+
+ + + +
+
+ + +
+ + +
+ + + +``` + +### Database Queries + +**File**: `internal/database/queries/queries.sql` + +**Add missing queries** (if not present): + +```sql +-- Get unlinked book by progress ID +-- name: GetUnlinkedBookByProgressID :one +SELECT * FROM unlinked_books WHERE progress_id = $1; + +-- List unlinked books with pagination +-- name: ListUnlinkedBooks :many +SELECT * FROM unlinked_books +WHERE user_id = @UserID +ORDER BY last_sync_timestamp DESC +LIMIT @Limit; + +-- Delete unlinked book +-- name: DeleteUnlinkedBook :exec +DELETE FROM unlinked_books WHERE progress_id = $1; +``` + +### Bruno API Tests + +**File**: `bruno/sync-kobo/Bulk Link Books.bru` + +```json +{ + "meta": { + "name": "Bulk Link Unlinked Books" + }, + "req": { + "url": "{{baseUrl}}/api/sync/bulk-link-books", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer {{authToken}}" + }, + "body": { + "links": [ + { + "progress_id": "uuid-1", + "media_item_id": "uuid-2", + "confidence_score": 1.0 + }, + { + "progress_id": "uuid-3", + "media_item_id": "uuid-4", + "confidence_score": 0.9 + } + ] + } + } +} +``` + +--- + +## Phase 3: Conflict Resolution UI & API + +### Overview + +Implement user-facing conflict resolution endpoints and UI. The backend already detects and stores conflicts - this phase adds resolution workflows. + +### Current State + +- ✅ `sync_conflicts` table exists +- ✅ Conflicts are detected and stored +- ❌ No user-facing resolution endpoints +- ❌ No bulk conflict resolution + +### Implementation Tasks + +#### 3.1 Add Conflict Resolution API + +**File**: `internal/handlers/conflicts.go` (create if doesn't exist) + +```go +package handlers + +import ( + "context" + "net/http" + + "github.com/jackc/pgx/v5/pgtype" + "bookmann/internal/database" +) + +type ConflictsHandler struct { + queries *database.Queries +} + +func NewConflictsHandler(queries *database.Queries) *ConflictsHandler { + return &ConflictsHandler{queries: queries} +} + +// POST /api/conflicts/:conflictId/resolve +// Resolve a single conflict by choosing a winner +func (h *ConflictsHandler) HandleResolveConflict(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + conflictID := c.Param("conflictId") + + var req struct { + WinningSourceID pgtype.UUID `json:"winning_source_id"` // ID of the progress source to keep + ResolutionNote string `json:"resolution_note"` // Optional note + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + // Get conflict details + conflict, err := h.queries.GetConflict(ctx, conflictID) + if err != nil { + return c.JSON(404, map[string]string{"error": "Conflict not found"}) + } + + // Update progress to winning value + var winningProgress interface{} + if conflict.Source1ID == req.WinningSourceID { + winningProgress = conflict.Source1Data + } else if conflict.Source2ID == req.WinningSourceID { + winningProgress = conflict.Source2Data + } else { + return c.JSON(400, map[string]string{"error": "Invalid winning source ID"}) + } + + // Apply winning progress + err = h.applyWinningProgress(ctx, conflict.MediaItemID, userID, winningProgress) + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to apply resolution"}) + } + + // Mark conflict as resolved + err = h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{ + ConflictID: conflictID, + Status: "resolved", + ResolvedBy: pgtype.UUID{Bytes: userID.Bytes, Valid: true}, + ResolutionNote: pgtype.Text{String: req.ResolutionNote, Valid: true}, + }) + + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to mark conflict as resolved"}) + } + + return c.JSON(200, map[string]string{"status": "resolved"}) +} + +// POST /api/conflicts/bulk-resolve +// Bulk resolve conflicts using same strategy +func (h *ConflictsHandler) HandleBulkResolveConflicts(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + ConflictIDs []pgtype.UUID `json:"conflict_ids"` + Strategy string `json:"strategy"` // "most_recent", "highest_progress", "manual" + WinningSource pgtype.UUID `json:"winning_source,omitempty"` // For manual strategy + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + results := make([]map[string]interface{}, 0) + + for _, conflictID := range req.ConflictIDs { + conflict, err := h.queries.GetConflict(ctx, conflictID.String()) + if err != nil { + results = append(results, map[string]interface{}{ + "conflict_id": conflictID, + "status": "error", + "error": "Conflict not found", + }) + continue + } + + var winningSource pgtype.UUID + + // Determine winner based on strategy + switch req.Strategy { + case "most_recent": + if conflict.Source1Timestamp.After(conflict.Source2Timestamp) { + winningSource = conflict.Source1ID + } else { + winningSource = conflict.Source2ID + } + case "highest_progress": + // Parse percentage from source data + progress1 := h.extractPercentage(conflict.Source1Data) + progress2 := h.extractPercentage(conflict.Source2Data) + if progress1 > progress2 { + winningSource = conflict.Source1ID + } else { + winningSource = conflict.Source2ID + } + case "manual": + winningSource = req.WinningSource + default: + results = append(results, map[string]interface{}{ + "conflict_id": conflictID, + "status": "error", + "error": "Invalid strategy", + }) + continue + } + + // Apply resolution + var winningProgress interface{} + if winningSource == conflict.Source1ID { + winningProgress = conflict.Source1Data + } else { + winningProgress = conflict.Source2Data + } + + err = h.applyWinningProgress(ctx, conflict.MediaItemID, userID, winningProgress) + if err != nil { + results = append(results, map[string]interface{}{ + "conflict_id": conflictID, + "status": "error", + "error": "Failed to apply resolution", + }) + continue + } + + // Mark as resolved + h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{ + ConflictID: conflictID.String(), + Status: "resolved", + ResolvedBy: pgtype.UUID{Bytes: userID.Bytes, Valid: true}, + }) + + results = append(results, map[string]interface{}{ + "conflict_id": conflictID, + "status": "success", + "winner": winningSource.String(), + }) + } + + return c.JSON(200, map[string]interface{}{ + "results": results, + "total": len(req.ConflictIDs), + }) +} + +// POST /api/conflicts/:conflictId/dismiss +// Dismiss a conflict without resolving (keep current state) +func (h *ConflictsHandler) HandleDismissConflict(c echo.Context) error { + ctx := c.Request().Context() + conflictID := c.Param("conflictId") + + err := h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{ + ConflictID: conflictID, + Status: "dismissed", + }) + + if err != nil { + return c.JSON(500, map[string]string{"error": "Failed to dismiss conflict"}) + } + + return c.JSON(200, map[string]string{"status": "dismissed"}) +} + +// Helper: Apply winning progress to reading_progress table +func (h *ConflictsHandler) applyWinningProgress(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, progressData interface{}) error { + // Parse progressData and update reading_progress table + // Implementation depends on progress data structure + + // Pseudo-code: + // var progress ReadingProgress + // json.Unmarshal(progressData, &progress) + // h.queries.UpdateReadingProgress(ctx, progress) + + return nil +} + +// Helper: Extract percentage from progress data +func (h *ConflictsHandler) extractPercentage(data interface{}) float64 { + // Parse JSON and extract percentage field + // Implementation depends on data structure + return 0.0 +} +``` + +#### 3.2 Update Frontend Template + +**File**: `templates/conflicts.templ` (enhance existing) + +```html + +
+
+ + +
+
+ + + +
+
+ + +
+ + +
+ + +``` + +### Database Queries + +**File**: `internal/database/queries/queries.sql` + +**Add missing queries**: + +```sql +-- Get conflict by ID +-- name: GetConflict :one +SELECT * FROM sync_conflicts WHERE id = $1; + +-- Update conflict status +-- name: UpdateConflictStatus :exec +UPDATE sync_conflicts +SET status = $2, + resolved_by = $3, + resolution_note = $4, + resolved_at = NOW() +WHERE id = $1; + +-- List conflicts by user +-- name: ListConflictsByUser :many +SELECT * FROM sync_conflicts +WHERE media_item_id IN ( + SELECT media_item_id FROM reading_progress WHERE user_id = $1 +) +ORDER BY created_at DESC; +``` + +### Bruno API Tests + +**File**: `bruno/conflicts/Bulk Resolve Conflicts.bru` + +```json +{ + "meta": {"name": "Bulk Resolve Conflicts"}, + "req": { + "url": "{{baseUrl}}/api/conflicts/bulk-resolve", + "method": "POST", + "body": { + "conflict_ids": ["uuid-1", "uuid-2", "uuid-3"], + "strategy": "most_recent" + } + } +} +``` + +--- + +## Phase 4: Analytics & Reporting Dashboard + +### Overview + +Implement aggregation endpoints and analytics dashboard using the existing `reading_history` table. + +### Implementation Tasks + +#### 4.1 Add Analytics API Endpoints + +**File**: `internal/handlers/analytics.go` (create new file) + +```go +package handlers + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "bookmann/internal/database" +) + +type AnalyticsHandler struct { + queries *database.Queries +} + +func NewAnalyticsHandler(queries *database.Queries) *AnalyticsHandler { + return &AnalyticsHandler{queries: queries} +} + +// GET /api/analytics/reading-stats +// Get reading statistics for a user +func (h *AnalyticsHandler) HandleGetReadingStats(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + // Get date range from query params + startDate := c.QueryParam("start_date") + endDate := c.QueryParam("end_date") + + if startDate == "" { + startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02") // Default: last 30 days + } + if endDate == "" { + endDate = time.Now().Format("2006-01-02") + } + + // Query reading history + history, err := h.queries.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{ + UserID: userID, + StartDate: pgtype.Date{Time: parseDate(startDate), Valid: true}, + EndDate: pgtype.Date{Time: parseDate(endDate), Valid: true}, + }) + + if err != nil { + return c.JSON(500, map[string]string{"error": err.Error()}) + } + + // Calculate statistics + stats := h.calculateReadingStats(history) + + return c.JSON(200, stats) +} + +// GET /api/analytics/device-usage +// Get device usage breakdown +func (h *AnalyticsHandler) HandleGetDeviceUsage(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + devices, err := h.queries.GetUserDeviceUsage(ctx, userID) + if err != nil { + return c.JSON(500, map[string]string{"error": err.Error()}) + } + + return c.JSON(200, map[string]interface{}{ + "devices": devices, + }) +} + +// GET /api/analytics/popular-books +// Get most read books +func (h *AnalyticsHandler) HandleGetPopularBooks(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + limit := c.QueryParam("limit") + if limit == "" { + limit = "10" + } + + books, err := h.queries.GetPopularBooks(ctx, database.GetPopularBooksParams{ + UserID: userID, + Limit: parseInt32(limit), + }) + + if err != nil { + return c.JSON(500, map[string]string{"error": err.Error()}) + } + + return c.JSON(200, map[string]interface{}{ + "books": books, + }) +} + +type ReadingStats struct { + TotalBooksRead int `json:"total_books_read"` + TotalPagesRead int `json:"total_pages_read"` + TotalReadingTime int `json:"total_reading_time_minutes"` + AverageSessionTime float64 `json:"average_session_time_minutes"` + LongestSession int `json:"longest_session_minutes"` + MostActiveDay string `json:"most_active_day_of_week"` + CompletionRate float64 `json:"completion_rate"` + DailyReadingMinutes []DailyReading `json:"daily_reading_minutes"` +} + +type DailyReading struct { + Date string `json:"date"` + Minutes int `json:"minutes"` + Pages int `json:"pages"` +} + +func (h *AnalyticsHandler) calculateReadingStats(history []database.ReadingHistory) ReadingStats { + stats := ReadingStats{} + + // Implementation: Calculate stats from history + // ... aggregation logic ... + + return stats +} +``` + +#### 4.2 Add Database Queries + +**File**: `internal/database/queries/queries.sql` + +```sql +-- Get user reading history for analytics +-- name: GetUserReadingHistory :many +SELECT * FROM reading_history +WHERE user_id = @UserID + AND created_at >= @StartDate + AND created_at <= @EndDate +ORDER BY created_at DESC; + +-- Get device usage statistics +-- name: GetUserDeviceUsage :many +SELECT + d.id, + d.device_name, + d.device_type, + COUNT(*) as sync_count, + MAX(ph.created_at) as last_sync +FROM devices d +JOIN progress_history ph ON ph.device_id = d.id +WHERE d.user_id = $1 +GROUP BY d.id, d.device_name, d.device_type +ORDER BY sync_count DESC; + +-- Get most popular books +-- name: GetPopularBooks :many +SELECT + mi.id, + mi.title, + mi.author, + COUNT(*) as read_count, + AVG(ph.percentage) as avg_completion +FROM media_items mi +JOIN reading_progress rp ON rp.media_item_id = mi.id +JOIN progress_history ph ON ph.progress_id = rp.id +WHERE rp.user_id = $1 +GROUP BY mi.id, mi.title, mi.author +ORDER BY read_count DESC +LIMIT $2; +``` + +#### 4.3 Add Frontend Template + +**File**: `templates/analytics.templ` (create new) + +```html +{{ template "header" . }} + +
+

📊 Reading Analytics

+ + +
+ + + +
+ + +
+
+

Books Read

+

-

+
+
+

Pages Read

+

-

+
+
+

Reading Time

+

-

+
+
+

Completion Rate

+

-

+
+
+ + +
+
+

Daily Reading Minutes

+ +
+
+

Device Usage

+ +
+
+ + +
+

Most Read Books

+ +
+
+ + + + +{{ template "footer" . }} +``` + +--- + +## Phase 5: Bulk Operations API + +### Overview + +Add bulk operation endpoints for books, collections, and other resources. Some individual operations exist - this phase adds bulk versions. + +### Implementation Tasks + +#### 5.1 Add Bulk Book Operations + +**File**: `internal/handlers/media_items.go` + +```go +// POST /api/books/bulk-delete +// Bulk delete books +func (h *MediaItemsHandler) HandleBulkDelete(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + BookIDs []pgtype.UUID `json:"book_ids"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + results := make([]map[string]interface{}, 0) + + for _, bookID := range req.BookIDs { + // Check permissions + book, err := h.queries.GetMediaItem(ctx, bookID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": bookID, + "status": "error", + "error": "Book not found", + }) + continue + } + + // Delete book + err = h.queries.DeleteMediaItem(ctx, bookID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": bookID, + "status": "error", + "error": err.Error(), + }) + continue + } + + // Delete file from disk + os.Remove(book.FilePath) + + results = append(results, map[string]interface{}{ + "book_id": bookID, + "status": "success", + }) + } + + return c.JSON(200, map[string]interface{}{ + "results": results, + "total": len(req.BookIDs), + }) +} + +// POST /api/books/bulk-update +// Bulk update book metadata +func (h *MediaItemsHandler) HandleBulkUpdate(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + Updates []struct { + BookID pgtype.UUID `json:"book_id"` + Updates struct { + Title *string `json:"title,omitempty"` + Author *string `json:"author,omitempty"` + Genre *string `json:"genre,omitempty"` + Language *string `json:"language,omitempty"` + Tags *string `json:"tags,omitempty"` + } `json:"updates"` + } `json:"updates"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + results := make([]map[string]interface{}, 0) + + for _, update := range req.Updates { + // Build update params dynamically + updateParams := database.UpdateMediaItemParams{ + ID: update.BookID, + } + + if update.Updates.Title != nil { + updateParams.Title = *update.Updates.Title + } + if update.Updates.Author != nil { + updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} + } + if update.Updates.Genre != nil { + updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} + } + // ... more fields ... + + _, err := h.queries.UpdateMediaItem(ctx, updateParams) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "error", + "error": err.Error(), + }) + continue + } + + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "success", + }) + } + + return c.JSON(200, map[string]interface{}{ + "results": results, + "total": len(req.Updates), + }) +} +``` + +#### 5.2 Add Bulk Collection Operations + +**File**: `internal/handlers/collections.go` + +```go +// POST /api/collections/bulk-add-books +// Bulk add books to multiple collections +func (h *CollectionsHandler) HandleBulkAddBooks(c echo.Context) error { + ctx := c.Request().Context() + userID := c.Get("user_id").(pgtype.UUID) + + var req struct { + Operations []struct { + CollectionID pgtype.UUID `json:"collection_id"` + BookIDs []pgtype.UUID `json:"book_ids"` + } `json:"operations"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request"}) + } + + results := make([]map[string]interface{}, 0) + + for _, op := range req.Operations { + for _, bookID := range op.BookIDs { + _, err := h.queries.CreateCollectionItem(ctx, database.CreateCollectionItemParams{ + CollectionID: op.CollectionID, + MediaItemID: bookID, + AddedByUserID: pgtype.UUID{Bytes: userID.Bytes, Valid: true}, + }) + + if err != nil { + results = append(results, map[string]interface{}{ + "collection_id": op.CollectionID, + "book_id": bookID, + "status": "error", + "error": err.Error(), + }) + continue + } + + results = append(results, map[string]interface{}{ + "collection_id": op.CollectionID, + "book_id": bookID, + "status": "success", + }) + } + } + + return c.JSON(200, map[string]interface{}{ + "results": results, + "total": len(results), + }) +} +``` + +--- + +## Phase 6: WebSocket Real-time Updates + +### Overview + +Verify and complete WebSocket integration for real-time updates. Infrastructure exists but needs testing and potential fixes. + +### Implementation Tasks + +#### 6.1 Verify WebSocket Handler + +**File**: `internal/handlers/websocket.go` + +**Verify implementation exists and test**: + +```go +package handlers + +import ( + "log" + "time" + + "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5/pgtype" +) + +type WebSocketHub struct { + clients map[*WebSocketClient]bool + broadcast chan []byte + register chan *WebSocketClient + unregister chan *WebSocketClient +} + +type WebSocketClient struct { + hub *WebSocketHub + conn *websocket.Conn + send chan []byte + userID pgtype.UUID + deviceID pgtype.UUID +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func NewWebSocketHub() *WebSocketHub { + hub := &WebSocketHub{ + clients: make(map[*WebSocketClient]bool), + broadcast: make(chan []byte), + register: make(chan *WebSocketClient), + unregister: make(chan *WebSocketClient), + } + + go hub.run() + + return hub +} + +func (h *WebSocketHub) run() { + for { + select { + case client := <-h.register: + h.clients[client] = true + log.Printf("Client connected: %s", client.userID) + + case client := <-h.unregister: + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + log.Printf("Client disconnected: %s", client.userID) + } + + case message := <-h.broadcast: + for client := range h.clients { + select { + case client.send <- message: + default: + close(client.send) + delete(h.clients, client) + } + } + } + } +} + +// BroadcastProgressUpdate broadcasts progress updates to all connected clients +func (h *WebSocketHub) BroadcastProgressUpdate(mediaItemID pgtype.UUID, userID pgtype.UUID, percentage float64) { + message := map[string]interface{}{ + "type": "progress_update", + "media_item_id": mediaItemID, + "user_id": userID, + "percentage": percentage, + "timestamp": time.Now().Unix(), + } + + // Marshal and broadcast + // ... implementation ... +} +``` + +#### 6.2 Integrate with Progress Sync + +**Update sync handlers to broadcast progress updates**: + +```go +// In Kobo/KOReader sync handlers, after updating progress: + +// Broadcast to WebSocket +h.wsHub.BroadcastProgressUpdate(mediaItemID, userID, newPercentage) +``` + +#### 6.3 Add WebSocket Test + +**File**: `cmd/server/tests/websocket_test.go` + +```go +package tests + +import ( + "testing" + "time" + "github.com/stretchr/testify/assert" + "gorilla/websocket" +) + +func TestWebSocketConnection(t *testing.T) { + // Connect to WebSocket + wsURL := "ws://localhost:8765/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + assert.NoError(t, err) + defer conn.Close() + + // Test authentication + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth","token":"..."}`)) + + // Wait for progress update + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + + _, message, err := conn.ReadMessage() + assert.NoError(t, err) + assert.Contains(t, string(message), "progress_update") +} +``` + +--- + +## Testing & Documentation + +### Integration Testing + +**File**: `cmd/server/tests/completion_plan_test.go` + +```go +package tests + +func TestFileConversionPipeline(t *testing.T) { + // Test EPUB→KEPUB conversion + // Test dual hash storage + // Test conversion caching +} + +func TestBulkConflictResolution(t *testing.T) { + // Test bulk resolution endpoint + // Test different strategies +} + +func TestAnalyticsEndpoint(t *testing.T) { + // Test reading stats aggregation + // Test device usage calculation +} +``` + +### Documentation Updates + +**File**: `README.md` + +**Add new sections**: +```markdown +## File Conversion +- On-the-fly EPUB→KEPUB conversion +- Dual hash storage for format variants +- Conversion caching with 24-hour TTL + +## Analytics Dashboard +- Reading statistics and trends +- Device usage breakdown +- Popular books tracking + +## Bulk Operations +- Bulk book management +- Bulk conflict resolution +- Bulk collection management +``` + +**File**: `docs/api/COMPLETION_PLAN.md` (create) + +Document all new endpoints with examples. + +--- + +## Phase Order & Dependencies + +### Recommended Implementation Order + +1. **Phase 1 (File Conversion)** - Foundation for OPDS enhancements +2. **Phase 2 (Unlinked Books)** - Improves sync reliability +3. **Phase 3 (Conflict Resolution)** - Enhances sync UX +4. **Phase 6 (WebSocket)** - Verify infrastructure before analytics +5. **Phase 4 (Analytics)** - Depends on stable progress tracking +6. **Phase 5 (Bulk Operations)** - Quality-of-life improvements + +### Testing Checklist + +After each phase: +- [ ] Unit tests pass +- [ ] Integration tests pass +- [ ] Bruno API tests pass +- [ ] Manual testing completed +- [ ] Documentation updated + +--- + +## Configuration Summary + +### Environment Variables + +```bash +# File Conversion +BOOKMANN_CONVERSION_CACHE_DIR=/var/bookmann/cache/kepub +BOOKMANN_CONVERSION_TOOL=/usr/bin/kepubify +BOOKMANN_CONVERSION_CACHE_TTL=24h + +# WebSocket +BOOKMANN_WS_ENABLED=true +BOOKMANN_WS_PORT=8765 + +# Analytics +BOOKMANN_ANALYTICS_RETENTION_DAYS=365 +``` + +### Docker Compose Updates + +```yaml +volumes: + - bookmann-cache:/var/bookmann/cache/kepub +``` + +--- + +## Rollback Plan + +If any phase causes issues: + +1. **File Conversion**: Disable via config, serve pre-converted only +2. **Conflict Resolution**: Use existing individual resolution +3. **Analytics**: Feature flag, can be disabled +4. **WebSocket**: Optional, doesn't break core functionality +5. **Bulk Operations**: Individual operations still work + +All phases are backward-compatible and can be disabled independently. + +--- + +## Success Criteria + +Each phase is complete when: + +- ✅ All endpoints implemented and tested +- ✅ Database queries added and tested +- ✅ Frontend templates updated +- ✅ Bruno tests added +- ✅ Documentation updated +- ✅ No regressions in existing functionality + +--- + +## Notes for AI Implementation + +1. **Preserve Existing Code**: Do not modify existing functionality +2. **Use Existing Patterns**: Follow code style from existing handlers +3. **Database First**: Add queries before implementing handlers +4. **Test Driven**: Write tests alongside implementation +5. **Incremental**: Each phase should be independently deployable +6. **Hash Integrity**: NEVER break SHA-256 matching - always store dual hashes for converted formats + +--- + +## Completion Checklist + +Use this checklist to track progress: + +- [ ] Phase 1: File Conversion Pipeline + - [ ] Conversion service created + - [ ] OPDS handler updated + - [ ] Dual hash storage verified + - [ ] Tests passing + +- [ ] Phase 2: Advanced Unlinked Book Resolution + - [ ] Bulk resolution API + - [ ] Auto-link endpoint + - [ ] Frontend enhancements + - [ ] Tests passing + +- [ ] Phase 3: Conflict Resolution UI & API + - [ ] Resolution endpoints + - [ ] Bulk operations + - [ ] Frontend template + - [ ] Tests passing + +- [ ] Phase 4: Analytics & Reporting Dashboard + - [ ] Analytics API + - [ ] Database queries + - [ ] Frontend template + - [ ] Tests passing + +- [ ] Phase 5: Bulk Operations API + - [ ] Bulk book operations + - [ ] Bulk collection operations + - [ ] Tests passing + +- [ ] Phase 6: WebSocket Real-time Updates + - [ ] WebSocket handler verified + - [ ] Integration with sync + - [ ] Tests passing + +- [ ] Documentation + - [ ] README.md updated + - [ ] API documentation created + - [ ] Deployment guide updated + +--- + +**End of Completion Plan** + +This plan is designed to be implemented by any AI with knowledge of Go, Echo framework, PostgreSQL, and HTMX. Each phase is atomic and can be implemented independently.