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