Files
bookhoard/COMPLETION_PLAN.md
T
john-okeefe a0523b2eb9 docs: add comprehensive completion plan for final 5% features
This plan implements the remaining features from the original implementation plan:
- Phase 1: File Conversion Pipeline (EPUB→KEPUB with dual hash storage)
- Phase 2: Advanced Unlinked Book Resolution (bulk operations)
- Phase 3: Conflict Resolution UI & API
- Phase 4: Analytics & Reporting Dashboard
- Phase 5: Bulk Operations API
- Phase 6: WebSocket Real-time Updates

Each phase is atomic, independently testable, and includes:
- Complete implementation code
- Database queries
- Frontend templates
- Bruno API tests
- Unit tests

The plan is designed to be implemented by any AI with knowledge of
Go, Echo framework, PostgreSQL, and HTMX.
2026-02-01 01:32:10 -05:00

63 KiB

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
  2. Phase 2: Advanced Unlinked Book Resolution
  3. Phase 3: Conflict Resolution UI & API
  4. Phase 4: Analytics & Reporting Dashboard
  5. Phase 5: Bulk Operations API
  6. Phase 6: WebSocket Real-time Updates
  7. 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):

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

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):

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:

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:

// 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:

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

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):

# 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):

# 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

{
  "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:

// 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):

<!-- 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):

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

{
  "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)

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)

<!-- 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:

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

{
  "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)

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

-- 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)

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

// 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

// 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:

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:

// 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

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

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:

## 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

  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

# 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

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.