Files
bookhoard/internal/handlers/koreader.go
T
John O'Keefe e83c00f869 refactor(progress): ProgressService required at construction; delete the 9 unreachable erase-fallbacks
Batch H1 step 3a. The ceiling census (exhaustive UpdateUniversalProgress
grep) found 9 nil-guarded `if progressSvc != nil { funnel } else { raw
erase-write }` fallbacks — the survey's "four" under-counted kobo.go,
which has FOUR sites (Markup ReadingSync, Markup last-read-place,
AnalyticsGettests, SyncFromServer), plus one each in media.go, queue.go
and koreader.go. All unreachable: the service is created unconditionally
at main.go:127 and wired into every handler and the queue processor; the
test harness wires them too.

The erasure pattern is now unrepresentable:
- NewMediaHandler / NewKOReaderHandler / NewKoboHandler / both
  NewSyncQueueProcessor flavors take the ProgressService as a REQUIRED
  constructor arg; the SetProgressService setters are gone.
- Every fallback body (raw UpdateUniversalProgress with explicit-NULL
  pgtypes for viewport/scroll/panel/reading_mode/zoom and no
  audio_offset_seconds/context_text) is deleted; each write funnels
  through SaveProgress with the LWW seed, so fields the writer doesn't
  know survive.
- Removed the now-dead koreader pg-type helpers (textPtrToPgText,
  intPtrToPgInt4, int64PtrToPgInt8) and their self-tests.

Behavior deltas (stored-row content, no wire shape changes —
KOReader/Kobo response pins stayed green):
- KOReader fallback used to write explicit NULL viewports and drop the
  context_text the koplugin sends; the funneled mainline already maps
  book.ContextText.
- Queue flushes no longer overwrite chapter_progress with the book
  percentage (bogus data; preserved via the seed).
- A web PUT without percentage no longer writes 0% via the deleted
  fallback; percentage seeds from the stored row.

Contract suite: KOReader×4 + Kobo×3 pins green.
2026-10-16 17:28:06 -04:00

1928 lines
64 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
bookResolver *services.BookResolver
}
type LibraryPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
}
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor, progressSvc *wsync.ProgressService) *KOReaderHandler {
return &KOReaderHandler{
db: db,
connManager: connManager,
queue: queue,
progressSvc: progressSvc,
bookResolver: services.NewBookResolver(db),
}
}
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
// annotationEpub carries the per-book context every locator conversion
// needs: the media item (format gating) and the resolved EPUB path. It is
// resolved once per request so all annotations in a push share one
// converter-cache entry instead of re-resolving (and re-parsing the book)
// per annotation.
type annotationEpub struct {
mediaItem *database.MediaItems
epubPath string
}
func (ec annotationEpub) convertible() bool {
return ec.mediaItem != nil && ec.epubPath != ""
}
func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pgtype.UUID) annotationEpub {
var ec annotationEpub
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return ec
}
ec.mediaItem = &mediaItem
if h.libraryService != nil {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
ec.epubPath = epubPath
}
}
return ec
}
// convertHighlightPositions resolves a device annotation's pos0/pos1
// locators to canonical CFIs through the shared facade. contextText is the
// selection's own text — a quote of the document, so the converter can
// verify structural landings against it and, when it must search, anchor a
// range end that spans block boundaries. percentage anchors the
// last-resort fallback. Only structural/exact landings are returned: a
// low-confidence conversion yields "" so an echo preserves the row's
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
// with a guess, and a new device highlight paints nowhere rather than in
// the wrong place.
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
if pos0 == "" || !ec.convertible() {
return "", ""
}
// Primary: anchor both endpoints by the selection text itself. One
// document match yields a same-spine ordered pair — the invariant the
// web reader's range-CFI builder needs. Independent per-endpoint
// conversion mis-anchors any selection past a block's first text node
// (crengine text-node indices don't survive to the raw XHTML).
startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage)
if startLoc.Precision == "exact" || startLoc.Precision == "structural" {
startCFI := webUsableCFI(startLoc)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision})
}
return startCFI, endCFI
}
// Fallback: legacy independent per-endpoint conversion.
startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc)
endCFI := ""
// A text-search start matched the selection text itself: its extent
// is the selection's true end, even across blocks.
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
if endCFI == "" && pos1 != "" {
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
endCFI = webUsableCFI(endLoc)
}
// The end conversion carries no context text; when neither resolved
// confidently, derive the end from the start advanced by the
// selection's UTF-16 length (the CFI offset unit). Multi-node
// selections produce an out-of-range offset — harmless: resolution
// clamps or fails, and consumers fall back to the start.
if endCFI == "" && startCFI != "" && contextText != "" {
endCFI = extendCFIByLength(startCFI, contextText)
}
return startCFI, endCFI
}
// convertBookmarkPosition resolves a device bookmark's locator to the
// canonical CFI for the web reader. Structural-only by design: bookmark
// text is a display label ("in <chapter>" auto-fill or a user note), never
// book text, so no context is supplied and only a structural/exact landing
// is trusted — lower rungs would store a confident-looking guess the web
// drawer would present as a real destination.
func (h *KOReaderHandler) convertBookmarkPosition(ec annotationEpub, position string, percentage float64) string {
if position == "" || !ec.convertible() || !wsync.IsCREXPointer(position) {
return ""
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, position, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
return webUsableCFI(loc)
}
// webUsableCFI keeps only high-confidence conversions: the web reader
// navigates bookmarks by CFI, so href/percentage/fallback results are
// discarded instead of stored as dead links.
func webUsableCFI(loc wsync.CanonicalLocator) string {
if loc.Precision != "structural" && loc.Precision != "exact" {
return ""
}
if !strings.HasPrefix(loc.CFI, "epubcfi(") || !strings.HasSuffix(loc.CFI, ")") {
return ""
}
return loc.CFI
}
// extendCFIByLength advances a point CFI's trailing character offset by the
// UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units).
// Selections spanning multiple text nodes produce an out-of-range offset —
// harmless: resolution clamps or fails, and consumers fall back to the start.
func extendCFIByLength(cfi, text string) string {
if cfi == "" || text == "" {
return cfi
}
i := strings.LastIndex(cfi, ":")
if i < 0 || !strings.HasSuffix(cfi, ")") {
return cfi
}
off, err := strconv.Atoi(cfi[i+1 : len(cfi)-1])
if err != nil {
return cfi
}
utf16len := 0
for _, r := range text {
if r > 0xFFFF {
utf16len += 2
} else {
utf16len++
}
}
return cfi[:i+1] + strconv.Itoa(off+utf16len) + ")"
}
// existingHighlightColor returns the stored color of the highlight matching
// the dedup key ("" when none) so device echoes that carry no color never
// clobber the web color.
func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string {
if dedupKey == "" {
return ""
}
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
UserID: userID,
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return ""
}
return existing.Color.String
}
// deriveAnnotationPercentage computes a percentage for device-pushed
// annotations when the client didn't send one (thin clients skip their own
// per-annotation page lookups; arithmetic is only free on paging documents).
func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
if ec.mediaItem == nil {
return 0
}
formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(ec.mediaItem.PageCount.Int32)
}
return 0
}
if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
return wsync.SectionPercentageCached(ec.epubPath, pos0)
}
return 0
}
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
SyncMode string `json:"sync_mode,omitempty"`
}
type KOReaderBookProgress struct {
UUID string `json:"uuid,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
// Device-side deletions, reported by dedup key. Keys refer to annotations
// the device previously received from the server (or echoes of its own
// pushes); the device only flags a deletion after observing the key in a
// pull, so absence from these arrays is never interpreted as deletion.
DeletedHighlights []KOReaderDeletedAnnotation `json:"deleted_highlights,omitempty"`
DeletedBookmarks []KOReaderDeletedAnnotation `json:"deleted_bookmarks,omitempty"`
}
// KOReaderDeletedAnnotation identifies a deleted annotation by dedup key.
type KOReaderDeletedAnnotation struct {
DedupKey string `json:"dedup_key"`
}
type KOReaderDeviceInfo struct {
KOReaderVersion string `json:"koreader_version,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
}
// FlexInt tolerates the loose types KOReader clients send for optional
// numeric fields: JSON numbers, numeric strings ("30"), empty strings
// (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE
// documents) — the latter decode to 0. Without this, a single annotation
// carrying chapter:"" or page:"/body/..." failed the whole request bind
// with a 400.
type FlexInt int
func (f *FlexInt) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if s == "null" || s == `""` {
*f = 0
return nil
}
if n, err := strconv.Atoi(s); err == nil {
*f = FlexInt(n)
return nil
}
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
inner := s[1 : len(s)-1]
if n, err := strconv.Atoi(inner); err == nil {
*f = FlexInt(n)
return nil
}
*f = 0
return nil
}
if fl, err := strconv.ParseFloat(s, 64); err == nil {
*f = FlexInt(int(fl))
return nil
}
*f = 0
return nil
}
type KOReaderBookmark struct {
Chapter FlexInt `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page FlexInt `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
BookSHA256 string `json:"book_sha256,omitempty"`
DedupKey string `json:"dedup_key,omitempty"`
}
type KOReaderHighlight struct {
Chapter FlexInt `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page FlexInt `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Color string `json:"color,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
BookSHA256 string `json:"book_sha256,omitempty"`
DedupKey string `json:"dedup_key,omitempty"`
}
type KOReaderNote struct {
Chapter FlexInt `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page FlexInt `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
BookSHA256 string `json:"book_sha256,omitempty"`
DedupKey string `json:"dedup_key,omitempty"`
}
type KOReaderSyncResponse struct {
SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"`
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
}
type KOReaderBookSyncResult struct {
SHA256 string `json:"sha256"`
BookUUID string `json:"book_uuid"`
Synced bool `json:"synced"`
}
type KOReaderConflict struct {
BookUUID string `json:"book_uuid"`
ConflictType string `json:"conflict_type"`
DeviceProgress float64 `json:"device_progress"`
ServerProgress float64 `json:"server_progress"`
Resolution string `json:"resolution"`
}
type KOReaderMetadata struct {
UUID string `json:"uuid"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title"`
Authors []string `json:"authors"`
Progress KOReaderProgressData `json:"progress"`
Annotations KOReaderAnnotations `json:"annotations"`
LastSync string `json:"last_sync"`
}
type KOReaderProgressData struct {
Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
}
type KOReaderAnnotations struct {
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
}
type KOReaderLibraryResponse struct {
LibrarySync []KOReaderLibraryBook `json:"library_sync"`
TotalBooks int `json:"total_books"`
LastSync string `json:"last_sync"`
}
type KOReaderLibraryBook struct {
UUID string `json:"uuid"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title"`
Author string `json:"author"`
ContentType string `json:"content_type"`
PercentRead float64 `json:"percent_read"`
PagesRemaining *int `json:"pages_remaining,omitempty"`
BookmarkCount int `json:"bookmark_count"`
LastModified string `json:"last_modified"`
}
func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
var req KOReaderProgressRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
"details": err.Error(),
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
syncMode := req.SyncMode
if syncMode == "" {
syncMode = "immediate"
}
if syncMode == "checkpoint" {
return h.handleCheckpointSync(c, device, pgUserID, req)
}
booksSynced := 0
conflicts := []KOReaderConflict{}
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
synced := err == nil
if synced {
booksSynced++
}
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update device timestamp",
})
}
if syncMode == "immediate" {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
})
}
return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
ctx := c.Request().Context()
// Priority 1: UUID provided (highest confidence - 1.0)
if book.UUID != "" {
mediaUUID, err := uuid.Parse(book.UUID)
if err == nil {
mediaItem, err := h.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err == nil {
// Create device file alias if FilePath is also provided
if book.FilePath != "" {
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
}
return mediaItem.ID, 1.0
}
}
}
// Priority 2: SHA-256 provided (medium confidence - 0.9)
// Uses the shared BookResolver, which also checks per-format hashes
// (media_item_formats) so a converted file (KEPUB/PDF) matches too.
if book.SHA256 != "" && len(book.SHA256) == 64 {
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, book.SHA256)
if err == nil {
// Create device file alias if FilePath is provided
if book.FilePath != "" {
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
}
return mediaItem.ID, 0.9
}
}
// Priority 3: FilePath provided (check existing alias or create new - confidence 0.7)
if book.FilePath != "" {
// Check if alias already exists
alias, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
DeviceID: deviceID,
FilePath: book.FilePath,
})
if err == nil {
return alias.MediaItemID, alias.ConfidenceScore.Float64
}
// Try to find by file path (search any library)
mediaItem, err := h.db.GetMediaItemByFilePathAnyLibrary(ctx, book.FilePath)
if err == nil {
// Create new alias
confidence := 0.7
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
return mediaItem.ID, confidence
}
}
// Priority 4: Title + Author match (lowest confidence - 0.5)
if book.Title != "" {
mediaItems, err := h.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 100,
Offset: 0,
})
if err == nil {
for _, mi := range mediaItems {
if mi.Title == book.Title {
// Check author match if provided
if len(book.Authors) > 0 && mi.Author.Valid {
if mi.Author.String == book.Authors[0] {
// Create device file alias if FilePath is provided
if book.FilePath != "" {
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
}
return mi.ID, 0.5
}
} else {
// Title only match
if book.FilePath != "" {
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
}
return mi.ID, 0.4
}
}
}
}
}
return pgtype.UUID{}, 0.0
}
func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
ctx := c.Request().Context()
if book.FilePath == "" {
return
}
sha256 := book.SHA256
if sha256 == "" {
// Try to get SHA256 from media item
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err == nil && mediaItem.FileSha256.Valid {
sha256 = mediaItem.FileSha256.String
}
}
confidence := 0.7
// Check if alias already exists
_, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
DeviceID: deviceID,
FilePath: book.FilePath,
})
if err != nil {
// Create new alias
_, _ = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: mediaItemID,
DeviceID: deviceID,
FilePath: book.FilePath,
FileSha256: pgtype.Text{String: sha256, Valid: sha256 != ""},
ConfidenceScore: pgtype.Float8{Float64: confidence, Valid: true},
})
}
}
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
booksEnqueued := 0
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
synced := err == nil
if synced {
booksEnqueued++
}
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update device timestamp",
})
}
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "checkpoint_enqueued",
BooksSynced: booksEnqueued,
BookResults: bookResults,
Timestamp: time.Now().Format(time.RFC3339),
})
}
func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
if h.queue == nil {
return fmt.Errorf("sync queue not available")
}
update := &wsync.ProgressUpdate{
DeviceID: deviceID,
MediaItemID: mediaItemID,
UserID: userID,
Percentage: book.Percentage,
Epubcfi: book.Epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
Character: book.Character,
Page: book.Page,
TotalPages: book.TotalPages,
Source: "koreader",
SyncMode: "checkpoint",
}
return h.queue.EnqueueProgress(update)
}
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
if h.annotationSvc == nil {
return
}
ec := h.loadAnnotationEpub(ctx, mediaItemID)
for _, hl := range book.Highlights {
startPos := hl.Pos0
endPos := hl.Pos1
pctStart := 0.0
if hl.Percentage != nil {
pctStart = *hl.Percentage
}
if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(hl.Page))
}
// The highlight's own text anchors the conversion exactly.
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, hl.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": hl.Datetime,
"pos0": hl.Pos0,
"pos1": hl.Pos1,
"page": hl.Page,
})
// Color semantics: devices render their own default and cannot
// round-trip web colors. An echo carries NO color — preserve the
// stored (web) color so round-trips never change it. A non-empty
// color means the user edited the highlight on the device: map the
// device color name and let it win.
color := ""
if hl.Color != "" {
color = mapColorFromKOReader(hl.Color)
}
dedupKey := hl.DedupKey
if dedupKey == "" {
dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos)
}
if color == "" {
color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey)
}
if color == "" {
color = "#ffd54f"
}
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: hl.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: color,
NoteText: hl.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
DedupKey: dedupKey,
})
}
for _, note := range book.Notes {
startPos := note.Pos0
endPos := note.Pos1
pctStart := 0.0
if note.Percentage != nil {
pctStart = *note.Percentage
}
if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(note.Page))
}
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, note.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
"pos1": note.Pos1,
"page": note.Page,
})
dedupKey := note.DedupKey
if dedupKey == "" {
dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos)
}
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: note.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey),
NoteText: note.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
DedupKey: dedupKey,
})
}
for _, bookmark := range book.Bookmarks {
position := ""
if bookmark.Pos0 != "" {
position = bookmark.Pos0
} else if bookmark.Page > 0 {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
// Device-native xpointer → canonical CFI so the web drawer can
// navigate KOReader-created bookmarks (page-only positions have no
// convertible locator; the drawer's page fallback covers those).
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
"page": bookmark.Page,
})
dedupKey := bookmark.DedupKey
if dedupKey == "" {
dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position)
}
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: userID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
DedupKey: dedupKey,
})
}
// Device-reported deletions: tombstone by dedup key. Tombstoned rows stay
// in the history (restorable from the book page) and are echoed to other
// devices as tombstones on their next pull. A device replay that pushes a
// stale copy of the annotation cannot resurrect the tombstone (its save
// carries no modification timestamp newer than the delete). Deletions run
// after the upserts purely so a key present in both lists resolves to
// "deleted" — the newer intent.
for _, del := range book.DeletedHighlights {
if del.DedupKey == "" {
continue
}
if err := h.annotationSvc.TombstoneHighlight(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
log.Printf("KOReader: tombstone highlight by dedup key failed: %v", err)
}
}
for _, del := range book.DeletedBookmarks {
if del.DedupKey == "" {
continue
}
if err := h.annotationSvc.TombstoneBookmarkByDedupKey(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
log.Printf("KOReader: tombstone bookmark by dedup key failed: %v", err)
}
}
}
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context()
deviceInfo := book.DeviceInfo
deviceModel := deviceInfo.DeviceModel
if deviceModel == "" {
deviceModel = "KOReader Device"
}
epubcfi := book.Epubcfi
// No position pointer from the device, or a conversion that
// declined below: the write is percentage-only by intent. The
// service then clears any inherited stale anchor instead of
// re-pairing it with the new percentage.
clearLocator := epubcfi == nil
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
// Same facade every annotation uses: cached converter,
// structural-first resolution, guarded fallbacks. The facade
// passes non-reflowable formats through untouched.
ec := h.loadAnnotationEpub(ctx, mediaItemID)
if ec.convertible() {
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
switch {
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
(loc.Precision == "structural" || loc.Precision == "exact"):
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
case loc.CFI != "" && loc.CFI != *epubcfi &&
(loc.Precision == "element" || loc.Precision == "section"):
// Fragment-ID positions resolve to a section href: keep
// serving it (legacy behavior).
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
default:
// Low-confidence (percentage/fallback): store no
// canonical locator — the row's percentage restores
// approximately instead of a confidently-wrong CFI,
// and the device keeps its own native position.
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
epubcfi = nil
clearLocator = true
}
}
}
saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: "koreader",
DeviceID: deviceID,
Percentage: &book.Percentage,
Epubcfi: epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
CharacterOffset: book.Character,
CurrentPage: book.Page,
TotalPages: book.TotalPages,
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
ClearLocator: clearLocator,
}
if _, err := h.progressSvc.SaveProgress(ctx, saveReq); err != nil {
return err
}
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
return nil
}
type KOReaderResolveResponse struct {
BookUUID string `json:"book_uuid"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Author string `json:"author,omitempty"`
}
// ResolveBook maps a file SHA-256 to the book's UUID without touching any
// progress state. Devices need the UUID to pull metadata, but a freshly
// downloaded book has none cached yet — the old way of learning it was to
// push once, which transmitted the device's first-page position to the
// server and manufactured a progress conflict for books already mid-read
// from another source. This read-only lookup lets the client link (and
// pull) without ever pushing bootstrap progress.
func (h *KOReaderHandler) ResolveBook(c *echo.Context) error {
sha256 := c.QueryParam("sha256")
if sha256 == "" || len(sha256) != 64 {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "sha256 query parameter is required (64 hex characters)",
})
}
mediaItem, _, err := h.bookResolver.ResolveBySHA256(c.Request().Context(), sha256)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "book not found",
})
}
return c.JSON(http.StatusOK, KOReaderResolveResponse{
BookUUID: uuid.UUID(mediaItem.ID.Bytes).String(),
SHA256: sha256,
Title: mediaItem.Title,
Author: mediaItem.Author.String,
})
}
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid book UUID",
})
}
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "book not found",
})
}
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
if err != nil {
return c.JSON(http.StatusOK, map[string]interface{}{
"uuid": bookUUID,
"title": mediaItem.Title,
"author": mediaItem.Author,
"progress": nil,
"annotations": map[string][]interface{}{},
})
}
progressData := KOReaderProgressData{
Percentage: progress.Percentage.Float64,
}
// CFI/xpointer are meaningless for image-based fixed-layout content; the
// page index is the canonical locator. Only return them for reflowable docs.
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
formatGroup == wsync.FormatGroupComicArchive
if !isFixed {
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
}
}
if progress.Chapter.Valid {
progress := int(progress.Chapter.Int32)
progressData.Chapter = &progress
}
if progress.ChapterProgress.Valid {
progress := progress.ChapterProgress.Float64
progressData.ChapterProgress = &progress
}
if progress.CharacterOffset.Valid {
progress := progress.CharacterOffset.Int64
progressData.Character = &progress
}
if progress.CurrentPage.Valid {
progress := int(progress.CurrentPage.Int32)
progressData.Page = &progress
}
if progress.TotalPages.Valid {
progress := int(progress.TotalPages.Int32)
progressData.TotalPages = &progress
}
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
annotationsResponse := KOReaderAnnotations{
Highlights: []KOReaderHighlight{},
Notes: []KOReaderNote{},
Bookmarks: []KOReaderBookmark{},
}
for _, ann := range annotations {
if ann.AnnotationType == "highlight" {
// Selection text doubles as the converter's text-search context.
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText)
pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText)
if pos0 == "" {
// Nothing the device could place — serving a locator it can't
// resolve would create junk bookmarks that re-push as
// duplicates, so skip instead.
log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID)
continue
}
// Old web highlights carry no end anchor, and converted range
// CFIs resolve to their start — either way pos1 collapses onto
// pos0 and the device paints a zero-width highlight. Derive the
// end by advancing the start's character offset by the length
// of the selected text.
if pos1 == "" || pos1 == pos0 {
pos1 = extendXPointerByLength(pos0, ann.SelectionText)
}
highlight := KOReaderHighlight{
Text: ann.SelectionText,
Pos0: pos0,
Pos1: pos1,
// Web colors flow to the device, mapped to KOReader's named
// palette. Round-trip safety: the device suppresses the color
// when echoing un-edited applied entries (a pink→purple
// palette mismatch must not rewrite the stored hex), and an
// actual device edit pushes its color, which wins.
Color: mapColorToKOReader(ann.Color.String),
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
DedupKey: ann.DedupKey.String,
}
if ann.NoteText.Valid && ann.NoteText.String != "" {
highlight.Notes = ann.NoteText.String
}
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
} else if ann.AnnotationType == "note" {
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "")
if pos0 == "" {
log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID)
continue
}
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
Text: ann.SelectionText,
Pos0: pos0,
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
DedupKey: ann.DedupKey.String,
})
}
}
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
for _, bm := range bookmarks {
pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "")
if pos0 == "" {
log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID)
continue
}
koreaderBookmark := KOReaderBookmark{
Text: bm.Title,
Pos0: pos0,
Pos1: pos0,
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
DedupKey: bm.DedupKey.String,
}
if bm.Notes.Valid && bm.Notes.String != "" {
koreaderBookmark.Notes = bm.Notes.String
}
if bm.ChapterNumber.Valid {
koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32)
}
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
}
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
DeletedAt: cutoff,
})
for _, ts := range tombstones {
var dd map[string]interface{}
if len(ts.DeviceSyncData) > 0 {
json.Unmarshal(ts.DeviceSyncData, &dd)
}
if dd == nil {
dd = map[string]interface{}{}
}
dd["dedup_key"] = ts.DedupKey.String
// KOReader deletes by matching pos0. Device-pushed annotations carry
// it in device_sync_data; web-created ones don't (their locator is
// converted at serve time), so resolve it from the stored columns.
if dd["pos0"] == nil || dd["pos0"] == "" {
if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" {
dd["pos0"] = pos0
}
}
if ts.AnnotationType == "highlight" {
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
} else if ts.AnnotationType == "bookmark" {
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
}
}
lastSync := "never"
if progress.LastSyncTimestamp.Valid {
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
}
metadata := KOReaderMetadata{
UUID: bookUUID.String(),
SHA256: mediaItem.FileSha256.String,
Title: mediaItem.Title,
Authors: []string{mediaItem.Author.String},
Progress: progressData,
Annotations: annotationsResponse,
LastSync: lastSync,
}
return c.JSON(http.StatusOK, metadata)
}
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
// Same facade path annotations use on serve: structural resolution
// first, guarded text search only as fallback, cached converter. The
// stored percentage anchors the reverse fallback ladder.
if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
progressData.KoreaderXPointer = &xp
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
}
}
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string, percentage float64) string {
if h.libraryService == nil || epubcfi == "" {
return ""
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return ""
}
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, percentage, contextText, mediaItem.FormatGroup, epubPath, "")
if loc.Position != "" && loc.Position != epubcfi {
return loc.Position
}
return ""
}
// pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start
// for PDF text highlights (page-fraction rects; page index is 0-based).
type pdfRectAnchor struct {
V int `json:"v"`
Page int `json:"page"`
Rects [][]float64 `json:"rects"`
}
// koreaderPos0 resolves a device-native KOReader pos0 from an annotation's
// stored locators, whatever the source. Resolution order:
//
// extendXPointerByLength advances a CRE xpointer's trailing text-node
// character offset by the rune length of text, so a highlight with only a
// start anchor still gets a plausible (non-collapsed) end for drawing.
// Overshooting the node just clamps on the device.
func extendXPointerByLength(xp, text string) string {
if xp == "" || text == "" {
return xp
}
i := strings.LastIndex(xp, ".")
if i < 0 {
return xp
}
off, err := strconv.Atoi(xp[i+1:])
if err != nil {
return xp
}
return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text))
}
// KOReader paints highlight colors from a fixed set of names
// (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at
// the boundary so each side always receives something it can render;
// unmappable values fall back to each side's default (yellow).
var koreaderColorFromName = map[string]string{
"yellow": "#ffd54f",
"orange": "#ffd54f",
"green": "#a5d6a7",
"olive": "#a5d6a7",
"cyan": "#90caf9",
"blue": "#90caf9",
"purple": "#ce93d8",
"red": "#f48fb1",
}
// mapColorFromKOReader normalizes a device color name to a web hex
// swatch (default yellow) when ingesting device pushes.
func mapColorFromKOReader(name string) string {
if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok {
return hex
}
return "#ffd54f"
}
var koreaderColorFromHex = map[string]string{
"#ffd54f": "yellow",
"#a5d6a7": "green",
"#90caf9": "blue",
"#ce93d8": "purple",
"#f48fb1": "purple",
}
// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader
// color name (default yellow) when serving to devices. Pink maps to purple
// (the palette's closest); round-trip drift is prevented on the device by
// suppressing echo colors for un-edited applied entries.
func mapColorToKOReader(hex string) string {
if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok {
return name
}
return "yellow"
}
// 1. A device-native CRE xpointer ("/body/...") in startPosition wins —
// round-trip identical for KOReader-pushed annotations (converting the
// stored CFI instead could drift and duplicate on the device).
// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging
// documents use the page number as pos0).
// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the
// reader's "cfi:" prefix) → converted to a CRE xpointer, with
// contextText (the selection text) enabling the text-search fallback.
// 4. A "page:N" or bare-numeric position → the bare number.
//
// Returns "" when nothing usable exists; callers skip such annotations so
// devices never receive locators they cannot place.
func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string {
if wsync.IsCREXPointer(startPosition) {
return startPosition
}
if strings.HasPrefix(epubcfi, "{") {
var anchor pdfRectAnchor
if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 {
return strconv.Itoa(anchor.Page)
}
}
cfi := epubcfi
if cfi == "" && strings.HasPrefix(startPosition, "cfi:") {
cfi = strings.TrimPrefix(startPosition, "cfi:")
}
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText, 0); converted != "" {
return converted
}
// Conversion failed; fall through so numeric positions still work.
if wsync.IsCREXPointer(cfi) {
return cfi
}
}
if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 {
return p
}
return ""
}
func parsePageInt(s string) int64 {
var n int64
for _, r := range s {
if r < '0' || r > '9' {
return -1
}
n = n*10 + int64(r-'0')
}
return n
}
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch library",
})
}
libraryBooks := []KOReaderLibraryBook{}
for _, item := range mediaItems {
pgItemUUID := pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgItemUUID,
UserID: pgUserID,
})
percentRead := 0.0
var pagesRemaining *int
bookmarkCount := 0
lastModified := time.Now().Format(time.RFC3339)
if err == nil {
percentRead = progress.Percentage.Float64
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
pages := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
pagesRemaining = &pages
}
if progress.LastReadAt.Valid {
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
}
}
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgItemUUID,
UserID: pgUserID,
})
bookmarkCount = len(annotations)
libraryBooks = append(libraryBooks, KOReaderLibraryBook{
UUID: uuid.UUID(item.ID.Bytes).String(),
SHA256: item.FileSha256.String,
Title: item.Title,
Author: item.Author.String,
ContentType: "6",
PercentRead: percentRead * 100,
PagesRemaining: pagesRemaining,
BookmarkCount: bookmarkCount,
LastModified: lastModified,
})
}
return c.JSON(http.StatusOK, KOReaderLibraryResponse{
LibrarySync: libraryBooks,
TotalBooks: len(libraryBooks),
LastSync: time.Now().Format(time.RFC3339),
})
}
func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
var req struct {
BookUUID string `json:"book_uuid,omitempty"`
BookSHA256 string `json:"book_sha256,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks"`
Notes []KOReaderNote `json:"notes"`
Highlights []KOReaderHighlight `json:"highlights"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}
ctx := c.Request().Context()
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Resolve media item ID using priority matching
var pgBookUUID pgtype.UUID
if req.BookUUID != "" {
// Use UUID directly
bookUUID, err := uuid.Parse(req.BookUUID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid book UUID",
})
}
pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true}
} else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 {
// Use SHA-256 to find book (format-aware: also checks media_item_formats)
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, req.BookSHA256)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "book not found by SHA-256",
})
}
pgBookUUID = mediaItem.ID
} else {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "either book_uuid or book_sha256 is required",
})
}
bookmarksSynced := 0
notesSynced := 0
highlightsSynced := 0
for _, bookmark := range req.Bookmarks {
mediaItemID := pgBookUUID
// If bookmark has its own SHA-256, use it for matching
if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 {
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, bookmark.BookSHA256)
if err == nil {
mediaItemID = mediaItem.ID
}
}
position := ""
if bookmark.Pos0 != "" {
position = bookmark.Pos0
} else if bookmark.Page > 0 {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
"page": bookmark.Page,
})
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: bookmark.Text,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
bookmarksSynced++
}
}
}
for _, note := range req.Notes {
mediaItemID := pgBookUUID
// If note has its own SHA-256, use it for matching
if note.BookSHA256 != "" && len(note.BookSHA256) == 64 {
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, note.BookSHA256)
if err == nil {
mediaItemID = mediaItem.ID
}
}
position := ""
if note.Pos0 != "" {
position = note.Pos0
} else if note.Page > 0 {
position = fmt.Sprintf("page:%d", note.Page)
}
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
"page": note.Page,
})
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: position,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
notesSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
notesSynced++
}
}
}
for _, highlight := range req.Highlights {
mediaItemID := pgBookUUID
// If highlight has its own SHA-256, use it for matching
if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 {
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, highlight.BookSHA256)
if err == nil {
mediaItemID = mediaItem.ID
}
}
startPos := highlight.Pos0
endPos := highlight.Pos1
if startPos == "" && highlight.Page > 0 {
startPos = fmt.Sprintf("page:%d", highlight.Page)
endPos = startPos
}
color := "#ffd54f"
if highlight.Color != "" {
color = mapColorFromKOReader(highlight.Color)
}
if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctStart := 0.0
if highlight.Percentage != nil {
pctStart = *highlight.Percentage
}
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime,
"pos0": highlight.Pos0,
"pos1": highlight.Pos1,
"page": highlight.Page,
})
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: color,
NoteText: highlight.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSynced++
}
} else {
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if err == nil {
highlightsSynced++
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"sync_status": "completed",
"bookmarks_synced": bookmarksSynced,
"notes_synced": notesSynced,
"highlights_synced": highlightsSynced,
"total_synced": bookmarksSynced + notesSynced + highlightsSynced,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// ---------------------------------------------------------------------------
// Ratings / review / reading-time bridge (handoff item 4 Batch B)
//
// The plugin mirrors the book's .sdr summary (summary.rating 5-star,
// summary.note = the Book Status "Review" box) against the server's
// 1-10 rating and raw-markdown review, and pushes its cumulative
// reading time from statistics.sqlite3. Stats are PUSH-ONLY and
// watermark-converted: the device sends its TOTAL seconds for the
// book and the server inserts only the delta as a reading_history
// session attributed to this device, so totals unify in the one
// stats pipeline while the By Device panel keeps the per-client
// split. A device reinstall cannot double-count (the watermark is
// server-side), and sub-threshold deltas simply ride forward.
// ---------------------------------------------------------------------------
// koreaderBookRef is the minimal book identity the bridge endpoints
// accept; it resolves through the same priority chain as progress.
type koreaderBookRef struct {
UUID string `json:"uuid,omitempty" query:"uuid"`
SHA256 string `json:"sha256,omitempty" query:"sha256"`
Title string `json:"title,omitempty" query:"title"`
Authors []string `json:"authors,omitempty"`
}
func (r koreaderBookRef) toProgress() KOReaderBookProgress {
return KOReaderBookProgress{
UUID: r.UUID,
SHA256: r.SHA256,
Title: r.Title,
Authors: r.Authors,
}
}
// resolveParsedRef resolves an already-bound book identity to a media
// item. Endpoints bind ONCE — a resolve helper that re-bound the body
// would drain it and break the payload bind that follows.
func (h *KOReaderHandler) resolveParsedRef(c *echo.Context, device database.Devices, ref koreaderBookRef) (pgtype.UUID, error) {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, device.UserID, ref.toProgress())
if !mediaItemID.Valid {
return pgtype.UUID{}, fmt.Errorf("book not found on server")
}
return mediaItemID, nil
}
// GetKOReaderRating handles GET /api/sync/koreader/rating?uuid=&sha256=
func (h *KOReaderHandler) GetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// SetKOReaderRating handles POST /api/sync/koreader/rating. Rating 0
// (koreader's "unrated") clears the server rating.
func (h *KOReaderHandler) SetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Rating int `json:"rating" validate:"min=0,max=10"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if req.Rating <= 0 {
if err := h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Rating: int32(req.Rating),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// GetKOReaderReview handles GET /api/sync/koreader/review. Returns the
// RAW MARKDOWN source (koreader's review box shows it literally, the
// same accepted tradeoff as notes).
func (h *KOReaderHandler) GetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
review, err := h.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// SetKOReaderReview handles POST /api/sync/koreader/review. An empty
// body clears the review (mirrors the first-party PUT contract).
func (h *KOReaderHandler) SetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Review string `json:"review" validate:"max=50000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if strings.TrimSpace(req.Review) == "" {
if err := h.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := h.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// PushKOReaderStats handles POST /api/sync/koreader/stats. The device
// sends its cumulative total reading seconds for the book; only the
// delta above the stored watermark becomes a reading_history session
// (attributed to the koreader device, metadata carrying the new
// cumulative marker). Sub-minute deltas ride forward: no session, no
// watermark movement, nothing lost.
func (h *KOReaderHandler) PushKOReaderStats(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
TotalSeconds int64 `json:"total_seconds" validate:"min=0,max=2000000000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
// sqlc scans COALESCE(MAX(...)) as interface{}; pgx hands bigint
// back as int64.
watermarkAny, err := h.db.GetKoreaderStatWatermark(c.Request().Context(), database.GetKoreaderStatWatermarkParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
watermark, _ := watermarkAny.(int64)
// Delta against the stored watermark (the highest total ever
// converted for this user+book across ALL koreader devices). When
// another device is ahead, the delta clamps to zero so a lagging
// device can never drag the total backwards.
delta := req.TotalSeconds - watermark
if delta < 0 {
delta = 0
}
result := map[string]interface{}{
"total_seconds": req.TotalSeconds,
"watermark": watermark,
"delta_seconds": delta,
"session_recorded": false,
}
if delta >= 60 {
now := time.Now()
start := now.Add(-time.Duration(delta) * time.Second)
metadata, _ := json.Marshal(map[string]interface{}{
"source": "koreader",
"total_seconds": req.TotalSeconds,
})
if _, err := h.db.CreateReadingHistory(c.Request().Context(), database.CreateReadingHistoryParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
DeviceID: device.ID,
ProgressPercentage: pgtype.Float8{Float64: 0, Valid: false},
ReadingSessionStart: pgtype.Timestamptz{Time: start, Valid: true},
ReadingSessionEnd: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 0, Valid: false},
TimeSpentSeconds: pgtype.Int4{Int32: int32(delta), Valid: true},
DeviceMetadata: metadata,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
result["session_recorded"] = true
result["session_seconds"] = delta
}
return c.JSON(http.StatusOK, result)
}