Both directions synced data but rendered nothing: - Web reader <- devices: highlights painted no overlay. Device pushes resolve their start xpointer exactly (text-search anchored by the selection) but the end conversion carries no context and fell back to a document-start CFI (epubcfi .../1:0) — a garbage range end. When the start resolved exactly, the end is now derived from it: same node, character offset advanced by the selection's UTF-16 length (extendCFIByLength). Same repair when SERVING to devices, where old web highlights (no end anchor) and converted range CFIs both collapsed pos1 onto pos0 (extendXPointerByLength on the xpointer form) — KOReader drew zero-width highlights. - Colors: KOReader paints from a fixed name set (Blitbuffer HIGHLIGHT_COLORS), the web uses hex swatches; neither understood the other, so device colors fell back to defaults and web hex drew nothing useful on devices. Both boundaries now translate: ingest maps names to hex (default #ffd54f), GetMetadata maps hex to names (default yellow) — per-datatype edits re-push with the editing side's color, which LWW then propagates. SyncBookmarks endpoint aligned to the same mapping and default.
1510 lines
48 KiB
Go
1510 lines
48 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"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) *KOReaderHandler {
|
|
return &KOReaderHandler{
|
|
db: db,
|
|
connManager: connManager,
|
|
queue: queue,
|
|
bookResolver: services.NewBookResolver(db),
|
|
}
|
|
}
|
|
|
|
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
|
h.progressSvc = svc
|
|
}
|
|
|
|
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|
h.annotationSvc = svc
|
|
}
|
|
|
|
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
|
if pos0 == "" || h.libraryService == nil {
|
|
return "", ""
|
|
}
|
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil || epubPath == "" {
|
|
return "", ""
|
|
}
|
|
// The annotation's own text is the ideal anchor for the converter's
|
|
// text-search path: clients (thin, underpowered) send only raw
|
|
// locators, the server resolves them against the actual book.
|
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
|
endCFI := endLoc.CFI
|
|
// The end conversion carries no context text, so unless it resolved
|
|
// exactly it degenerates to a percentage fallback anchored at the
|
|
// document start — useless as a range end. When the START resolved
|
|
// exactly, derive the end from it: same node, character offset
|
|
// advanced by the selection's UTF-16 length (the CFI offset unit).
|
|
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
|
|
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
|
}
|
|
return startLoc.CFI, endCFI
|
|
}
|
|
|
|
// 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) + ")"
|
|
}
|
|
|
|
// 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(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
|
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
|
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
|
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
|
return float64(page) / float64(mediaItem.PageCount.Int32)
|
|
}
|
|
return 0
|
|
}
|
|
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
|
|
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
|
return wsync.NewCFIConverter(epubPath).SectionPercentage(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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
for _, hl := range book.Highlights {
|
|
startPos := hl.Pos0
|
|
endPos := hl.Pos1
|
|
// The highlight's own text anchors the conversion exactly.
|
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
|
|
|
|
pctStart := 0.0
|
|
if hl.Percentage != nil {
|
|
pctStart = *hl.Percentage
|
|
}
|
|
if pctStart == 0 {
|
|
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
|
}
|
|
|
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
|
"datetime": hl.Datetime,
|
|
"pos0": hl.Pos0,
|
|
"pos1": hl.Pos1,
|
|
"page": hl.Page,
|
|
})
|
|
|
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
SelectionText: hl.Text,
|
|
StartPosition: startPos,
|
|
EndPosition: endPos,
|
|
Color: mapColorFromKOReader(hl.Color),
|
|
NoteText: hl.Notes,
|
|
PercentageStart: pctStart,
|
|
EpubcfiStart: epubcfiStart,
|
|
EpubcfiEnd: epubcfiEnd,
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
})
|
|
}
|
|
|
|
for _, note := range book.Notes {
|
|
startPos := note.Pos0
|
|
endPos := note.Pos1
|
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
|
|
|
pctStart := 0.0
|
|
if note.Percentage != nil {
|
|
pctStart = *note.Percentage
|
|
}
|
|
if pctStart == 0 {
|
|
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
|
}
|
|
|
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
|
"datetime": note.Datetime,
|
|
"pos0": note.Pos0,
|
|
"pos1": note.Pos1,
|
|
"page": note.Page,
|
|
})
|
|
|
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
SelectionText: note.Text,
|
|
StartPosition: startPos,
|
|
EndPosition: endPos,
|
|
NoteText: note.Notes,
|
|
PercentageStart: pctStart,
|
|
EpubcfiStart: epubcfiStart,
|
|
EpubcfiEnd: epubcfiEnd,
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
})
|
|
}
|
|
|
|
for _, bookmark := range book.Bookmarks {
|
|
position := ""
|
|
if bookmark.Pos0 != "" {
|
|
position = bookmark.Pos0
|
|
} else if bookmark.Page > 0 {
|
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
|
}
|
|
|
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
|
"datetime": bookmark.Datetime,
|
|
"pos0": bookmark.Pos0,
|
|
"page": bookmark.Page,
|
|
})
|
|
|
|
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Title: bookmark.Text,
|
|
Position: position,
|
|
ChapterNumber: int32(bookmark.Chapter),
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
})
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
if h.progressSvc != nil {
|
|
epubcfi := book.Epubcfi
|
|
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
|
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
|
if err != nil {
|
|
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
|
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
|
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
|
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
|
// comic archives) has no extractable text, so CRE→CFI conversion
|
|
// cannot succeed. The page index (page/total_pages) is the
|
|
// canonical locator. Keep the incoming xpointer for device-native
|
|
// restore; the web reader restores by page.
|
|
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
|
} else if h.libraryService == nil {
|
|
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
|
} else {
|
|
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
|
if resolveErr != nil {
|
|
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
|
} else if epubPath == "" {
|
|
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
|
} else {
|
|
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
|
converter := wsync.NewCFIConverter(epubPath)
|
|
pct := 0.0
|
|
if book.Percentage >= 0 {
|
|
pct = book.Percentage
|
|
}
|
|
contextText := ""
|
|
if book.ContextText != nil {
|
|
contextText = *book.ContextText
|
|
}
|
|
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
|
if convErr != nil {
|
|
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
|
} else if result != nil {
|
|
if result.EPUBCFI != "" {
|
|
convertedCFI := result.EPUBCFI
|
|
epubcfi = &convertedCFI
|
|
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
|
} else if result.Href != "" {
|
|
convertedHref := result.Href
|
|
epubcfi = &convertedHref
|
|
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
|
} else {
|
|
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
|
return nil
|
|
}
|
|
|
|
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
|
Epubcfi: textPtrToPgText(book.Epubcfi),
|
|
Chapter: intPtrToPgInt4(book.Chapter),
|
|
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
|
CharacterOffset: int64PtrToPgInt8(book.Character),
|
|
CurrentPage: intPtrToPgInt4(book.Page),
|
|
TotalPages: intPtrToPgInt4(book.TotalPages),
|
|
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
|
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
|
ViewportY: pgtype.Float8{},
|
|
ScrollPositionX: pgtype.Float8{},
|
|
ScrollPositionY: pgtype.Float8{},
|
|
PanelNumber: pgtype.Int4{},
|
|
ReadingMode: pgtype.Text{},
|
|
ZoomLevel: pgtype.Float8{},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
h.connManager.BroadcastProgressUpdate(
|
|
mediaItemID.Bytes,
|
|
book.Percentage,
|
|
wsync.SourceDevice{
|
|
ID: uuid.UUID(deviceID.Bytes).String(),
|
|
Name: deviceModel,
|
|
Type: "koreader",
|
|
},
|
|
)
|
|
|
|
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
|
|
|
return nil
|
|
}
|
|
|
|
func textPtrToPgText(s *string) pgtype.Text {
|
|
if s != nil {
|
|
return pgtype.Text{String: *s, Valid: true}
|
|
}
|
|
return pgtype.Text{}
|
|
}
|
|
|
|
func intPtrToPgInt4(i *int) pgtype.Int4 {
|
|
if i != nil {
|
|
return pgtype.Int4{Int32: int32(*i), Valid: true}
|
|
}
|
|
return pgtype.Int4{}
|
|
}
|
|
|
|
func int64PtrToPgInt8(i *int64) pgtype.Int8 {
|
|
if i != nil {
|
|
return pgtype.Int8{Int64: *i, Valid: true}
|
|
}
|
|
return pgtype.Int8{}
|
|
}
|
|
|
|
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,
|
|
Color: mapColorToKOReader(ann.Color.String),
|
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
|
}
|
|
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),
|
|
})
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
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) {
|
|
if h.libraryService == nil {
|
|
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
|
return
|
|
}
|
|
|
|
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
|
return
|
|
}
|
|
if epubPath == "" {
|
|
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
|
return
|
|
}
|
|
|
|
converter := wsync.NewCFIConverter(epubPath)
|
|
contextText := ""
|
|
if progress.ContextText.Valid {
|
|
contextText = progress.ContextText.String
|
|
}
|
|
pct := progress.Percentage.Float64
|
|
|
|
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
|
if err != nil {
|
|
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
|
return
|
|
}
|
|
if result != nil && result.XPointer != "" {
|
|
progressData.KoreaderXPointer = &result.XPointer
|
|
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
|
}
|
|
}
|
|
|
|
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) 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, 0, 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",
|
|
}
|
|
|
|
var koreaderColorFromHex = map[string]string{
|
|
"#ffd54f": "yellow",
|
|
"#a5d6a7": "green",
|
|
"#90caf9": "blue",
|
|
"#ce93d8": "purple",
|
|
"#f48fb1": "purple",
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// mapColorToKOReader normalizes a web hex swatch to a KOReader color
|
|
// name (default yellow) when serving to devices.
|
|
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); 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 {
|
|
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,
|
|
ChapterNumber: int32(bookmark.Chapter),
|
|
Source: "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 {
|
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
|
|
|
|
pctStart := 0.0
|
|
if highlight.Percentage != nil {
|
|
pctStart = *highlight.Percentage
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|