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