chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION)

This commit is contained in:
2026-05-01 14:31:13 -04:00
parent 1863fb3380
commit e14bbeabdd
2 changed files with 0 additions and 1224 deletions
-777
View File
@@ -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<DetectionResult> {
// 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<Panel[]>;
async function detectPanelsML(imageData: ImageData): Promise<Panel[]>;
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<void> {
if (openCVLoaded) return;
// OpenCV.js loads asynchronously and registers globally
await import("@techstark/opencv-js");
// Wait for OpenCV to be ready
return new Promise<void>((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<Panel[]> {
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<void> {
if (tfLoaded) return;
// Load TensorFlow.js
await import("@tensorflow/tfjs");
tfLoaded = true;
}
async function loadModel(): Promise<void> {
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<Panel[]> {
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<number>();
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<HTMLImageElement> {
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<void>((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<void> {
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<Panel[]> {
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<number, HTMLImageElement>;
loading: Set<number>;
maxAhead: number;
mediaItemId: string;
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
}
// Add this function
async function detectPagePanels(
state: PageCacheState,
pageNumber: number
): Promise<any[]> {
// 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
-447
View File
@@ -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