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