chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user