From e14bbeabdde87b31ca3749eac6f4d1261ccee611 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 1 May 2026 14:31:13 -0400 Subject: [PATCH] chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION) --- PANEL_DETECTION_PLAN.md | 777 ---------------------------------------- PROGRESS_MIGRATION.md | 447 ----------------------- 2 files changed, 1224 deletions(-) delete mode 100644 PANEL_DETECTION_PLAN.md delete mode 100644 PROGRESS_MIGRATION.md diff --git a/PANEL_DETECTION_PLAN.md b/PANEL_DETECTION_PLAN.md deleted file mode 100644 index c29dd39..0000000 --- a/PANEL_DETECTION_PLAN.md +++ /dev/null @@ -1,777 +0,0 @@ -# Panel Detection Implementation Plan - -## Overview - -Multi-tier panel detection system with fallback chain: -**OpenCV → ML (COCO-SSD) → Grid → Manual Editor** - -Designed for a constantly growing library - handles any comic style without custom training. - ---- - -## Detection Pipeline - -``` -1. OpenCV Edge Detection (Primary) - ├─ Fast, lightweight (~500KB lazy-loaded) - ├─ Works on 80% of comics with clear panel borders - └─ Future-proof: works on unknown future comics - -2. ML Detection (COCO-SSD Fallback) - ├─ Pre-trained on millions of diverse images - ├─ Handles irregular layouts - └─ ~2MB (TensorFlow.js) + ~2MB (model), lazy-loaded - -3. Grid Detection (Baseline) - └─ Always works as final fallback - -4. Manual Editor (Last Resort) - └─ User manually draws panels -``` - ---- - -## Dependencies - -Add to `package.json`: - -```json -{ - "dependencies": { - "@techstark/opencv-js": "^4.12.0", - "@tensorflow/tfjs": "^4.22.0", - "@tensorflow-models/coco-ssd": "^2.2.3" - } -} -``` - -**Bundle sizes:** -- OpenCV.js: ~500KB (lazy-loaded) -- TensorFlow.js: ~2MB (lazy-loaded) -- COCO-SSD model: ~2MB (lazy-loaded, cached after first load) -- **Total: ~4.5MB** (acceptable for modern networks) - ---- - -## File Structure - -``` -web/src/reader/comic/ -├── panel-detection.service.ts [NEW] - Main detection service with fallback chain -├── panel-detection.opencv.ts [NEW] - OpenCV edge detection -├── panel-detection.ml.ts [NEW] - COCO-SSD ML detection -├── panel-detector.ts [MODIFY] - Add export for grid detection -├── panel-editor.ts [MODIFY] - Add re-detect, connect to service -├── page-cache.ts [OPTIONAL] - On-demand detection -├── background-color.ts [KEEP] -├── chapter-markers.ts [KEEP] -├── page-order.ts [KEEP] -├── page-scrubber.ts [KEEP] -└── panel-gap.ts [KEEP] -``` - ---- - -## Implementation - -### 1. Panel Detection Service (`panel-detection.service.ts`) - -Create this file in `web/src/reader/comic/`: - -```typescript -// Main panel detection service with fallback chain -// Priority: OpenCV → ML → Grid → Manual Editor - -interface DetectionResult { - panels: Panel[]; - method: "opencv" | "ml" | "grid" | "manual"; - confidence: number; -} - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -async function detectPanels( - imageData: ImageData, - allowManual: boolean = true -): Promise { - - // Tier 1: OpenCV Edge Detection - try { - const panels = await detectPanelsOpenCV(imageData); - if (validatePanels(panels, imageData)) { - return { panels, method: "opencv", confidence: 0.85 }; - } - } catch (e) { - console.warn("OpenCV detection failed:", e); - } - - // Tier 2: ML Detection (COCO-SSD) - try { - const panels = await detectPanelsML(imageData); - if (validatePanels(panels, imageData)) { - return { panels, method: "ml", confidence: 0.9 }; - } - } catch (e) { - console.warn("ML detection failed:", e); - } - - // Tier 3: Grid Detection (baseline) - const panels = detectPanelsGrid(imageData); - return { panels, method: "grid", confidence: 0.5 }; -} - -function validatePanels(panels: Panel[], imageData: ImageData): boolean { - // Must have at least 1 panel - if (panels.length === 0) return false; - - // Should not have too many panels (probably noise) - if (panels.length > 30) return false; - - // Panels should cover reasonable area (not all empty space) - let totalArea = panels.reduce((sum, p) => sum + (p.width * p.height), 0); - if (totalArea < 10 || totalArea > 100) return false; - - return true; -} - -// Import detection methods from other files -async function detectPanelsOpenCV(imageData: ImageData): Promise; -async function detectPanelsML(imageData: ImageData): Promise; -function detectPanelsGrid(imageData: ImageData, config?: { rows: number; cols: number }): Panel[]; - -export { detectPanels, DetectionResult, Panel }; -``` - ---- - -### 2. OpenCV Detection (`panel-detection.opencv.ts`) - -Create this file in `web/src/reader/comic/`: - -```typescript -// OpenCV.js-based edge detection for panel boundaries - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -let openCVLoaded = false; - -async function loadOpenCV(): Promise { - if (openCVLoaded) return; - - // OpenCV.js loads asynchronously and registers globally - await import("@techstark/opencv-js"); - - // Wait for OpenCV to be ready - return new Promise((resolve) => { - const check = () => { - if ((window as any).cv && (window as any).cv.Mat) { - openCVLoaded = true; - resolve(); - } else { - setTimeout(check, 50); - } - }; - check(); - }); -} - -async function detectPanelsOpenCV(imageData: ImageData): Promise { - await loadOpenCV(); - - const cv = (window as any).cv; - - // Create matrices from ImageData - const src = cv.matFromImageData(imageData); - const gray = new cv.Mat(); - const blurred = new cv.Mat(); - const edges = new cv.Mat(); - const contours = new cv.Mat(); - const hierarchy = new cv.Mat(); - - try { - // Convert to grayscale - cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0); - - // Apply Gaussian blur to reduce noise - cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT); - - // Detect edges using Canny - cv.Canny(blurred, edges, 50, 150, 3, false); - - // Find contours - cv.findContours( - edges, - contours, - hierarchy, - cv.RETR_EXTERNAL, - cv.CHAIN_APPROX_SIMPLE - ); - - // Convert contours to panels - const panels: Panel[] = []; - const imgWidth = imageData.width; - const imgHeight = imageData.height; - - for (let i = 0; i < contours.size(); i++) { - const rect = cv.boundingRect(contours.get(i)); - const aspectRatio = rect.width / rect.height; - - // Filter: reject very small or very thin contours - const minSize = Math.min(imgWidth, imgHeight) * 0.05; - if (rect.width < minSize || rect.height < minSize) continue; - if (aspectRatio < 0.1 || aspectRatio > 10) continue; - - panels.push({ - id: `opencv-panel-${i}`, - x: (rect.x / imgWidth) * 100, - y: (rect.y / imgHeight) * 100, - width: (rect.width / imgWidth) * 100, - height: (rect.height / imgHeight) * 100, - reading_order: i, - }); - } - - // Sort panels by reading order (top-left to bottom-right) - panels.sort((a, b) => { - const rowA = Math.floor(a.y / 25); - const rowB = Math.floor(b.y / 25); - if (rowA !== rowB) return rowA - rowB; - return a.x - b.x; - }); - - // Reassign reading order after sorting - panels.forEach((p, i) => (p.reading_order = i)); - - return panels; - - } finally { - // Clean up OpenCV matrices - src.delete(); - gray.delete(); - blurred.delete(); - edges.delete(); - contours.delete(); - hierarchy.delete(); - } -} - -export { detectPanelsOpenCV, loadOpenCV }; -``` - ---- - -### 3. ML Detection (`panel-detection.ml.ts`) - -Create this file in `web/src/reader/comic/`: - -```typescript -// ML-based panel detection using COCO-SSD pre-trained model - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -let model: any = null; -let tfLoaded = false; - -async function loadTF(): Promise { - if (tfLoaded) return; - - // Load TensorFlow.js - await import("@tensorflow/tfjs"); - tfLoaded = true; -} - -async function loadModel(): Promise { - if (model) return; - - await loadTF(); - - // Load COCO-SSD model (pre-trained on millions of images) - const cocoSsd = await import("@tensorflow-models/coco-ssd"); - model = await cocoSsd.load({ - base: "lite_mobilenet_v2", // Smaller, faster model - }); -} - -async function detectPanelsML(imageData: ImageData): Promise { - await loadModel(); - - // Create HTMLCanvasElement to run model inference - const canvas = document.createElement("canvas"); - canvas.width = imageData.width; - canvas.height = imageData.height; - const ctx = canvas.getContext("2d")!; - ctx.putImageData(imageData, 0, 0); - - // Run COCO-SSD model - const predictions = await model.detect(canvas); - - // Filter predictions to find rectangular regions (panels) - // COCO-SSD detects common objects, we look for rectangular ones - const panels: Panel[] = []; - const imgWidth = imageData.width; - const imgHeight = imageData.height; - - for (let i = 0; i < predictions.length; i++) { - const pred = predictions[i]; - - // COCO-SSD detects "book" and similar objects - // We filter for reasonable panel-like detections - const [x, y, w, h] = pred.bbox; - const aspectRatio = w / h; - - const isRectangular = - aspectRatio > 0.3 && // Not too tall/thin - aspectRatio < 5 && // Not too wide - w > imgWidth * 0.05 && // Not too small - h > imgHeight * 0.05; - - if (isRectangular) { - panels.push({ - id: `ml-panel-${i}`, - x: (x / imgWidth) * 100, - y: (y / imgHeight) * 100, - width: (w / imgWidth) * 100, - height: (h / imgHeight) * 100, - reading_order: i, - }); - } - } - - // Sort panels by reading order - panels.sort((a, b) => { - const rowA = Math.floor(a.y / 25); - const rowB = Math.floor(b.y / 25); - if (rowA !== rowB) return rowA - rowB; - return a.x - b.x; - }); - - panels.forEach((p, i) => (p.reading_order = i)); - - return panels; -} - -export { detectPanelsML, loadModel }; -``` - ---- - -### 4. Grid Detection (`panel-detector.ts` - Update) - -Modify the existing `panel-detector.ts` to add the export at the end: - -```typescript -// Grid-based panel detection (fast, lightweight) -// Keep as final fallback - -interface Panel { - id: string; - x: number; - y: number; - width: number; - height: number; - reading_order: number; -} - -interface GridConfig { - rows: number; - cols: number; -} - -function detectPanelsGrid( - imageData: ImageData, - config: GridConfig = { rows: 3, cols: 3 }, -): Panel[] { - const panels: Panel[] = []; - const cellWidth = imageData.width / config.cols; - const cellHeight = imageData.height / config.rows; - - for (let y = 0; y < config.rows; y++) { - for (let x = 0; x < config.cols; x++) { - const cell = extractCell(imageData, x, y, cellWidth, cellHeight); - - if (!isEmpty(cell)) { - panels.push({ - id: `panel-${panels.length}`, - x: (x / config.cols) * 100, - y: (y / config.rows) * 100, - width: (1 / config.cols) * 100, - height: (1 / config.rows) * 100, - reading_order: panels.length, - }); - } - } - } - - return mergeAdjacentPanels(panels); -} - -function isEmpty(cellData: ImageData): boolean { - let emptyPixels = 0; - const totalPixels = cellData.width * cellData.height; - const threshold = 0.95; - - for (let i = 0; i < cellData.data.length; i += 4) { - const r = cellData.data[i]; - const g = cellData.data[i + 1]; - const b = cellData.data[i + 2]; - const a = cellData.data[i + 3]; - - if (a < 10 || (r > 250 && g > 250 && b > 250)) { - emptyPixels++; - } - } - - return emptyPixels / totalPixels > threshold; -} - -function mergeAdjacentPanels(panels: Panel[]): Panel[] { - const merged: Panel[] = []; - const used = new Set(); - - for (let i = 0; i < panels.length; i++) { - if (used.has(i)) continue; - - let current = { ...panels[i] }; - used.add(i); - - for (let j = i + 1; j < panels.length; j++) { - if (used.has(j)) continue; - if (isAdjacent(current, panels[j])) { - current = mergePanels(current, panels[j]); - used.add(j); - } - } - - merged.push(current); - } - - return merged; -} - -function extractCell( - imageData: ImageData, - gridX: number, - gridY: number, - cellWidth: number, - cellHeight: number, -): ImageData { - const startX = Math.floor(gridX * cellWidth); - const startY = Math.floor(gridY * cellHeight); - const width = Math.floor(cellWidth); - const height = Math.floor(cellHeight); - - const cellData = new Uint8ClampedArray(width * height * 4); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4; - const destIdx = (y * width + x) * 4; - cellData[destIdx] = imageData.data[srcIdx]; - cellData[destIdx + 1] = imageData.data[srcIdx + 1]; - cellData[destIdx + 2] = imageData.data[srcIdx + 2]; - cellData[destIdx + 3] = imageData.data[srcIdx + 3]; - } - } - - return new ImageData(cellData, width, height); -} - -function isAdjacent(p1: Panel, p2: Panel): boolean { - const tolerance = 5; - if (Math.abs(p1.y - p2.y) < tolerance && Math.abs(p1.height - p2.height) < tolerance) { - return Math.abs(p1.x + p1.width - p2.x) < tolerance || Math.abs(p2.x + p2.width - p1.x) < tolerance; - } - if (Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.width - p2.width) < tolerance) { - return Math.abs(p1.y + p1.height - p2.y) < tolerance || Math.abs(p2.y + p2.height - p1.y) < tolerance; - } - return false; -} - -function mergePanels(p1: Panel, p2: Panel): Panel { - const minX = Math.min(p1.x, p2.x); - const minY = Math.min(p1.y, p2.y); - const maxX = Math.max(p1.x + p1.width, p2.x + p2.width); - const maxY = Math.max(p1.y + p1.height, p2.y + p2.height); - - return { - id: p1.id, - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - reading_order: Math.min(p1.reading_order, p2.reading_order), - }; -} - -// ADD THIS EXPORT AT THE END OF THE FILE -export { detectPanelsGrid, isEmpty, mergeAdjacentPanels, extractCell, isAdjacent, mergePanels }; -``` - ---- - -### 5. Panel Editor Updates (`panel-editor.ts`) - -Modify the existing `panel-editor.ts` to add imports and re-detect function: - -```typescript -// Manual panel editor for admins/power users - -import { Alpine } from "../../alpine"; -import { apiPut } from "../../api"; -import { detectPanels, Panel } from "./panel-detection.service"; - -async function loadImageForPage(pageNumber: number): Promise { - const mediaItemId = document.body.dataset.mediaItemId; - if (!mediaItemId) { - throw new Error("No mediaItemId found"); - } - - const token = localStorage.getItem("token"); - const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`Failed to load page ${pageNumber}`); - } - - const blob = await response.blob(); - const img = new Image(); - img.src = URL.createObjectURL(blob); - - await new Promise((resolve) => { - img.onload = () => resolve(); - }); - - return img; -} - -function getCurrentPageNumber(): number { - const Alpine = (window as any).Alpine; - if (Alpine) { - const readerEl = document.querySelector('[x-data="readerShell"]'); - if (readerEl) { - const readerShell = Alpine.$data(readerEl); - if (readerShell?.currentPage) { - return readerShell.currentPage; - } - } - } - - const content = document.getElementById("reader-content"); - const pageFromDataset = content?.dataset.currentPage; - if (pageFromDataset) { - return parseInt(pageFromDataset, 10); - } - - return 1; -} - -function loadPage(pageNumber: number): void { - window.dispatchEvent( - new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }), - ); -} - -function openPanelEditor(pageNumber: number): void { - const modal = document.getElementById("panel-editor-modal"); - modal?.classList.remove("hidden"); - - const canvas = document.getElementById("panel-editor-canvas") as HTMLCanvasElement; - const ctx = canvas?.getContext("2d"); - - loadImageForPage(pageNumber).then((image) => { - canvas!.width = image.width; - canvas!.height = image.height; - ctx?.drawImage(image, 0, 0); - - enablePanelDrawing(canvas!); - }); -} - -function enablePanelDrawing(canvas: HTMLCanvasElement): void { - let isDrawing = false; - let startX = 0; - let startY = 0; - - canvas.addEventListener("mousedown", (e) => { - isDrawing = true; - startX = e.offsetX; - startY = e.offsetY; - }); - - canvas.addEventListener("mousemove", (e) => { - if (!isDrawing) return; - - const ctx = canvas.getContext("2d"); - // Clear and redraw to show selection rectangle - ctx?.clearRect(0, 0, canvas.width, canvas.height); - ctx?.drawImage(canvas, 0, 0); - ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY); - }); - - canvas.addEventListener("mouseup", (e) => { - if (!isDrawing) return; - isDrawing = false; - - const panel: Panel = { - id: `manual-${Date.now()}`, - x: (startX / canvas.width) * 100, - y: (startY / canvas.height) * 100, - width: ((e.offsetX - startX) / canvas.width) * 100, - height: ((e.offsetY - startY) / canvas.height) * 100, - reading_order: 0, - }; - - saveManualPanel(panel); - }); -} - -async function saveManualPanel(panel: Panel): Promise { - const mediaItemId = document.body.dataset.mediaItemId; - const pageNumber = getCurrentPageNumber(); - - await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, { - detection_method: "manual", - panels: [panel], - }); - - loadPage(pageNumber); -} - -// Re-detect panels using detection service -async function reDetectPanels(pageNumber: number): Promise { - const image = await loadImageForPage(pageNumber); - - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const result = await detectPanels(imageData, true); - - return result.panels; -} - -// Alpine component -Alpine.data("panelEditor", () => ({ - get isComicOrManga(): boolean { - const libraryType = document.body.dataset.mediaType; - return libraryType === "comic" || libraryType === "manga"; - }, - - openPanelEditor(pageNumber: number) { - openPanelEditor(pageNumber); - }, - - async reDetectPanels(pageNumber: number) { - const panels = await reDetectPanels(pageNumber); - return panels; - } -})); - -export { openPanelEditor, reDetectPanels }; -``` - ---- - -### 6. Page Cache Integration (`page-cache.ts` - Optional) - -Optional: Add on-demand panel detection to page-cache.ts: - -```typescript -// Add this import at the top -import { detectPanels } from "./panel-detection.service"; - -// Add to PageCacheState interface -interface PageCacheState { - cache: Map; - loading: Set; - maxAhead: number; - mediaItemId: string; - panelData: Map; -} - -// Add this function -async function detectPagePanels( - state: PageCacheState, - pageNumber: number -): Promise { - // Check if already detected - if (state.panelData?.has(pageNumber)) { - return state.panelData.get(pageNumber)!.panels; - } - - // Get or create image - let image: HTMLImageElement; - if (state.cache.has(pageNumber)) { - image = state.cache.get(pageNumber)!; - } else { - image = await loadComicPage(state, pageNumber); - } - - // Run detection on demand - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - - const result = await detectPanels(imageData, true); - - if (!state.panelData) { - state.panelData = new Map(); - } - state.panelData.set(pageNumber, result); - - return result.panels; -} - -// Export the new function -export { createPageCache, getCachedPage, loadComicPage, detectPagePanels }; -``` - ---- - -## Implementation Order - -1. **Add dependencies to `package.json`** and run `npm install` -2. **Create `panel-detection.service.ts`** -3. **Create `panel-detection.opencv.ts`** -4. **Create `panel-detection.ml.ts`** -5. **Update `panel-detector.ts`** - add export statement (one line at the end) -6. **Update `panel-editor.ts`** - add imports and re-detect function -7. **(Optional) Update `page-cache.ts`** - add on-demand detection - ---- - -## Future Enhancements - -1. **User feedback loop:** Store user corrections to improve detection -2. **Per-comic detection:** Different methods for different comic styles -3. **Batch detection:** Pre-detect pages in background -4. **Detection history:** Track which method works best per comic -5. **Panel preview:** Show detected panels before entering panel view diff --git a/PROGRESS_MIGRATION.md b/PROGRESS_MIGRATION.md deleted file mode 100644 index 92acbda..0000000 --- a/PROGRESS_MIGRATION.md +++ /dev/null @@ -1,447 +0,0 @@ -# Universal Progress Service Migration Plan - -## Goal - -Consolidate all progress-saving handlers into a single `ProgressService` that: -- Merges new data with existing progress (preventing data loss across client switches) -- Enriches progress with computed fields (e.g., character_offset from percentage) -- Detects conflicts between different sync sources -- Broadcasts updates via WebSocket -- Is called by all clients: web reader, KOReader, Kobo - -## Architecture - -``` -Client Request → HTTP Handler (thin) → ProgressService.SaveProgress() - ↓ - 1. Read existing progress from DB - 2. Merge new data over existing (keep unset fields) - 3. Enrich (compute missing fields) - 4. Conflict detection - 5. Upsert enriched progress to DB - 6. WebSocket broadcast -``` - -## Current State: Three Separate Handlers Writing Progress - -| Route | Handler | Auth | What it does | -|---|---|---|---| -| `PUT /api/media-items/:id/progress` | `MediaHandler.UpdateMediaReadingProgress` | JWT | Raw upsert, 4 fields only (percentage, current_page, total_pages, epubcfi). ALL other fields set to NULL. | -| `POST /api/progress/:id` | `Handler.UpdateUniversalProgress` (ScannerHandler) | JWT | Page→percentage conversion, WebSocket broadcast. Still nulls unset fields. | -| `POST /api/sync/koreader/progress` | `KOReaderHandler.SyncProgress` | Device token | Book resolution (UUID→hash→path→title), conflict detection, checkpoint mode, WebSocket broadcast. Sets chapter/character_offset but nulls viewport/zoom/panel. | -| `POST /api/sync/kobo/:token/markup` | `KoboHandler.Markup` | Device token | ContentId mapping, ReadingSync + BookmarkSync. `last-read-place` only sets epubcfi/chapter, NULLs everything else. | -| `POST /api/sync/kobo/:token/v1/analytics/gettests` | `KoboHandler.AnalyticsGettests` | Device token | Same as ReadingSync in Markup. | -| `POST /api/sync/kobo/:token/sync-from-server` | `KoboHandler.SyncFromServer` | Device token | Same pattern, `last_sync_source = "bookhoard"`. | - -### Critical Bug in Current Code (Data Loss) - -`UpdateUniversalProgress` SQL uses `ON CONFLICT DO UPDATE SET ... = EXCLUDED.*` — it replaces ALL fields. Any field passed as `Valid: false` (NULL) overwrites whatever was previously stored. - -**This means every cross-client save loses data.** Examples: -- KOReader saves character_offset → web reader saves → character_offset becomes NULL -- Kobo saves percentage → Kobo sends last-read-place → percentage becomes NULL -- KOReader saves chapter → web reader saves → chapter becomes NULL - -The merge approach in this migration fixes this. - -## Read Routes - -| Route | Handler | Returns | -|---|---|---| -| `GET /api/media-items/:id/progress` | `MediaHandler.GetMediaReadingProgress` | Raw `ReadingProgress` struct | -| `GET /api/progress/:id` | `Handler.GetUniversalProgress` | Enriched with `format_group`, `total_characters`, `chapter_count` from media_items JOIN | -| `GET /api/progress/:id/history` | `Handler.GetProgressHistory` | Reading history array | -| `GET /progress` (frontend) | Inline in `frontend.go` | Progress overview page, calls `GetAllProgressData` | - -## Existing Bugs to Fix During Migration - -### KOReader handler (`internal/handlers/koreader.go`) -1. **Line ~556:** `UpdateDeviceLastSync` called with zero UUID `pgtype.UUID{Bytes: [16]byte{}, Valid: false}` instead of actual device ID. The correct call already exists in `SyncProgress` at line ~201. Remove the duplicate. -2. **Line ~549:** `SourceDevice.ID` set to `uuid.UUID(userID.Bytes).String()` (user ID) instead of device ID. Device ID is available from the device context but not passed through to `updateProgressForBook`. -3. **`ChapterProgress` always set to `book.Percentage`** (overall book progress), not chapter-relative. Fix: only set if KOReader provides it explicitly, otherwise preserve existing value via merge. -4. **Dead code:** `conflicts` response field is initialized but never populated. This is intentional — conflicts are only shown in web UI, not returned to devices. No change needed. - -### Kobo handler (`internal/handlers/kobo.go`) -5. **`last-read-place` (line ~487):** `epubcfi` passed as `Valid: true` even when empty string (BookmarkId doesn't start with `epubcfi(`). Fix: only set `Valid: true` if non-empty after stripping. -6. **`calculateFileSHA256` function:** Defined but never called. Dead code — remove. -7. **`parseKoboDeviceHeader` function:** Defined but never called in kobo.go (may be used by middleware). Verify before removing. -8. **`GetLibrary` bookmark_count:** Counts ALL annotations (highlights + notes + bookmarks), not just bookmarks. Known issue, fix separately. - -### Media handler (`internal/handlers/media.go`) -9. **`UpdateMediaReadingProgress`:** Sets `CharacterOffset`, `Chapter`, `ChapterProgress`, `ViewportX/Y`, `ZoomLevel`, `ScrollPositionX/Y`, `PanelNumber`, `ReadingMode` all to `Valid: false` — nulls them. Fixed by merge approach. - -### Universal progress handler (`internal/handlers/progress.go`) -10. **`UpdateUniversalProgress`:** Also sets `ChapterProgress = percentage` (book-wide, not chapter-relative). Same bug as KOReader. Fixed by merge approach. - -### Sync infrastructure -11. **`OfflineDetector`** (`internal/sync/offline.go`): Fully implemented but never started in `cmd/server/main.go`. Not part of this migration, but noted. -12. **Queue processor `syncNote`/`syncHighlight`** (`internal/sync/queue.go`): Stub methods, not implemented. Not part of this migration. -13. **`reading_progress.conflict_detected` column:** Never set to `true` by any handler. The `sync_conflicts` table records conflicts, but the boolean on the progress row stays false. The SQL upsert doesn't include this column in the `DO UPDATE SET` clause. Schema fix needed separately. - -## New Code: ProgressService - -### Location: `internal/sync/progress.go` (add to existing file) - -### Struct - -```go -type ProgressService struct { - db *database.Queries - connManager *ConnectionManager -} - -func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService -``` - -### Input Struct - -```go -type SaveProgressRequest struct { - MediaItemID pgtype.UUID - UserID pgtype.UUID - Source string // "web", "koreader", "kobo", "bookhoard" - DeviceID pgtype.UUID // for conflict detection context - - // All pointer fields — nil means "don't change existing value" - Percentage *float64 - Epubcfi *string - CharacterOffset *int64 - Chapter *int - ChapterProgress *float64 - CurrentPage *int - TotalPages *int - ViewportX *float64 - ViewportY *float64 - ZoomLevel *float64 - ScrollX *float64 - ScrollY *float64 - PanelNumber *int - ReadingMode *string - - // For broadcast and conflict detection - DeviceType string - DeviceName string -} -``` - -### SaveProgress Logic (pseudocode) - -``` -func SaveProgress(ctx, req) (ReadingProgress, error): - - // 1. Get media item metadata (for enrichment) - mediaItem = db.GetMediaItem(ctx, req.MediaItemID) - - // 2. Read existing progress - existing, err = db.GetReadingProgress(ctx, {MediaItemID, UserID}) - if err == pgx.ErrNoRows: - existing = empty defaults - else if err != nil: - return err - - // 3. Merge: build UpdateUniversalProgressParams by starting with - // existing values, then overwriting with any non-nil fields from req - params = buildParamsFromExisting(existing) - params = mergeRequestOverParams(params, req) - - // 4. Enrich missing fields - if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0: - charOffset = PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64) - params.CharacterOffset = {Int64: charOffset, Valid: true} - - if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid: - page = PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32)) - params.CurrentPage = {Int32: int32(page), Valid: true} - - // (Future: generate CFI from character_offset using EPUB parser) - - // 5. Set sync metadata - params.MediaItemID = req.MediaItemID - params.UserID = req.UserID - params.LastSyncDevice = {String: req.DeviceType, Valid: true} - params.LastSyncSource = {String: req.Source, Valid: true} - - // 6. Conflict detection - if existing exists AND existing.LastSyncSource.Valid: - if existing.LastSyncSource.String != req.Source AND existing.LastSyncTimestamp.Valid: - if time.Since(existing.LastSyncTimestamp.Time) < 5*time.Minute: - pctDiff = abs(params.Percentage.Float64 - existing.Percentage.Float64) - if pctDiff > 0.01: - // Record conflict - conflictData = buildConflictData(existing, req) - db.CreateSyncConflict(ctx, {MediaItemID, UserID, "progress", conflictData}) - connManager.BroadcastConflictNotification(mediaItemID, "detection", "") - - // 7. Upsert - result, err = db.UpdateUniversalProgress(ctx, params) - if err != nil: - return err - - // 8. Broadcast - deviceName = req.DeviceName - if deviceName == "": deviceName = req.DeviceType + " Device" - connManager.BroadcastProgressUpdate( - mediaItemID, - params.Percentage.Float64, - SourceDevice{ID: req.DeviceID, Name: deviceName, Type: req.Source}, - ) - - return result, nil -``` - -### Merge Logic Detail - -The `buildParamsFromExisting` function reads every field from the existing `ReadingProgress` row into `UpdateUniversalProgressParams`. - -The `mergeRequestOverParams` function only overwrites a field if the corresponding pointer in `SaveProgressRequest` is non-nil. - -This ensures: -- Web reader sends percentage + epubcfi + current_page + total_pages → character_offset from KOReader's last save is preserved -- KOReader sends percentage + character_offset + chapter → epubcfi from web reader's last save is preserved -- Kobo sends only percentage → everything else preserved -- Kobo sends epubcfi + chapter (last-read-place) → percentage from previous ReadingSync preserved - -## File Changes - -### Modified Files - -| File | Change | -|---|---| -| `internal/sync/progress.go` | Add `ProgressService` struct, `NewProgressService`, `SaveProgress`, merge/enrich helpers | -| `internal/handlers/media.go` | `UpdateMediaReadingProgress`: parse richer request, call `ProgressService.SaveProgress`. `GetMediaReadingProgress`: use `GetUniversalProgress` query for enriched response. `MediaHandler` struct: add `progressService` field. Constructor: accept `ProgressService`. | -| `internal/handlers/koreader.go` | `updateProgressForBook`: replace raw upsert with `ProgressService.SaveProgress` call. Fix `SourceDevice.ID` bug (use device ID). Remove duplicate `UpdateDeviceLastSync` with zero UUID. `enqueueProgressForBook`: update `ProgressUpdate` struct if needed. `KOReaderHandler` struct: add `progressService` field. Constructor: accept `ProgressService`. | -| `internal/handlers/kobo.go` | `Markup` ReadingSync: call `ProgressService.SaveProgress`. `Markup` last-read-place: call `ProgressService.SaveProgress` (merge preserves percentage). `AnalyticsGettests`: same. `SyncFromServer`: same. `KoboHandler` struct: add `progressService` field. Constructor: accept `ProgressService`. | -| `internal/handlers/progress.go` | Remove `UpdateUniversalProgress` method. Keep `GetUniversalProgress`, `GetAllProgressData`, `GetProgressHistory`. | -| `internal/sync/queue.go` | `syncProgress` method: call `ProgressService.SaveProgress` instead of raw `db.UpdateUniversalProgress`. `SyncQueueProcessor` struct: add `progressService` field. | -| `internal/router/media.go` | Add `GET /media-items/:id/progress/history` route. Remove "Legacy" comment from progress routes. | -| `internal/router/progress.go` | **DELETE THIS FILE** — routes moved to media.go or removed. | -| `internal/router/router.go` | Remove `registerProgressRoutes` call. Create `ProgressService` and inject into `MediaHandler`, `KOReaderHandler`, `KoboHandler`, `SyncQueueProcessor`. | -| `cmd/server/main.go` | Create `ProgressService` after `connManager` and `queueProcessor` creation. Pass to handler constructors. | -| `web/src/reader/reader.ts` | `saveProgress`: send richer payload (add chapter, chapter_progress, reading_mode, zoom_level, etc.) | - -### Deleted Files - -| File | Why | -|---|---| -| `internal/router/progress.go` | All routes moved to `media.go` or removed | - -### Dead Code to Remove - -| What | Where | -|---|---| -| `UpdateReadingProgress` query | `queries/queries.sql` (source) + `queries.sql.go` + `querier.go` (generated) | -| `registerProgressRoutes` function | `router/progress.go` (file deleted) | -| `calculateFileSHA256` function | `handlers/kobo.go` — never called | -| `parseKoboDeviceHeader` function | `handlers/kobo.go` — verify it's not used by middleware before removing | - -## Route Changes - -### Before - -| Method | Route | Handler | Auth | -|---|---|---|---| -| GET | `/api/media-items/:id/progress` | `MediaHandler.GetMediaReadingProgress` | JWT | -| PUT | `/api/media-items/:id/progress` | `MediaHandler.UpdateMediaReadingProgress` | JWT | -| DELETE | `/api/media-items/:id/progress` | `MediaHandler.DeleteMediaReadingProgress` | JWT | -| GET | `/api/progress/:id` | `Handler.GetUniversalProgress` | JWT | -| POST | `/api/progress/:id` | `Handler.UpdateUniversalProgress` | JWT | -| GET | `/api/progress/:id/history` | `Handler.GetProgressHistory` | JWT | -| POST | `/api/sync/koreader/progress` | `KOReaderHandler.SyncProgress` | Device | -| GET | `/api/sync/koreader/metadata/:uuid` | `KOReaderHandler.GetMetadata` | Device | -| GET | `/api/sync/koreader/library` | `KOReaderHandler.GetLibrary` | Device | -| POST | `/api/sync/koreader/bookmarks` | `KOReaderHandler.SyncBookmarks` | Device | -| POST | `/api/sync/kobo/:token/markup` | `KoboHandler.Markup` | Device | -| POST | `/api/sync/kobo/:token/bookmark` | `KoboHandler.Bookmark` | Device | -| POST | `/api/sync/kobo/:token/v1/analytics/gettests` | `KoboHandler.AnalyticsGettests` | Device | -| GET | `/api/sync/kobo/:token/v1/initialization` | `KoboHandler.Initialization` | Device | -| POST | `/api/sync/kobo/:token/sync-from-server` | `KoboHandler.SyncFromServer` | Device | - -### After - -| Method | Route | Handler | Auth | Change | -|---|---|---|---|---| -| GET | `/api/media-items/:id/progress` | `MediaHandler.GetMediaReadingProgress` | JWT | Enhanced response (adds format_group, total_characters) | -| PUT | `/api/media-items/:id/progress` | `MediaHandler.UpdateMediaReadingProgress` | JWT | Now calls ProgressService, richer request | -| DELETE | `/api/media-items/:id/progress` | `MediaHandler.DeleteMediaReadingProgress` | JWT | No change | -| GET | `/api/media-items/:id/progress/history` | `MediaHandler.GetProgressHistory` | JWT | **NEW** (moved from /progress/:id/history) | -| ~~GET~~ | ~~`/api/progress/:id`~~ | ~~removed~~ | | **REMOVED** | -| ~~POST~~ | ~~`/api/progress/:id`~~ | ~~removed~~ | | **REMOVED** | -| ~~GET~~ | ~~`/api/progress/:id/history`~~ | ~~removed~~ | | **MOVED** to media-items | -| POST | `/api/sync/koreader/progress` | `KOReaderHandler.SyncProgress` | Device | Internally uses ProgressService | -| GET | `/api/sync/koreader/metadata/:uuid` | `KOReaderHandler.GetMetadata` | Device | No change | -| GET | `/api/sync/koreader/library` | `KOReaderHandler.GetLibrary` | Device | No change | -| POST | `/api/sync/koreader/bookmarks` | `KOReaderHandler.SyncBookmarks` | Device | No change | -| POST | `/api/sync/kobo/:token/markup` | `KoboHandler.Markup` | Device | Internally uses ProgressService | -| POST | `/api/sync/kobo/:token/bookmark` | `KoboHandler.Bookmark` | Device | No change (bookmark-only, no progress) | -| POST | `/api/sync/kobo/:token/v1/analytics/gettests` | `KoboHandler.AnalyticsGettests` | Device | Internally uses ProgressService | -| GET | `/api/sync/kobo/:token/v1/initialization` | `KoboHandler.Initialization` | Device | No change | -| POST | `/api/sync/kobo/:token/sync-from-server` | `KoboHandler.SyncFromServer` | Device | Internally uses ProgressService | - -**All device-facing URLs are unchanged.** KOReader and Kobo firmware expect exact paths. - -## Conflict Rules (Preserved From Current Behavior) - -- Conflict detected ONLY when: - 1. Existing progress has `last_sync_source` that differs from current source - 2. `last_sync_timestamp` is within 5 minutes - 3. Absolute percentage difference > 0.01 (1%) -- On conflict: record in `sync_conflicts` table, broadcast WebSocket notification -- Current client's data ALWAYS wins (overwrite, don't merge with conflicting data) -- Conflict details NOT returned to device caller (only shown in web UI) -- Same-source rapid syncs never trigger conflicts (built-in debouncing for web, same-source check in handler) - -## Enrichment Rules - -After merge, compute missing fields: - -| Condition | Enrichment | -|---|---| -| Has percentage, missing character_offset, media has total_characters | `character_offset = PercentageToCharacter(pct, totalChars)` | -| Has percentage, missing current_page, has total_pages | `current_page = PercentageToPage(pct, totalPages)` | -| Has current_page + total_pages, missing percentage | `percentage = PageToPercentage(page, totalPages)` | -| Has character_offset + total_characters, missing percentage | `percentage = CharacterToPercentage(char, totalChars)` | -| Missing chapter_progress | Preserve existing value (never compute from book-wide percentage) | - -**All enrichment is only computed when source data is valid and non-zero. Silently skip if insufficient data.** - -## Kobo Special Cases - -### `last-read-place` (in Markup BookmarkSync) -- Only provides: `epubcfi` (parsed from BookmarkId), `chapter`, `chapter_progress = 0.5` -- Does NOT provide: `percentage`, `current_page`, `total_pages`, `character_offset` -- **Before migration:** These fields get NULLed (data loss bug) -- **After migration:** Merge preserves existing values, only overwrites epubcfi/chapter/chapter_progress - -### `ReadingSync` (in Markup) -- Only provides: `percentage` (from PercentRead/100) -- Does NOT provide: `epubcfi`, `chapter`, `character_offset`, etc. -- **Before migration:** These fields get NULLed (data loss bug) -- **After migration:** Merge preserves existing values, enrichment may compute character_offset from percentage - -### `SyncFromServer` -- `last_sync_source = "bookhoard"` (NOT "kobo") — this must be preserved -- No WebSocket broadcast — this must be preserved - -## KOReader Special Cases - -### Book resolution (stays in handler, NOT in ProgressService) -The 4-priority resolution chain is KOReader-specific: -1. UUID match (confidence 1.0) -2. SHA-256 match (confidence 0.9) -3. File path match via alias or DB lookup (confidence 0.7) -4. Title + Author match (confidence 0.5/0.4) - -This logic stays in `KOReaderHandler.resolveBookToMediaItem`. Only the final progress write goes through `ProgressService`. - -### Checkpoint mode -- `enqueueProgressForBook` creates `ProgressUpdate` struct → queue channel -- Queue processor's `syncProgress` calls `ProgressService.SaveProgress` instead of raw upsert -- `ProgressService` needs to be injected into `SyncQueueProcessor` - -### Device file aliases -- `createDeviceFileAlias` stays in `KOReaderHandler` — it's book resolution, not progress writing - -### Bulk sync -- `SyncProgress` loops over books, resolves each, calls `ProgressService.SaveProgress` per book -- If one book fails, others continue (current behavior, must preserve) -- Error from `SaveProgress` causes the book to not be counted in `booksSynced` - -## Execution Order - -Each step is independently deployable. If a step breaks, previous steps are safe. - -1. **Add `ProgressService` to `internal/sync/progress.go`** — additive, nothing breaks -2. **Write tests for `ProgressService.SaveProgress`** — verify merge, enrichment, conflict detection -3. **Update `cmd/server/main.go` and `internal/router/router.go`** — create `ProgressService`, inject into handlers. Pass as new parameter to constructors. -4. **Update `MediaHandler`** — accept `ProgressService`, use it in `UpdateMediaReadingProgress`. Accept richer request body. -5. **Update `KOReaderHandler`** — accept `ProgressService`, use in `updateProgressForBook`. Fix bugs. -6. **Update `KoboHandler`** — accept `ProgressService`, use in Markup/AnalyticsGettests/SyncFromServer. -7. **Update queue processor** — `syncProgress` calls `ProgressService.SaveProgress` -8. **Update `reader.ts`** — send richer payload -9. **Move routes** — add history route to media.go, remove progress.go -10. **Delete dead code** — `UpdateReadingProgress` query, `calculateFileSHA256`, etc. -11. **Run all tests** -12. **Rebuild frontend** (`npm run build:ts`) -13. **Rebuild app** (`make rebuild-app`) -14. **Manual test**: web reader → KOReader sync → Kobo sync → back to web reader - -## Dependency Injection Changes - -### Current (cmd/server/main.go) -``` -connManager = sync.NewConnectionManager() -queueProcessor = sync.NewSyncQueueProcessor(queries) -mediaHandler = handlers.NewMediaHandler(queries, libraryService, worker) -koreaderHandler = handlers.NewKOReaderHandler(queries, connManager, queueProcessor) -koboHandler = handlers.NewKoboHandler(queries, connManager) -``` - -### After -``` -connManager = sync.NewConnectionManager() -progressService = sync.NewProgressService(queries, connManager) -queueProcessor = sync.NewSyncQueueProcessor(queries, progressService) -mediaHandler = handlers.NewMediaHandler(queries, libraryService, worker, progressService) -koreaderHandler = handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService) -koboHandler = handlers.NewKoboHandler(queries, connManager, progressService) -``` - -## Tests to Write/Update - -1. **`internal/sync/progress_test.go`** — add tests for: - - `SaveProgress` with no existing data (fresh insert) - - `SaveProgress` merge: KOReader data preserved when web saves - - `SaveProgress` merge: web data preserved when KOReader saves - - `SaveProgress` enrichment: character_offset computed from percentage - - `SaveProgress` enrichment: current_page computed from percentage + total_pages - - `SaveProgress` conflict detection: triggered when different source within 5 min - - `SaveProgress` conflict detection: NOT triggered for same source - - `SaveProgress` conflict detection: NOT triggered after 5 min window -2. **Run existing tests** — `ConvertProgress`, `MergeProgress`, `PageToPercentage`, etc. must still pass - -## Functionality That Must Not Be Touched - -- KOReader bookmark/highlight/note sync (`SyncBookmarks`) — annotation creation, not progress -- Kobo bookmark creation in `Bookmark` handler — annotation creation -- Kobo library initialization — read-only -- Kobo ContentId mapping helpers — book resolution -- KOReader book resolution logic — book resolution -- KOReader metadata/library retrieval — read-only -- WebSocket connection management — infrastructure -- Offline detection — infrastructure (not started anyway) -- Scanner/worker functionality on `Handler` struct -- Frontend progress overview page -- Frontend book detail page progress display -- All annotation-related queries and handlers - -## Context: The Bigger Picture - -This migration is the foundation for the "universal sync engine" — the core purpose of the Bookhoard project. The goal is seamless reading progress sync across all devices: -- Web reader (foliate-js based) -- KOReader (crengine based, running on Kindle/Kobo/Android/desktop) -- Kobo (stock firmware) - -The `ProgressService` is designed to eventually support: -- Server-side CFI generation from crengine data (EPUB parser needed) -- Server-side crengine XPointer generation from CFI (EPUB parser needed) -- Bidirectional exact position sync between any two clients - -Current `percentage` is the universal fallback. CFI is exact for EPUB. Character offset bridges the gap for crengine. The `ProgressService` enrichment step is where future CFI generation will be added. - -## Database Schema (unchanged) - -The `reading_progress` table already has all needed fields. No schema changes required. - -Key columns: -- `percentage` FLOAT (0.0-1.0) — universal progress -- `epubcfi` TEXT — exact EPUB position -- `character_offset` BIGINT — crengine position -- `chapter` INTEGER — chapter number -- `chapter_progress` FLOAT — within-chapter progress -- `current_page` / `total_pages` INTEGER — page display -- `viewport_x/y`, `zoom_level`, `scroll_position_x/y` — fixed-layout state -- `panel_number` — comic panel -- `reading_mode` — reading mode identifier -- `last_sync_device` / `last_sync_source` — sync metadata -- `last_sync_timestamp` — for conflict detection -- `conflict_detected` / `conflict_resolved` — conflict flags