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.
2152 lines
63 KiB
Markdown
2152 lines
63 KiB
Markdown
# Bookmann Completion Plan: Final 5% Features
|
|
|
|
## Executive Summary
|
|
|
|
This plan implements the final 5% of features from the original IMPLEMENTATION_PLAN.md that were deferred during initial implementation. All features are designed to be atomic, independently testable, and fully integrated with existing code.
|
|
|
|
**Key Design Principle**: All conversions MUST preserve hash integrity for book matching. When converting EPUB→KEPUB, store BOTH hashes in `media_item_formats` table to ensure cross-device matching still works.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Phase 1: File Conversion Pipeline](#phase-1-file-conversion-pipeline)
|
|
2. [Phase 2: Advanced Unlinked Book Resolution](#phase-2-advanced-unlinked-book-resolution)
|
|
3. [Phase 3: Conflict Resolution UI & API](#phase-3-conflict-resolution-ui--api)
|
|
4. [Phase 4: Analytics & Reporting Dashboard](#phase-4-analytics--reporting-dashboard)
|
|
5. [Phase 5: Bulk Operations API](#phase-5-bulk-operations-api)
|
|
6. [Phase 6: WebSocket Real-time Updates](#phase-6-websocket-real-time-updates)
|
|
7. [Testing & Documentation](#testing--documentation)
|
|
|
|
---
|
|
|
|
## Phase 1: File Conversion Pipeline
|
|
|
|
### Overview
|
|
|
|
Implement on-the-fly EPUB→KEPUB conversion with **dual hash storage** to ensure book matching continues to work after format conversion. This is CRITICAL for the cross-device sync system.
|
|
|
|
### Key Requirements
|
|
|
|
1. **Dual Hash Storage**: Store both original EPUB hash AND converted KEPUB hash in `media_item_formats` table
|
|
2. **On-Demand Conversion**: Convert EPUB→KEPUB when requested via OPDS with `?format=kepub`
|
|
3. **Hash Preservation**: After conversion, both hashes are queryable for book matching
|
|
4. **Conversion Caching**: Store converted files to avoid re-conversion
|
|
5. **Format Integrity**: Ensure converted KEPUB maintains all reading progress markers
|
|
|
|
### Architecture
|
|
|
|
```
|
|
User requests book via OPDS with ?format=kepub
|
|
↓
|
|
Check media_item_formats table for existing KEPUB
|
|
↓
|
|
If KEPUB exists and is recent (< 24 hours):
|
|
→ Serve pre-converted file
|
|
→ Set X-Bookmann-KEPUB-SHA256 header
|
|
↓
|
|
If KEPUB doesn't exist or is stale:
|
|
→ Convert EPUB→KEPUB on-the-fly
|
|
→ Calculate SHA-256 of converted KEPUB
|
|
→ Store in media_item_formats (with converted_from_format_id)
|
|
→ Serve converted file
|
|
→ Set X-Bookmann-KEPUB-SHA256 header
|
|
↓
|
|
Device downloads book with hash in response header
|
|
↓
|
|
Device syncs progress using hash for matching
|
|
```
|
|
|
|
### Database Schema
|
|
|
|
**Existing Table** (already in schema.sql):
|
|
```sql
|
|
-- No changes needed - table already supports dual hash storage
|
|
CREATE TABLE media_item_formats (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID REFERENCES media_items(id) ON DELETE CASCADE,
|
|
format_type VARCHAR(10) NOT NULL, -- 'epub', 'kepub', 'pdf', 'cbz'
|
|
file_path VARCHAR(500),
|
|
file_sha256 CHAR(64), -- Hash for THIS format version
|
|
file_size_bytes BIGINT,
|
|
mime_type VARCHAR(100),
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
converted_from_format_id UUID REFERENCES media_item_formats(id), -- Track conversion chain
|
|
UNIQUE(media_item_id, format_type)
|
|
);
|
|
```
|
|
|
|
**Example Data**:
|
|
```
|
|
Row 1: media_item_id=uuid-123, format_type='epub', file_sha256='abc123...'
|
|
Row 2: media_item_id=uuid-123, format_type='kepub', file_sha256='xyz789...', converted_from_format_id=Row1.id
|
|
```
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 1.1 Create Conversion Service
|
|
|
|
**File**: `internal/services/conversion_service.go`
|
|
|
|
```go
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"bookmann/internal/database"
|
|
)
|
|
|
|
type ConversionService struct {
|
|
db *database.Queries
|
|
cacheDir string // e.g., "/var/bookmann/cache/kepub"
|
|
conversionTool string // Path to conversion tool (ebook-convert, kepubify, etc.)
|
|
}
|
|
|
|
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
|
return &ConversionService{
|
|
db: db,
|
|
cacheDir: cacheDir,
|
|
conversionTool: "/usr/bin/kepubify", // Or ebook-convert from Calibre
|
|
}
|
|
}
|
|
|
|
// ConvertEPUBToKEPUB converts EPUB to KEPUB format with hash storage
|
|
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
|
|
// Step 1: Check if KEPUB already exists and is recent
|
|
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
})
|
|
if err == nil {
|
|
// Check if conversion is recent (< 24 hours)
|
|
if time.Since(existing.CreatedAt.Time) < 24*time.Hour {
|
|
return &ConvertedKEPUB{
|
|
Path: existing.FilePath.String,
|
|
SHA256: existing.FileSha256.String,
|
|
Cached: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Step 2: Perform conversion
|
|
kepubPath := filepath.Join(s.cacheDir, fmt.Sprintf("%s.kepub.epub", mediaItemID.String()))
|
|
|
|
if err := s.convertEPUB(epubPath, kepubPath); err != nil {
|
|
return nil, fmt.Errorf("conversion failed: %w", err)
|
|
}
|
|
|
|
// Step 3: Calculate SHA-256 of converted KEPUB
|
|
kepubSHA256, err := s.calculateSHA256(kepubPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash calculation failed: %w", err)
|
|
}
|
|
|
|
// Step 4: Get EPUB format_id for converted_from_format_id
|
|
epubFormat, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "epub",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("EPUB format not found: %w", err)
|
|
}
|
|
|
|
// Step 5: Store converted format in database (dual hash storage)
|
|
fileinfo, _ := os.Stat(kepubPath)
|
|
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
FilePath: pgtype.Text{String: kepubPath, Valid: true},
|
|
FileSha256: pgtype.Text{String: kepubSHA256, Valid: true},
|
|
FileSizeBytes: fileinfo.Size(),
|
|
MimeType: pgtype.Text{String: "application/vnd.kobo+xml+zip", Valid: true},
|
|
ConvertedFromFormatID: pgtype.UUID{Bytes: epubFormat.ID.Bytes, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to store converted format: %w", err)
|
|
}
|
|
|
|
return &ConvertedKEPUB{
|
|
Path: kepubPath,
|
|
SHA256: kepubSHA256,
|
|
Cached: false,
|
|
}, nil
|
|
}
|
|
|
|
// convertEPUB performs the actual EPUB→KEPUB conversion
|
|
func (s *ConversionService) convertEPUB(epubPath, kepubPath string) error {
|
|
// Option 1: Using kepubify (recommended for Kobo)
|
|
cmd := exec.Command(s.conversionTool, "-i", epubPath, "-o", kepubPath)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("kepubify failed: %w, output: %s", err, output)
|
|
}
|
|
|
|
// Option 2: Using Calibre's ebook-convert (fallback)
|
|
// cmd := exec.Command("ebook-convert", epubPath, kepubPath, "--output-format", "kepub")
|
|
|
|
return nil
|
|
}
|
|
|
|
// calculateSHA256 calculates SHA-256 hash of file
|
|
func (s *ConversionService) calculateSHA256(filePath string) (string, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
|
|
hasher := sha256.New()
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
}
|
|
|
|
type ConvertedKEPUB struct {
|
|
Path string
|
|
SHA256 string
|
|
Cached bool // True if served from cache, false if freshly converted
|
|
}
|
|
```
|
|
|
|
#### 1.2 Update OPDS Handler to Use Conversion Service
|
|
|
|
**File**: `internal/handlers/opds.go`
|
|
|
|
**Location**: In the download handler (around line 390-470)
|
|
|
|
**Current Code** (simplified):
|
|
```go
|
|
func (h *Handler) HandleDownload(c echo.Context) error {
|
|
// ... existing code ...
|
|
if format == "kepub" {
|
|
kepubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
})
|
|
if err == nil {
|
|
// Serve pre-converted file
|
|
return c.File(kepubFormat.FilePath.String)
|
|
}
|
|
// Fallback to EPUB
|
|
}
|
|
}
|
|
```
|
|
|
|
**Updated Code**:
|
|
```go
|
|
func (h *Handler) HandleDownload(c echo.Context) error {
|
|
// ... existing validation code ...
|
|
|
|
format := c.QueryParam("format")
|
|
if format == "" {
|
|
format = "epub" // Default format
|
|
}
|
|
|
|
var filePath string
|
|
var fileSHA256 string
|
|
|
|
switch format {
|
|
case "kepub":
|
|
// Step 1: Try to get existing KEPUB format
|
|
kepubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
})
|
|
|
|
if err == nil && kepubFormat.FilePath.Valid {
|
|
// KEPUB exists - serve it
|
|
filePath = kepubFormat.FilePath.String
|
|
fileSHA256 = kepubFormat.FileSha256.String
|
|
} else {
|
|
// KEPUB doesn't exist - convert on-the-fly
|
|
epubFormat, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "epub",
|
|
})
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": "EPUB source not found"})
|
|
}
|
|
|
|
// Use conversion service
|
|
converted, err := h.conversionService.ConvertEPUBToKEPUB(ctx, mediaItemID, epubFormat.FilePath.String)
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": fmt.Sprintf("Conversion failed: %v", err)})
|
|
}
|
|
|
|
filePath = converted.Path
|
|
fileSHA256 = converted.SHA256
|
|
}
|
|
|
|
case "epub", "pdf", "cbz":
|
|
// Serve original format directly
|
|
formatRecord, err := h.queries.GetMediaItemFormatByType(ctx, queries.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: format,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(404, map[string]string{"error": "Format not found"})
|
|
}
|
|
|
|
filePath = formatRecord.FilePath.String
|
|
fileSHA256 = formatRecord.FileSha256.String
|
|
|
|
default:
|
|
return c.JSON(400, map[string]string{"error": "Unsupported format"})
|
|
}
|
|
|
|
// Set response headers with format-specific hash
|
|
c.Response().Header().Set("Content-Type", getContentType(format))
|
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, title, format))
|
|
c.Response().Header().Set("X-Bookmann-UUID", mediaItemID.String)
|
|
|
|
if format == "kepub" {
|
|
c.Response().Header().Set("X-Bookmann-KEPUB-SHA256", fileSHA256)
|
|
} else {
|
|
c.Response().Header().Set("X-Bookmann-SHA256", fileSHA256)
|
|
}
|
|
|
|
return c.Attachment(filePath, title)
|
|
}
|
|
|
|
func getContentType(format string) string {
|
|
switch format {
|
|
case "epub":
|
|
return "application/epub+zip"
|
|
case "kepub":
|
|
return "application/vnd.kobo+xml+zip"
|
|
case "pdf":
|
|
return "application/pdf"
|
|
case "cbz":
|
|
return "application/x-cbr"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 1.3 Register Conversion Service in Dependency Injection
|
|
|
|
**File**: `cmd/server/main.go` (or wherever handlers are initialized)
|
|
|
|
**Add to initialization**:
|
|
```go
|
|
// After database queries initialization
|
|
conversionService := services.NewConversionService(queries, "/var/bookmann/cache/kepub")
|
|
|
|
// Create handler with conversion service
|
|
opdsHandler := handlers.NewOPDSHandler(queries, conversionService, userService)
|
|
```
|
|
|
|
#### 1.4 Update Handler Constructor
|
|
|
|
**File**: `internal/handlers/opds.go`
|
|
|
|
**Update struct and constructor**:
|
|
```go
|
|
type Handler struct {
|
|
queries *database.Queries
|
|
conversionService *services.ConversionService
|
|
userService *services.UserService
|
|
// ... existing fields ...
|
|
}
|
|
|
|
func NewOPDSHandler(queries *database.Queries, conversionService *services.ConversionService, userService *services.UserService) *Handler {
|
|
return &Handler{
|
|
queries: queries,
|
|
conversionService: conversionService,
|
|
userService: userService,
|
|
// ... existing fields ...
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing
|
|
|
|
**File**: `internal/services/conversion_service_test.go`
|
|
|
|
```go
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestConvertEPUBToKEPUB(t *testing.T) {
|
|
// Setup
|
|
db := setupTestDB(t)
|
|
service := NewConversionService(db, t.TempDir())
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create test EPUB
|
|
testEPUB := createTestEPUB(t, "test-book.epub")
|
|
|
|
// Create media item and EPUB format
|
|
mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true}
|
|
_, err := db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "epub",
|
|
FilePath: pgtype.Text{String: testEPUB, Valid: true},
|
|
FileSha256: pgtype.Text{String: "abc123", Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Test conversion
|
|
result, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB)
|
|
|
|
// Assertions
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, result.Path)
|
|
assert.NotEmpty(t, result.SHA256)
|
|
assert.FileExists(t, result.Path)
|
|
assert.False(t, result.Cached) // First conversion should not be cached
|
|
|
|
// Verify dual hash storage
|
|
epubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "epub",
|
|
})
|
|
assert.Equal(t, "abc123", epubFormat.FileSha256.String)
|
|
|
|
kepubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
})
|
|
assert.NotEqual(t, "abc123", kepubFormat.FileSha256.String) // Different hash for KEPUB
|
|
assert.Equal(t, epubFormat.ID, kepubFormat.ConvertedFromFormatID) // Conversion chain
|
|
}
|
|
|
|
func TestConvertCaching(t *testing.T) {
|
|
// Test that subsequent conversions use cache
|
|
db := setupTestDB(t)
|
|
service := NewConversionService(db, t.TempDir())
|
|
|
|
ctx := context.Background()
|
|
mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true}
|
|
testEPUB := createTestEPUB(t, "cached-book.epub")
|
|
|
|
// First conversion
|
|
result1, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB)
|
|
require.NoError(t, err)
|
|
assert.False(t, result1.Cached)
|
|
|
|
// Second conversion (should use cache)
|
|
result2, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB)
|
|
require.NoError(t, err)
|
|
assert.True(t, result2.Cached)
|
|
assert.Equal(t, result1.SHA256, result2.SHA256)
|
|
}
|
|
|
|
func TestConversionChain(t *testing.T) {
|
|
// Test that conversion chain is preserved
|
|
db := setupTestDB(t)
|
|
service := NewConversionService(db, t.TempDir())
|
|
|
|
ctx := context.Background()
|
|
mediaItemID := pgtype.UUID{Bytes: [16]byte{1, 2, 3}, Valid: true}
|
|
testEPUB := createTestEPUB(t, "chain-test.epub")
|
|
|
|
// Create EPUB format
|
|
epubFormat, err := db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "epub",
|
|
FilePath: pgtype.Text{String: testEPUB, Valid: true},
|
|
FileSha256: pgtype.Text{String: "original-epub-hash", Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Convert
|
|
result, err := service.ConvertEPUBToKEPUB(ctx, mediaItemID, testEPUB)
|
|
require.NoError(t, err)
|
|
|
|
// Verify conversion chain
|
|
kepubFormat, _ := db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: "kepub",
|
|
})
|
|
|
|
assert.Equal(t, epubFormat.ID, kepubFormat.ConvertedFromFormatID)
|
|
assert.NotEqual(t, "original-epub-hash", result.SHA256) // Different hash after conversion
|
|
}
|
|
```
|
|
|
|
### Configuration
|
|
|
|
**Environment Variables** (add to `.env` or `system_config`):
|
|
```bash
|
|
# Conversion service configuration
|
|
BOOKMANN_CONVERSION_CACHE_DIR=/var/bookmann/cache/kepub
|
|
BOOKMANN_CONVERSION_TOOL=/usr/bin/kepubify # or /usr/bin/ebook-convert
|
|
BOOKMANN_CONVERSION_CACHE_TTL=24h
|
|
```
|
|
|
|
**Dockerfile Updates** (if using kepubify):
|
|
```dockerfile
|
|
# Install kepubify for EPUB→KEPUB conversion
|
|
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
|
&& chmod +x /usr/bin/kepubify
|
|
|
|
# Or install Calibre for ebook-convert
|
|
# RUN apt-get update && apt-get install -y calibre
|
|
```
|
|
|
|
### Bruno API Tests
|
|
|
|
**File**: `bruno/opds/Download Book KEPUB (On-the-fly Conversion).bru`
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"name": "Download Book KEPUB (On-the-fly Conversion)",
|
|
"type": "http",
|
|
"event": [
|
|
{
|
|
"listen": "test",
|
|
"script": {
|
|
"exec": [
|
|
"// Test format-specific hash header",
|
|
"const kepubHash = resp.headers.get('X-Bookmann-KEPUB-SHA256');",
|
|
"if (kepubHash) {",
|
|
" tests['KEPUB hash present'] = true;",
|
|
" tests['Hash is 64 chars'] = kepubHash.length === 64;",
|
|
"} else {",
|
|
" tests['KEPUB hash present'] = false;",
|
|
"}"
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"req": {
|
|
"url": "{{baseUrl}}/opds/devices/{{deviceId}}/download/{{mediaItemId}}?format=kepub",
|
|
"method": "GET"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Advanced Unlinked Book Resolution
|
|
|
|
### Overview
|
|
|
|
Enhance the unlinked book resolution workflow with bulk operations, better matching UI, and automated suggestions. The backend already stores unlinked books - this phase adds user-friendly workflows.
|
|
|
|
### Current State
|
|
|
|
- ✅ Backend stores `unlinked_books` table
|
|
- ✅ Manual linking endpoint exists: `POST /api/sync/link-book`
|
|
- ✅ Query endpoint exists: `POST /api/sync/books/query`
|
|
- ❌ No bulk resolution workflow
|
|
- ❌ No automated matching suggestions
|
|
- ❌ UI is basic (templates exist but workflow incomplete)
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 2.1 Add Bulk Resolution API
|
|
|
|
**File**: `internal/handlers/book_matching.go`
|
|
|
|
**Add new endpoints**:
|
|
|
|
```go
|
|
// POST /api/sync/bulk-link-books
|
|
// Bulk link multiple unlinked books at once
|
|
func (h *BookMatchingHandler) HandleBulkLinkBooks(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
Links []struct {
|
|
ProgressID pgtype.UUID `json:"progress_id"`
|
|
MediaItemID pgtype.UUID `json:"media_item_id"`
|
|
ConfidenceScore float64 `json:"confidence_score"`
|
|
} `json:"links"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0, len(req.Links))
|
|
|
|
for _, link := range req.Links {
|
|
// Get unlinked record
|
|
unlinked, err := h.queries.GetUnlinkedBookByProgressID(ctx, link.ProgressID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"progress_id": link.ProgressID,
|
|
"status": "error",
|
|
"error": "Unlinked record not found",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Create device file alias
|
|
_, err = h.queries.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
|
MediaItemID: link.MediaItemID,
|
|
DeviceID: unlinked.DeviceID,
|
|
FilePath: unlinked.FilePath.String,
|
|
FileSha256: unlinked.FileSha256,
|
|
ConfidenceScore: link.ConfidenceScore,
|
|
})
|
|
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"progress_id": link.ProgressID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Delete from unlinked_books
|
|
err = h.queries.DeleteUnlinkedBook(ctx, link.ProgressID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"progress_id": link.ProgressID,
|
|
"status": "warning",
|
|
"error": "Linked but failed to delete unlinked record",
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"progress_id": link.ProgressID,
|
|
"status": "success",
|
|
"media_item_id": link.MediaItemID,
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.Links),
|
|
"successful": countSuccessful(results),
|
|
"failed": countFailed(results),
|
|
})
|
|
}
|
|
|
|
// POST /api/sync/auto-link-books
|
|
// Automatically attempt to link unlinked books using matching algorithm
|
|
func (h *BookMatchingHandler) HandleAutoLinkBooks(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
ConfidenceThreshold float64 `json:"confidence_threshold"` // e.g., 0.8
|
|
Limit int `json:"limit"` // Max books to process
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
req.ConfidenceThreshold = 0.8 // Default
|
|
req.Limit = 50 // Default
|
|
}
|
|
|
|
// Get unlinked books
|
|
unlinked, err := h.queries.ListUnlinkedBooks(ctx, database.ListUnlinkedBooksParams{
|
|
UserID: userID,
|
|
Limit: int32(req.Limit),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
|
|
for _, book := range unlinked {
|
|
// Try to match using multiple identifiers
|
|
match, err := h.bookMatchingService.QueryBooks(ctx, services.BookQueryRequest{
|
|
SHA256: book.FileSha256.String,
|
|
Title: book.TitleFromDevice.String,
|
|
Author: "", // Not available in unlinked_books
|
|
Identifiers: []string{},
|
|
})
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if best match meets confidence threshold
|
|
if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold {
|
|
bestMatch := match.Matches[0]
|
|
|
|
// Auto-link
|
|
_, err := h.queries.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
|
MediaItemID: bestMatch.MediaItemID,
|
|
DeviceID: book.DeviceID,
|
|
FilePath: book.FilePath.String,
|
|
FileSha256: book.FileSha256,
|
|
ConfidenceScore: bestMatch.Confidence,
|
|
})
|
|
|
|
if err == nil {
|
|
// Delete from unlinked
|
|
h.queries.DeleteUnlinkedBook(ctx, book.ProgressID)
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"progress_id": book.ProgressID,
|
|
"title": book.TitleFromDevice.String,
|
|
"matched_media_item_id": bestMatch.MediaItemID,
|
|
"confidence": bestMatch.Confidence,
|
|
"match_method": bestMatch.MatchMethod,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"auto_linked": len(results),
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
// GET /api/sync/unlinked-books/suggestions
|
|
// Get matching suggestions for unlinked books
|
|
func (h *BookMatchingHandler) HandleGetSuggestions(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
progressID := c.Param("progressId")
|
|
|
|
// Get unlinked book details
|
|
unlinked, err := h.queries.GetUnlinkedBookByProgressID(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(404, map[string]string{"error": "Unlinked book not found"})
|
|
}
|
|
|
|
// Query for matches
|
|
matches, err := h.bookMatchingService.QueryBooks(ctx, services.BookQueryRequest{
|
|
SHA256: unlinked.FileSha256.String,
|
|
Title: unlinked.TitleFromDevice.String,
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Return suggestions with confidence scores
|
|
return c.JSON(200, map[string]interface{}{
|
|
"progress_id": progressID,
|
|
"title_from_device": unlinked.TitleFromDevice.String,
|
|
"sha256": unlinked.FileSha256.String,
|
|
"suggestions": matches.Matches,
|
|
"total_suggestions": len(matches.Matches),
|
|
})
|
|
}
|
|
```
|
|
|
|
#### 2.2 Update Frontend Template
|
|
|
|
**File**: `templates/unlinked_books.templ`
|
|
|
|
**Add bulk operations UI** (enhance existing template):
|
|
|
|
```html
|
|
<!-- Add bulk actions toolbar -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<input type="checkbox" id="select-all-unlinked" onchange="toggleAllUnlinked()">
|
|
<label for="select-all-unlinked" class="ml-2">Select All</label>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg">
|
|
🤖 Auto-Link Selected (High Confidence)
|
|
</button>
|
|
<button onclick="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg">
|
|
💡 Get Suggestions for Selected
|
|
</button>
|
|
<button onclick="bulkManualLink()" class="btn-primary px-4 py-2 rounded-lg">
|
|
🔗 Bulk Manual Link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add checkboxes to each book row -->
|
|
<div class="book-item flex items-center" id="book-{ book.ProgressID }">
|
|
<input type="checkbox" class="unlinked-checkbox mr-4" data-progress-id="{ book.ProgressID }">
|
|
<!-- existing book details -->
|
|
</div>
|
|
|
|
<!-- Add JavaScript for bulk operations -->
|
|
<script>
|
|
function toggleAllUnlinked() {
|
|
const selectAll = document.getElementById('select-all-unlinked');
|
|
document.querySelectorAll('.unlinked-checkbox').forEach(cb => {
|
|
cb.checked = selectAll.checked;
|
|
});
|
|
}
|
|
|
|
function getSelectedUnlinked() {
|
|
return Array.from(document.querySelectorAll('.unlinked-checkbox:checked'))
|
|
.map(cb => cb.getAttribute('data-progress-id'));
|
|
}
|
|
|
|
async function bulkAutoLink() {
|
|
const selected = getSelectedUnlinked();
|
|
if (selected.length === 0) {
|
|
alert('Please select at least one book');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Auto-link ${selected.length} books with high confidence matches?`)) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/api/sync/auto-link-books', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
confidence_threshold: 0.8,
|
|
limit: selected.length
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
alert(`Auto-linked ${result.auto_linked} books`);
|
|
location.reload();
|
|
}
|
|
|
|
async function bulkGetSuggestions() {
|
|
const selected = getSelectedUnlinked();
|
|
if (selected.length === 0) {
|
|
alert('Please select at least one book');
|
|
return;
|
|
}
|
|
|
|
for (const progressId of selected) {
|
|
const response = await fetch(`/api/sync/unlinked-books/${progressId}/suggestions`);
|
|
const result = await response.json();
|
|
|
|
// Display suggestions in UI
|
|
displaySuggestions(progressId, result.suggestions);
|
|
}
|
|
}
|
|
|
|
function displaySuggestions(progressId, suggestions) {
|
|
const container = document.getElementById(`matches-${progressId}`);
|
|
if (!container) return;
|
|
|
|
container.classList.remove('hidden');
|
|
const listContainer = container.querySelector('.matches-list');
|
|
listContainer.innerHTML = '';
|
|
|
|
suggestions.forEach(match => {
|
|
const div = document.createElement('div');
|
|
div.className = 'p-3 border rounded cursor-pointer hover:bg-gray-100';
|
|
div.innerHTML = `
|
|
<div class="flex justify-between">
|
|
<div>
|
|
<h4 class="font-semibold">${match.title}</h4>
|
|
<p class="text-sm text-gray-600">${match.author}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-sm font-semibold">${(match.confidence * 100).toFixed(0)}% confidence</div>
|
|
<div class="text-xs text-gray-500">${match.match_method}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
div.onclick = () => linkBook(progressId, match.media_item_id, match.confidence);
|
|
listContainer.appendChild(div);
|
|
});
|
|
}
|
|
</script>
|
|
```
|
|
|
|
### Database Queries
|
|
|
|
**File**: `internal/database/queries/queries.sql`
|
|
|
|
**Add missing queries** (if not present):
|
|
|
|
```sql
|
|
-- Get unlinked book by progress ID
|
|
-- name: GetUnlinkedBookByProgressID :one
|
|
SELECT * FROM unlinked_books WHERE progress_id = $1;
|
|
|
|
-- List unlinked books with pagination
|
|
-- name: ListUnlinkedBooks :many
|
|
SELECT * FROM unlinked_books
|
|
WHERE user_id = @UserID
|
|
ORDER BY last_sync_timestamp DESC
|
|
LIMIT @Limit;
|
|
|
|
-- Delete unlinked book
|
|
-- name: DeleteUnlinkedBook :exec
|
|
DELETE FROM unlinked_books WHERE progress_id = $1;
|
|
```
|
|
|
|
### Bruno API Tests
|
|
|
|
**File**: `bruno/sync-kobo/Bulk Link Books.bru`
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"name": "Bulk Link Unlinked Books"
|
|
},
|
|
"req": {
|
|
"url": "{{baseUrl}}/api/sync/bulk-link-books",
|
|
"method": "POST",
|
|
"headers": {
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer {{authToken}}"
|
|
},
|
|
"body": {
|
|
"links": [
|
|
{
|
|
"progress_id": "uuid-1",
|
|
"media_item_id": "uuid-2",
|
|
"confidence_score": 1.0
|
|
},
|
|
{
|
|
"progress_id": "uuid-3",
|
|
"media_item_id": "uuid-4",
|
|
"confidence_score": 0.9
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Conflict Resolution UI & API
|
|
|
|
### Overview
|
|
|
|
Implement user-facing conflict resolution endpoints and UI. The backend already detects and stores conflicts - this phase adds resolution workflows.
|
|
|
|
### Current State
|
|
|
|
- ✅ `sync_conflicts` table exists
|
|
- ✅ Conflicts are detected and stored
|
|
- ❌ No user-facing resolution endpoints
|
|
- ❌ No bulk conflict resolution
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 3.1 Add Conflict Resolution API
|
|
|
|
**File**: `internal/handlers/conflicts.go` (create if doesn't exist)
|
|
|
|
```go
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"bookmann/internal/database"
|
|
)
|
|
|
|
type ConflictsHandler struct {
|
|
queries *database.Queries
|
|
}
|
|
|
|
func NewConflictsHandler(queries *database.Queries) *ConflictsHandler {
|
|
return &ConflictsHandler{queries: queries}
|
|
}
|
|
|
|
// POST /api/conflicts/:conflictId/resolve
|
|
// Resolve a single conflict by choosing a winner
|
|
func (h *ConflictsHandler) HandleResolveConflict(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
conflictID := c.Param("conflictId")
|
|
|
|
var req struct {
|
|
WinningSourceID pgtype.UUID `json:"winning_source_id"` // ID of the progress source to keep
|
|
ResolutionNote string `json:"resolution_note"` // Optional note
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
// Get conflict details
|
|
conflict, err := h.queries.GetConflict(ctx, conflictID)
|
|
if err != nil {
|
|
return c.JSON(404, map[string]string{"error": "Conflict not found"})
|
|
}
|
|
|
|
// Update progress to winning value
|
|
var winningProgress interface{}
|
|
if conflict.Source1ID == req.WinningSourceID {
|
|
winningProgress = conflict.Source1Data
|
|
} else if conflict.Source2ID == req.WinningSourceID {
|
|
winningProgress = conflict.Source2Data
|
|
} else {
|
|
return c.JSON(400, map[string]string{"error": "Invalid winning source ID"})
|
|
}
|
|
|
|
// Apply winning progress
|
|
err = h.applyWinningProgress(ctx, conflict.MediaItemID, userID, winningProgress)
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": "Failed to apply resolution"})
|
|
}
|
|
|
|
// Mark conflict as resolved
|
|
err = h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{
|
|
ConflictID: conflictID,
|
|
Status: "resolved",
|
|
ResolvedBy: pgtype.UUID{Bytes: userID.Bytes, Valid: true},
|
|
ResolutionNote: pgtype.Text{String: req.ResolutionNote, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": "Failed to mark conflict as resolved"})
|
|
}
|
|
|
|
return c.JSON(200, map[string]string{"status": "resolved"})
|
|
}
|
|
|
|
// POST /api/conflicts/bulk-resolve
|
|
// Bulk resolve conflicts using same strategy
|
|
func (h *ConflictsHandler) HandleBulkResolveConflicts(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
ConflictIDs []pgtype.UUID `json:"conflict_ids"`
|
|
Strategy string `json:"strategy"` // "most_recent", "highest_progress", "manual"
|
|
WinningSource pgtype.UUID `json:"winning_source,omitempty"` // For manual strategy
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
|
|
for _, conflictID := range req.ConflictIDs {
|
|
conflict, err := h.queries.GetConflict(ctx, conflictID.String())
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"conflict_id": conflictID,
|
|
"status": "error",
|
|
"error": "Conflict not found",
|
|
})
|
|
continue
|
|
}
|
|
|
|
var winningSource pgtype.UUID
|
|
|
|
// Determine winner based on strategy
|
|
switch req.Strategy {
|
|
case "most_recent":
|
|
if conflict.Source1Timestamp.After(conflict.Source2Timestamp) {
|
|
winningSource = conflict.Source1ID
|
|
} else {
|
|
winningSource = conflict.Source2ID
|
|
}
|
|
case "highest_progress":
|
|
// Parse percentage from source data
|
|
progress1 := h.extractPercentage(conflict.Source1Data)
|
|
progress2 := h.extractPercentage(conflict.Source2Data)
|
|
if progress1 > progress2 {
|
|
winningSource = conflict.Source1ID
|
|
} else {
|
|
winningSource = conflict.Source2ID
|
|
}
|
|
case "manual":
|
|
winningSource = req.WinningSource
|
|
default:
|
|
results = append(results, map[string]interface{}{
|
|
"conflict_id": conflictID,
|
|
"status": "error",
|
|
"error": "Invalid strategy",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Apply resolution
|
|
var winningProgress interface{}
|
|
if winningSource == conflict.Source1ID {
|
|
winningProgress = conflict.Source1Data
|
|
} else {
|
|
winningProgress = conflict.Source2Data
|
|
}
|
|
|
|
err = h.applyWinningProgress(ctx, conflict.MediaItemID, userID, winningProgress)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"conflict_id": conflictID,
|
|
"status": "error",
|
|
"error": "Failed to apply resolution",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Mark as resolved
|
|
h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{
|
|
ConflictID: conflictID.String(),
|
|
Status: "resolved",
|
|
ResolvedBy: pgtype.UUID{Bytes: userID.Bytes, Valid: true},
|
|
})
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"conflict_id": conflictID,
|
|
"status": "success",
|
|
"winner": winningSource.String(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.ConflictIDs),
|
|
})
|
|
}
|
|
|
|
// POST /api/conflicts/:conflictId/dismiss
|
|
// Dismiss a conflict without resolving (keep current state)
|
|
func (h *ConflictsHandler) HandleDismissConflict(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
conflictID := c.Param("conflictId")
|
|
|
|
err := h.queries.UpdateConflictStatus(ctx, database.UpdateConflictStatusParams{
|
|
ConflictID: conflictID,
|
|
Status: "dismissed",
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": "Failed to dismiss conflict"})
|
|
}
|
|
|
|
return c.JSON(200, map[string]string{"status": "dismissed"})
|
|
}
|
|
|
|
// Helper: Apply winning progress to reading_progress table
|
|
func (h *ConflictsHandler) applyWinningProgress(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, progressData interface{}) error {
|
|
// Parse progressData and update reading_progress table
|
|
// Implementation depends on progress data structure
|
|
|
|
// Pseudo-code:
|
|
// var progress ReadingProgress
|
|
// json.Unmarshal(progressData, &progress)
|
|
// h.queries.UpdateReadingProgress(ctx, progress)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper: Extract percentage from progress data
|
|
func (h *ConflictsHandler) extractPercentage(data interface{}) float64 {
|
|
// Parse JSON and extract percentage field
|
|
// Implementation depends on data structure
|
|
return 0.0
|
|
}
|
|
```
|
|
|
|
#### 3.2 Update Frontend Template
|
|
|
|
**File**: `templates/conflicts.templ` (enhance existing)
|
|
|
|
```html
|
|
<!-- Add bulk actions -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<input type="checkbox" id="select-all-conflicts" onchange="toggleAllConflicts()">
|
|
<label for="select-all-conflicts" class="ml-2">Select All</label>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<select id="bulk-strategy">
|
|
<option value="most_recent">Most Recent</option>
|
|
<option value="highest_progress">Highest Progress</option>
|
|
</select>
|
|
<button onclick="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg">
|
|
✅ Resolve Selected
|
|
</button>
|
|
<button onclick="bulkDismiss()" class="btn-secondary px-4 py-2 rounded-lg">
|
|
❌ Dismiss Selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add checkboxes to each conflict -->
|
|
<div class="conflict-item flex items-center" id="conflict-{ conflict.ID }">
|
|
<input type="checkbox" class="conflict-checkbox mr-4" data-conflict-id="{ conflict.ID }">
|
|
<!-- existing conflict details -->
|
|
</div>
|
|
|
|
<script>
|
|
function toggleAllConflicts() {
|
|
const selectAll = document.getElementById('select-all-conflicts');
|
|
document.querySelectorAll('.conflict-checkbox').forEach(cb => {
|
|
cb.checked = selectAll.checked;
|
|
});
|
|
}
|
|
|
|
function getSelectedConflicts() {
|
|
return Array.from(document.querySelectorAll('.conflict-checkbox:checked'))
|
|
.map(cb => cb.getAttribute('data-conflict-id'));
|
|
}
|
|
|
|
async function bulkResolve() {
|
|
const selected = getSelectedConflicts();
|
|
if (selected.length === 0) {
|
|
alert('Please select at least one conflict');
|
|
return;
|
|
}
|
|
|
|
const strategy = document.getElementById('bulk-strategy').value;
|
|
|
|
if (!confirm(`Resolve ${selected.length} conflicts using "${strategy}" strategy?`)) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/api/conflicts/bulk-resolve', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
conflict_ids: selected,
|
|
strategy: strategy
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
alert(`Resolved ${result.results.filter(r => r.status === 'success').length} conflicts`);
|
|
location.reload();
|
|
}
|
|
|
|
async function bulkDismiss() {
|
|
const selected = getSelectedConflicts();
|
|
if (selected.length === 0) {
|
|
alert('Please select at least one conflict');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Dismiss ${selected.length} conflicts?`)) {
|
|
return;
|
|
}
|
|
|
|
for (const conflictId of selected) {
|
|
await fetch(`/api/conflicts/${conflictId}/dismiss`, {method: 'POST'});
|
|
}
|
|
|
|
alert(`Dismissed ${selected.length} conflicts`);
|
|
location.reload();
|
|
}
|
|
</script>
|
|
```
|
|
|
|
### Database Queries
|
|
|
|
**File**: `internal/database/queries/queries.sql`
|
|
|
|
**Add missing queries**:
|
|
|
|
```sql
|
|
-- Get conflict by ID
|
|
-- name: GetConflict :one
|
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
|
|
|
-- Update conflict status
|
|
-- name: UpdateConflictStatus :exec
|
|
UPDATE sync_conflicts
|
|
SET status = $2,
|
|
resolved_by = $3,
|
|
resolution_note = $4,
|
|
resolved_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- List conflicts by user
|
|
-- name: ListConflictsByUser :many
|
|
SELECT * FROM sync_conflicts
|
|
WHERE media_item_id IN (
|
|
SELECT media_item_id FROM reading_progress WHERE user_id = $1
|
|
)
|
|
ORDER BY created_at DESC;
|
|
```
|
|
|
|
### Bruno API Tests
|
|
|
|
**File**: `bruno/conflicts/Bulk Resolve Conflicts.bru`
|
|
|
|
```json
|
|
{
|
|
"meta": {"name": "Bulk Resolve Conflicts"},
|
|
"req": {
|
|
"url": "{{baseUrl}}/api/conflicts/bulk-resolve",
|
|
"method": "POST",
|
|
"body": {
|
|
"conflict_ids": ["uuid-1", "uuid-2", "uuid-3"],
|
|
"strategy": "most_recent"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Analytics & Reporting Dashboard
|
|
|
|
### Overview
|
|
|
|
Implement aggregation endpoints and analytics dashboard using the existing `reading_history` table.
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 4.1 Add Analytics API Endpoints
|
|
|
|
**File**: `internal/handlers/analytics.go` (create new file)
|
|
|
|
```go
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"bookmann/internal/database"
|
|
)
|
|
|
|
type AnalyticsHandler struct {
|
|
queries *database.Queries
|
|
}
|
|
|
|
func NewAnalyticsHandler(queries *database.Queries) *AnalyticsHandler {
|
|
return &AnalyticsHandler{queries: queries}
|
|
}
|
|
|
|
// GET /api/analytics/reading-stats
|
|
// Get reading statistics for a user
|
|
func (h *AnalyticsHandler) HandleGetReadingStats(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
// Get date range from query params
|
|
startDate := c.QueryParam("start_date")
|
|
endDate := c.QueryParam("end_date")
|
|
|
|
if startDate == "" {
|
|
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02") // Default: last 30 days
|
|
}
|
|
if endDate == "" {
|
|
endDate = time.Now().Format("2006-01-02")
|
|
}
|
|
|
|
// Query reading history
|
|
history, err := h.queries.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
|
UserID: userID,
|
|
StartDate: pgtype.Date{Time: parseDate(startDate), Valid: true},
|
|
EndDate: pgtype.Date{Time: parseDate(endDate), Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Calculate statistics
|
|
stats := h.calculateReadingStats(history)
|
|
|
|
return c.JSON(200, stats)
|
|
}
|
|
|
|
// GET /api/analytics/device-usage
|
|
// Get device usage breakdown
|
|
func (h *AnalyticsHandler) HandleGetDeviceUsage(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
devices, err := h.queries.GetUserDeviceUsage(ctx, userID)
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"devices": devices,
|
|
})
|
|
}
|
|
|
|
// GET /api/analytics/popular-books
|
|
// Get most read books
|
|
func (h *AnalyticsHandler) HandleGetPopularBooks(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
limit := c.QueryParam("limit")
|
|
if limit == "" {
|
|
limit = "10"
|
|
}
|
|
|
|
books, err := h.queries.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
|
UserID: userID,
|
|
Limit: parseInt32(limit),
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"books": books,
|
|
})
|
|
}
|
|
|
|
type ReadingStats struct {
|
|
TotalBooksRead int `json:"total_books_read"`
|
|
TotalPagesRead int `json:"total_pages_read"`
|
|
TotalReadingTime int `json:"total_reading_time_minutes"`
|
|
AverageSessionTime float64 `json:"average_session_time_minutes"`
|
|
LongestSession int `json:"longest_session_minutes"`
|
|
MostActiveDay string `json:"most_active_day_of_week"`
|
|
CompletionRate float64 `json:"completion_rate"`
|
|
DailyReadingMinutes []DailyReading `json:"daily_reading_minutes"`
|
|
}
|
|
|
|
type DailyReading struct {
|
|
Date string `json:"date"`
|
|
Minutes int `json:"minutes"`
|
|
Pages int `json:"pages"`
|
|
}
|
|
|
|
func (h *AnalyticsHandler) calculateReadingStats(history []database.ReadingHistory) ReadingStats {
|
|
stats := ReadingStats{}
|
|
|
|
// Implementation: Calculate stats from history
|
|
// ... aggregation logic ...
|
|
|
|
return stats
|
|
}
|
|
```
|
|
|
|
#### 4.2 Add Database Queries
|
|
|
|
**File**: `internal/database/queries/queries.sql`
|
|
|
|
```sql
|
|
-- Get user reading history for analytics
|
|
-- name: GetUserReadingHistory :many
|
|
SELECT * FROM reading_history
|
|
WHERE user_id = @UserID
|
|
AND created_at >= @StartDate
|
|
AND created_at <= @EndDate
|
|
ORDER BY created_at DESC;
|
|
|
|
-- Get device usage statistics
|
|
-- name: GetUserDeviceUsage :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*) as sync_count,
|
|
MAX(ph.created_at) as last_sync
|
|
FROM devices d
|
|
JOIN progress_history ph ON ph.device_id = d.id
|
|
WHERE d.user_id = $1
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY sync_count DESC;
|
|
|
|
-- Get most popular books
|
|
-- name: GetPopularBooks :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
COUNT(*) as read_count,
|
|
AVG(ph.percentage) as avg_completion
|
|
FROM media_items mi
|
|
JOIN reading_progress rp ON rp.media_item_id = mi.id
|
|
JOIN progress_history ph ON ph.progress_id = rp.id
|
|
WHERE rp.user_id = $1
|
|
GROUP BY mi.id, mi.title, mi.author
|
|
ORDER BY read_count DESC
|
|
LIMIT $2;
|
|
```
|
|
|
|
#### 4.3 Add Frontend Template
|
|
|
|
**File**: `templates/analytics.templ` (create new)
|
|
|
|
```html
|
|
{{ template "header" . }}
|
|
|
|
<div class="container mx-auto px-4 py-8">
|
|
<h1 class="text-3xl font-bold mb-8">📊 Reading Analytics</h1>
|
|
|
|
<!-- Date Range Picker -->
|
|
<div class="flex gap-4 mb-6">
|
|
<input type="date" id="start-date" onchange="loadAnalytics()">
|
|
<input type="date" id="end-date" onchange="loadAnalytics()">
|
|
<button onclick="loadAnalytics()" class="btn-primary px-4 py-2 rounded">Update</button>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-2">Books Read</h3>
|
|
<p id="total-books" class="text-3xl font-bold">-</p>
|
|
</div>
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-2">Pages Read</h3>
|
|
<p id="total-pages" class="text-3xl font-bold">-</p>
|
|
</div>
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-2">Reading Time</h3>
|
|
<p id="reading-time" class="text-3xl font-bold">-</p>
|
|
</div>
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-2">Completion Rate</h3>
|
|
<p id="completion-rate" class="text-3xl font-bold">-</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-4">Daily Reading Minutes</h3>
|
|
<canvas id="daily-reading-chart"></canvas>
|
|
</div>
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-4">Device Usage</h3>
|
|
<canvas id="device-usage-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Popular Books -->
|
|
<div class="card p-6 rounded-lg">
|
|
<h3 class="text-lg font-semibold mb-4">Most Read Books</h3>
|
|
<div id="popular-books" class="space-y-3">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
let dailyChart, deviceChart;
|
|
|
|
async function loadAnalytics() {
|
|
const startDate = document.getElementById('start-date').value;
|
|
const endDate = document.getElementById('end-date').value;
|
|
|
|
// Load reading stats
|
|
const statsResponse = await fetch(`/api/analytics/reading-stats?start_date=${startDate}&end_date=${endDate}`);
|
|
const stats = await statsResponse.json();
|
|
|
|
document.getElementById('total-books').textContent = stats.total_books_read;
|
|
document.getElementById('total-pages').textContent = stats.total_pages_read;
|
|
document.getElementById('reading-time').textContent = `${stats.total_reading_time} min`;
|
|
document.getElementById('completion-rate').textContent = `${(stats.completion_rate * 100).toFixed(1)}%`;
|
|
|
|
// Render daily reading chart
|
|
renderDailyChart(stats.daily_reading_minutes);
|
|
|
|
// Load device usage
|
|
const deviceResponse = await fetch('/api/analytics/device-usage');
|
|
const deviceData = await deviceResponse.json();
|
|
renderDeviceChart(deviceData.devices);
|
|
|
|
// Load popular books
|
|
const booksResponse = await fetch('/api/analytics/popular-books?limit=10');
|
|
const booksData = await booksResponse.json();
|
|
renderPopularBooks(booksData.books);
|
|
}
|
|
|
|
function renderDailyChart(dailyData) {
|
|
const ctx = document.getElementById('daily-reading-chart').getContext('2d');
|
|
|
|
if (dailyChart) dailyChart.destroy();
|
|
|
|
dailyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: dailyData.map(d => d.date),
|
|
datasets: [{
|
|
label: 'Reading Minutes',
|
|
data: dailyData.map(d => d.minutes),
|
|
borderColor: 'rgb(75, 192, 192)',
|
|
tension: 0.1
|
|
}]
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderDeviceChart(devices) {
|
|
const ctx = document.getElementById('device-usage-chart').getContext('2d');
|
|
|
|
if (deviceChart) deviceChart.destroy();
|
|
|
|
deviceChart = new Chart(ctx, {
|
|
type: 'pie',
|
|
data: {
|
|
labels: devices.map(d => d.device_name),
|
|
datasets: [{
|
|
data: devices.map(d => d.sync_count),
|
|
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF']
|
|
}]
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderPopularBooks(books) {
|
|
const container = document.getElementById('popular-books');
|
|
container.innerHTML = books.map(book => `
|
|
<div class="flex justify-between items-center p-3 border rounded">
|
|
<div>
|
|
<h4 class="font-semibold">${book.title}</h4>
|
|
<p class="text-sm text-gray-600">${book.author}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="font-semibold">${book.read_count} reads</p>
|
|
<p class="text-sm text-gray-600">${(book.avg_completion * 100).toFixed(0)}% avg completion</p>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Load analytics on page load
|
|
loadAnalytics();
|
|
</script>
|
|
|
|
{{ template "footer" . }}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Bulk Operations API
|
|
|
|
### Overview
|
|
|
|
Add bulk operation endpoints for books, collections, and other resources. Some individual operations exist - this phase adds bulk versions.
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 5.1 Add Bulk Book Operations
|
|
|
|
**File**: `internal/handlers/media_items.go`
|
|
|
|
```go
|
|
// POST /api/books/bulk-delete
|
|
// Bulk delete books
|
|
func (h *MediaItemsHandler) HandleBulkDelete(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
BookIDs []pgtype.UUID `json:"book_ids"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
|
|
for _, bookID := range req.BookIDs {
|
|
// Check permissions
|
|
book, err := h.queries.GetMediaItem(ctx, bookID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"book_id": bookID,
|
|
"status": "error",
|
|
"error": "Book not found",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Delete book
|
|
err = h.queries.DeleteMediaItem(ctx, bookID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"book_id": bookID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Delete file from disk
|
|
os.Remove(book.FilePath)
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"book_id": bookID,
|
|
"status": "success",
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.BookIDs),
|
|
})
|
|
}
|
|
|
|
// POST /api/books/bulk-update
|
|
// Bulk update book metadata
|
|
func (h *MediaItemsHandler) HandleBulkUpdate(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
Updates []struct {
|
|
BookID pgtype.UUID `json:"book_id"`
|
|
Updates struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Author *string `json:"author,omitempty"`
|
|
Genre *string `json:"genre,omitempty"`
|
|
Language *string `json:"language,omitempty"`
|
|
Tags *string `json:"tags,omitempty"`
|
|
} `json:"updates"`
|
|
} `json:"updates"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
|
|
for _, update := range req.Updates {
|
|
// Build update params dynamically
|
|
updateParams := database.UpdateMediaItemParams{
|
|
ID: update.BookID,
|
|
}
|
|
|
|
if update.Updates.Title != nil {
|
|
updateParams.Title = *update.Updates.Title
|
|
}
|
|
if update.Updates.Author != nil {
|
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
|
}
|
|
if update.Updates.Genre != nil {
|
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
|
}
|
|
// ... more fields ...
|
|
|
|
_, err := h.queries.UpdateMediaItem(ctx, updateParams)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"book_id": update.BookID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"book_id": update.BookID,
|
|
"status": "success",
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.Updates),
|
|
})
|
|
}
|
|
```
|
|
|
|
#### 5.2 Add Bulk Collection Operations
|
|
|
|
**File**: `internal/handlers/collections.go`
|
|
|
|
```go
|
|
// POST /api/collections/bulk-add-books
|
|
// Bulk add books to multiple collections
|
|
func (h *CollectionsHandler) HandleBulkAddBooks(c echo.Context) error {
|
|
ctx := c.Request().Context()
|
|
userID := c.Get("user_id").(pgtype.UUID)
|
|
|
|
var req struct {
|
|
Operations []struct {
|
|
CollectionID pgtype.UUID `json:"collection_id"`
|
|
BookIDs []pgtype.UUID `json:"book_ids"`
|
|
} `json:"operations"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
|
|
for _, op := range req.Operations {
|
|
for _, bookID := range op.BookIDs {
|
|
_, err := h.queries.CreateCollectionItem(ctx, database.CreateCollectionItemParams{
|
|
CollectionID: op.CollectionID,
|
|
MediaItemID: bookID,
|
|
AddedByUserID: pgtype.UUID{Bytes: userID.Bytes, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookID,
|
|
"status": "success",
|
|
})
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6: WebSocket Real-time Updates
|
|
|
|
### Overview
|
|
|
|
Verify and complete WebSocket integration for real-time updates. Infrastructure exists but needs testing and potential fixes.
|
|
|
|
### Implementation Tasks
|
|
|
|
#### 6.1 Verify WebSocket Handler
|
|
|
|
**File**: `internal/handlers/websocket.go`
|
|
|
|
**Verify implementation exists and test**:
|
|
|
|
```go
|
|
package handlers
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type WebSocketHub struct {
|
|
clients map[*WebSocketClient]bool
|
|
broadcast chan []byte
|
|
register chan *WebSocketClient
|
|
unregister chan *WebSocketClient
|
|
}
|
|
|
|
type WebSocketClient struct {
|
|
hub *WebSocketHub
|
|
conn *websocket.Conn
|
|
send chan []byte
|
|
userID pgtype.UUID
|
|
deviceID pgtype.UUID
|
|
}
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
}
|
|
|
|
func NewWebSocketHub() *WebSocketHub {
|
|
hub := &WebSocketHub{
|
|
clients: make(map[*WebSocketClient]bool),
|
|
broadcast: make(chan []byte),
|
|
register: make(chan *WebSocketClient),
|
|
unregister: make(chan *WebSocketClient),
|
|
}
|
|
|
|
go hub.run()
|
|
|
|
return hub
|
|
}
|
|
|
|
func (h *WebSocketHub) run() {
|
|
for {
|
|
select {
|
|
case client := <-h.register:
|
|
h.clients[client] = true
|
|
log.Printf("Client connected: %s", client.userID)
|
|
|
|
case client := <-h.unregister:
|
|
if _, ok := h.clients[client]; ok {
|
|
delete(h.clients, client)
|
|
close(client.send)
|
|
log.Printf("Client disconnected: %s", client.userID)
|
|
}
|
|
|
|
case message := <-h.broadcast:
|
|
for client := range h.clients {
|
|
select {
|
|
case client.send <- message:
|
|
default:
|
|
close(client.send)
|
|
delete(h.clients, client)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// BroadcastProgressUpdate broadcasts progress updates to all connected clients
|
|
func (h *WebSocketHub) BroadcastProgressUpdate(mediaItemID pgtype.UUID, userID pgtype.UUID, percentage float64) {
|
|
message := map[string]interface{}{
|
|
"type": "progress_update",
|
|
"media_item_id": mediaItemID,
|
|
"user_id": userID,
|
|
"percentage": percentage,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
|
|
// Marshal and broadcast
|
|
// ... implementation ...
|
|
}
|
|
```
|
|
|
|
#### 6.2 Integrate with Progress Sync
|
|
|
|
**Update sync handlers to broadcast progress updates**:
|
|
|
|
```go
|
|
// In Kobo/KOReader sync handlers, after updating progress:
|
|
|
|
// Broadcast to WebSocket
|
|
h.wsHub.BroadcastProgressUpdate(mediaItemID, userID, newPercentage)
|
|
```
|
|
|
|
#### 6.3 Add WebSocket Test
|
|
|
|
**File**: `cmd/server/tests/websocket_test.go`
|
|
|
|
```go
|
|
package tests
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
"github.com/stretchr/testify/assert"
|
|
"gorilla/websocket"
|
|
)
|
|
|
|
func TestWebSocketConnection(t *testing.T) {
|
|
// Connect to WebSocket
|
|
wsURL := "ws://localhost:8765/ws"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
assert.NoError(t, err)
|
|
defer conn.Close()
|
|
|
|
// Test authentication
|
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth","token":"..."}`))
|
|
|
|
// Wait for progress update
|
|
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
|
|
_, message, err := conn.ReadMessage()
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, string(message), "progress_update")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing & Documentation
|
|
|
|
### Integration Testing
|
|
|
|
**File**: `cmd/server/tests/completion_plan_test.go`
|
|
|
|
```go
|
|
package tests
|
|
|
|
func TestFileConversionPipeline(t *testing.T) {
|
|
// Test EPUB→KEPUB conversion
|
|
// Test dual hash storage
|
|
// Test conversion caching
|
|
}
|
|
|
|
func TestBulkConflictResolution(t *testing.T) {
|
|
// Test bulk resolution endpoint
|
|
// Test different strategies
|
|
}
|
|
|
|
func TestAnalyticsEndpoint(t *testing.T) {
|
|
// Test reading stats aggregation
|
|
// Test device usage calculation
|
|
}
|
|
```
|
|
|
|
### Documentation Updates
|
|
|
|
**File**: `README.md`
|
|
|
|
**Add new sections**:
|
|
```markdown
|
|
## File Conversion
|
|
- On-the-fly EPUB→KEPUB conversion
|
|
- Dual hash storage for format variants
|
|
- Conversion caching with 24-hour TTL
|
|
|
|
## Analytics Dashboard
|
|
- Reading statistics and trends
|
|
- Device usage breakdown
|
|
- Popular books tracking
|
|
|
|
## Bulk Operations
|
|
- Bulk book management
|
|
- Bulk conflict resolution
|
|
- Bulk collection management
|
|
```
|
|
|
|
**File**: `docs/api/COMPLETION_PLAN.md` (create)
|
|
|
|
Document all new endpoints with examples.
|
|
|
|
---
|
|
|
|
## Phase Order & Dependencies
|
|
|
|
### Recommended Implementation Order
|
|
|
|
1. **Phase 1 (File Conversion)** - Foundation for OPDS enhancements
|
|
2. **Phase 2 (Unlinked Books)** - Improves sync reliability
|
|
3. **Phase 3 (Conflict Resolution)** - Enhances sync UX
|
|
4. **Phase 6 (WebSocket)** - Verify infrastructure before analytics
|
|
5. **Phase 4 (Analytics)** - Depends on stable progress tracking
|
|
6. **Phase 5 (Bulk Operations)** - Quality-of-life improvements
|
|
|
|
### Testing Checklist
|
|
|
|
After each phase:
|
|
- [ ] Unit tests pass
|
|
- [ ] Integration tests pass
|
|
- [ ] Bruno API tests pass
|
|
- [ ] Manual testing completed
|
|
- [ ] Documentation updated
|
|
|
|
---
|
|
|
|
## Configuration Summary
|
|
|
|
### Environment Variables
|
|
|
|
```bash
|
|
# File Conversion
|
|
BOOKMANN_CONVERSION_CACHE_DIR=/var/bookmann/cache/kepub
|
|
BOOKMANN_CONVERSION_TOOL=/usr/bin/kepubify
|
|
BOOKMANN_CONVERSION_CACHE_TTL=24h
|
|
|
|
# WebSocket
|
|
BOOKMANN_WS_ENABLED=true
|
|
BOOKMANN_WS_PORT=8765
|
|
|
|
# Analytics
|
|
BOOKMANN_ANALYTICS_RETENTION_DAYS=365
|
|
```
|
|
|
|
### Docker Compose Updates
|
|
|
|
```yaml
|
|
volumes:
|
|
- bookmann-cache:/var/bookmann/cache/kepub
|
|
```
|
|
|
|
---
|
|
|
|
## Rollback Plan
|
|
|
|
If any phase causes issues:
|
|
|
|
1. **File Conversion**: Disable via config, serve pre-converted only
|
|
2. **Conflict Resolution**: Use existing individual resolution
|
|
3. **Analytics**: Feature flag, can be disabled
|
|
4. **WebSocket**: Optional, doesn't break core functionality
|
|
5. **Bulk Operations**: Individual operations still work
|
|
|
|
All phases are backward-compatible and can be disabled independently.
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
Each phase is complete when:
|
|
|
|
- ✅ All endpoints implemented and tested
|
|
- ✅ Database queries added and tested
|
|
- ✅ Frontend templates updated
|
|
- ✅ Bruno tests added
|
|
- ✅ Documentation updated
|
|
- ✅ No regressions in existing functionality
|
|
|
|
---
|
|
|
|
## Notes for AI Implementation
|
|
|
|
1. **Preserve Existing Code**: Do not modify existing functionality
|
|
2. **Use Existing Patterns**: Follow code style from existing handlers
|
|
3. **Database First**: Add queries before implementing handlers
|
|
4. **Test Driven**: Write tests alongside implementation
|
|
5. **Incremental**: Each phase should be independently deployable
|
|
6. **Hash Integrity**: NEVER break SHA-256 matching - always store dual hashes for converted formats
|
|
|
|
---
|
|
|
|
## Completion Checklist
|
|
|
|
Use this checklist to track progress:
|
|
|
|
- [ ] Phase 1: File Conversion Pipeline
|
|
- [ ] Conversion service created
|
|
- [ ] OPDS handler updated
|
|
- [ ] Dual hash storage verified
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Phase 2: Advanced Unlinked Book Resolution
|
|
- [ ] Bulk resolution API
|
|
- [ ] Auto-link endpoint
|
|
- [ ] Frontend enhancements
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Phase 3: Conflict Resolution UI & API
|
|
- [ ] Resolution endpoints
|
|
- [ ] Bulk operations
|
|
- [ ] Frontend template
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Phase 4: Analytics & Reporting Dashboard
|
|
- [ ] Analytics API
|
|
- [ ] Database queries
|
|
- [ ] Frontend template
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Phase 5: Bulk Operations API
|
|
- [ ] Bulk book operations
|
|
- [ ] Bulk collection operations
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Phase 6: WebSocket Real-time Updates
|
|
- [ ] WebSocket handler verified
|
|
- [ ] Integration with sync
|
|
- [ ] Tests passing
|
|
|
|
- [ ] Documentation
|
|
- [ ] README.md updated
|
|
- [ ] API documentation created
|
|
- [ ] Deployment guide updated
|
|
|
|
---
|
|
|
|
**End of Completion Plan**
|
|
|
|
This plan is designed to be implemented by any AI with knowledge of Go, Echo framework, PostgreSQL, and HTMX. Each phase is atomic and can be implemented independently.
|