# 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
<!-- Add bulk actions toolbar -->
<div class="flex justify-between items-center mb-6">
    <div>
        <input type="checkbox" id="select-all-unlinked" onchange="toggleAllUnlinked()">
        <label for="select-all-unlinked" class="ml-2">Select All</label>
    </div>
    <div class="flex gap-2">
        <button onclick="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg">
            🤖 Auto-Link Selected (High Confidence)
        </button>
        <button onclick="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg">
            💡 Get Suggestions for Selected
        </button>
        <button onclick="bulkManualLink()" class="btn-primary px-4 py-2 rounded-lg">
            🔗 Bulk Manual Link
        </button>
    </div>
</div>

<!-- Add checkboxes to each book row -->
<div class="book-item flex items-center" id="book-{ book.ProgressID }">
    <input type="checkbox" class="unlinked-checkbox mr-4" data-progress-id="{ book.ProgressID }">
    <!-- existing book details -->
</div>

<!-- Add JavaScript for bulk operations -->
<script>
function toggleAllUnlinked() {
    const selectAll = document.getElementById('select-all-unlinked');
    document.querySelectorAll('.unlinked-checkbox').forEach(cb => {
        cb.checked = selectAll.checked;
    });
}

function getSelectedUnlinked() {
    return Array.from(document.querySelectorAll('.unlinked-checkbox:checked'))
        .map(cb => cb.getAttribute('data-progress-id'));
}

async function bulkAutoLink() {
    const selected = getSelectedUnlinked();
    if (selected.length === 0) {
        alert('Please select at least one book');
        return;
    }

    if (!confirm(`Auto-link ${selected.length} books with high confidence matches?`)) {
        return;
    }

    const response = await fetch('/api/sync/auto-link-books', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            confidence_threshold: 0.8,
            limit: selected.length
        })
    });

    const result = await response.json();
    alert(`Auto-linked ${result.auto_linked} books`);
    location.reload();
}

async function bulkGetSuggestions() {
    const selected = getSelectedUnlinked();
    if (selected.length === 0) {
        alert('Please select at least one book');
        return;
    }

    for (const progressId of selected) {
        const response = await fetch(`/api/sync/unlinked-books/${progressId}/suggestions`);
        const result = await response.json();
        
        // Display suggestions in UI
        displaySuggestions(progressId, result.suggestions);
    }
}

function displaySuggestions(progressId, suggestions) {
    const container = document.getElementById(`matches-${progressId}`);
    if (!container) return;

    container.classList.remove('hidden');
    const listContainer = container.querySelector('.matches-list');
    listContainer.innerHTML = '';

    suggestions.forEach(match => {
        const div = document.createElement('div');
        div.className = 'p-3 border rounded cursor-pointer hover:bg-gray-100';
        div.innerHTML = `
            <div class="flex justify-between">
                <div>
                    <h4 class="font-semibold">${match.title}</h4>
                    <p class="text-sm text-gray-600">${match.author}</p>
                </div>
                <div class="text-right">
                    <div class="text-sm font-semibold">${(match.confidence * 100).toFixed(0)}% confidence</div>
                    <div class="text-xs text-gray-500">${match.match_method}</div>
                </div>
            </div>
        `;
        div.onclick = () => linkBook(progressId, match.media_item_id, match.confidence);
        listContainer.appendChild(div);
    });
}
</script>
```

### 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
<!-- Add bulk actions -->
<div class="flex justify-between items-center mb-6">
    <div>
        <input type="checkbox" id="select-all-conflicts" onchange="toggleAllConflicts()">
        <label for="select-all-conflicts" class="ml-2">Select All</label>
    </div>
    <div class="flex gap-2">
        <select id="bulk-strategy">
            <option value="most_recent">Most Recent</option>
            <option value="highest_progress">Highest Progress</option>
        </select>
        <button onclick="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg">
            ✅ Resolve Selected
        </button>
        <button onclick="bulkDismiss()" class="btn-secondary px-4 py-2 rounded-lg">
            ❌ Dismiss Selected
        </button>
    </div>
</div>

<!-- Add checkboxes to each conflict -->
<div class="conflict-item flex items-center" id="conflict-{ conflict.ID }">
    <input type="checkbox" class="conflict-checkbox mr-4" data-conflict-id="{ conflict.ID }">
    <!-- existing conflict details -->
</div>

<script>
function toggleAllConflicts() {
    const selectAll = document.getElementById('select-all-conflicts');
    document.querySelectorAll('.conflict-checkbox').forEach(cb => {
        cb.checked = selectAll.checked;
    });
}

function getSelectedConflicts() {
    return Array.from(document.querySelectorAll('.conflict-checkbox:checked'))
        .map(cb => cb.getAttribute('data-conflict-id'));
}

async function bulkResolve() {
    const selected = getSelectedConflicts();
    if (selected.length === 0) {
        alert('Please select at least one conflict');
        return;
    }

    const strategy = document.getElementById('bulk-strategy').value;
    
    if (!confirm(`Resolve ${selected.length} conflicts using "${strategy}" strategy?`)) {
        return;
    }

    const response = await fetch('/api/conflicts/bulk-resolve', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            conflict_ids: selected,
            strategy: strategy
        })
    });

    const result = await response.json();
    alert(`Resolved ${result.results.filter(r => r.status === 'success').length} conflicts`);
    location.reload();
}

async function bulkDismiss() {
    const selected = getSelectedConflicts();
    if (selected.length === 0) {
        alert('Please select at least one conflict');
        return;
    }

    if (!confirm(`Dismiss ${selected.length} conflicts?`)) {
        return;
    }

    for (const conflictId of selected) {
        await fetch(`/api/conflicts/${conflictId}/dismiss`, {method: 'POST'});
    }

    alert(`Dismissed ${selected.length} conflicts`);
    location.reload();
}
</script>
```

### 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" . }}

<div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-8">📊 Reading Analytics</h1>

    <!-- Date Range Picker -->
    <div class="flex gap-4 mb-6">
        <input type="date" id="start-date" onchange="loadAnalytics()">
        <input type="date" id="end-date" onchange="loadAnalytics()">
        <button onclick="loadAnalytics()" class="btn-primary px-4 py-2 rounded">Update</button>
    </div>

    <!-- Stats Cards -->
    <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-2">Books Read</h3>
            <p id="total-books" class="text-3xl font-bold">-</p>
        </div>
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-2">Pages Read</h3>
            <p id="total-pages" class="text-3xl font-bold">-</p>
        </div>
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-2">Reading Time</h3>
            <p id="reading-time" class="text-3xl font-bold">-</p>
        </div>
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-2">Completion Rate</h3>
            <p id="completion-rate" class="text-3xl font-bold">-</p>
        </div>
    </div>

    <!-- Charts -->
    <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-4">Daily Reading Minutes</h3>
            <canvas id="daily-reading-chart"></canvas>
        </div>
        <div class="card p-6 rounded-lg">
            <h3 class="text-lg font-semibold mb-4">Device Usage</h3>
            <canvas id="device-usage-chart"></canvas>
        </div>
    </div>

    <!-- Popular Books -->
    <div class="card p-6 rounded-lg">
        <h3 class="text-lg font-semibold mb-4">Most Read Books</h3>
        <div id="popular-books" class="space-y-3">
            <!-- Populated by JavaScript -->
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let dailyChart, deviceChart;

async function loadAnalytics() {
    const startDate = document.getElementById('start-date').value;
    const endDate = document.getElementById('end-date').value;

    // Load reading stats
    const statsResponse = await fetch(`/api/analytics/reading-stats?start_date=${startDate}&end_date=${endDate}`);
    const stats = await statsResponse.json();

    document.getElementById('total-books').textContent = stats.total_books_read;
    document.getElementById('total-pages').textContent = stats.total_pages_read;
    document.getElementById('reading-time').textContent = `${stats.total_reading_time} min`;
    document.getElementById('completion-rate').textContent = `${(stats.completion_rate * 100).toFixed(1)}%`;

    // Render daily reading chart
    renderDailyChart(stats.daily_reading_minutes);

    // Load device usage
    const deviceResponse = await fetch('/api/analytics/device-usage');
    const deviceData = await deviceResponse.json();
    renderDeviceChart(deviceData.devices);

    // Load popular books
    const booksResponse = await fetch('/api/analytics/popular-books?limit=10');
    const booksData = await booksResponse.json();
    renderPopularBooks(booksData.books);
}

function renderDailyChart(dailyData) {
    const ctx = document.getElementById('daily-reading-chart').getContext('2d');
    
    if (dailyChart) dailyChart.destroy();

    dailyChart = new Chart(ctx, {
        type: 'line',
        data: {
            labels: dailyData.map(d => d.date),
            datasets: [{
                label: 'Reading Minutes',
                data: dailyData.map(d => d.minutes),
                borderColor: 'rgb(75, 192, 192)',
                tension: 0.1
            }]
        }
    });
}

function renderDeviceChart(devices) {
    const ctx = document.getElementById('device-usage-chart').getContext('2d');
    
    if (deviceChart) deviceChart.destroy();

    deviceChart = new Chart(ctx, {
        type: 'pie',
        data: {
            labels: devices.map(d => d.device_name),
            datasets: [{
                data: devices.map(d => d.sync_count),
                backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF']
            }]
        }
    });
}

function renderPopularBooks(books) {
    const container = document.getElementById('popular-books');
    container.innerHTML = books.map(book => `
        <div class="flex justify-between items-center p-3 border rounded">
            <div>
                <h4 class="font-semibold">${book.title}</h4>
                <p class="text-sm text-gray-600">${book.author}</p>
            </div>
            <div class="text-right">
                <p class="font-semibold">${book.read_count} reads</p>
                <p class="text-sm text-gray-600">${(book.avg_completion * 100).toFixed(0)}% avg completion</p>
            </div>
        </div>
    `).join('');
}

// Load analytics on page load
loadAnalytics();
</script>

{{ 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.
