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

📊 Reading Analytics

Books Read

-

Pages Read

-

Reading Time

-

Completion Rate

-

Daily Reading Minutes

Device Usage

Most Read Books

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