Two blockers, diagnosed by simulating the plugin against the live
server with real library books:
1. Every KOReader progress push carrying annotations failed the JSON
bind with 400 ('cannot unmarshal string into ... chapter/page of
type int') — the plugin sends chapter:'', page:'30', and for CRE
documents page:'/body/...' — so annotation sync AND progress sync
failed together. KOReader annotation chapter/page now use FlexInt,
which accepts numbers, numeric strings, empty strings, and
non-numeric strings (decoding to 0). The server is deliberately
liberal here so thin clients can send raw bookmark data.
2. GetMetadata served locators KOReader cannot place, so pulled items
were junk: web bookmarks leaked 'cfi:epubcfi(...)' positions, web
PDF highlights had empty pos0 (skipped by the plugin, invisible),
and web deletions carried no pos0 so tombstones never matched.
New koreaderPos0 resolver handles every source: device-native
xpointers pass through untouched (round-trip identical, verified),
web PDF JSON anchors map to their page number, EPUB CFIs convert
to CRE xpointers (selection text passed as text-search context for
exact anchoring), 'page:N' positions strip to the bare number.
Unresolvable annotations are skipped with a log line instead of
poisoning devices; tombstones get pos0 injected from the new
locator columns.
Also: thin clients omit per-annotation percentages (paging docs still
send arithmetic page/total); the server derives them — section
midpoint from the spine char distribution for CRE documents, page/
page-count for fixed formats.
1405 lines
44 KiB
Go
1405 lines
44 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"
|
|
|
|
"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, "")
|
|
return startLoc.CFI, endLoc.CFI
|
|
}
|
|
|
|
// 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: 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
|
|
}
|
|
highlight := KOReaderHighlight{
|
|
Text: ann.SelectionText,
|
|
Pos0: pos0,
|
|
Pos1: pos1,
|
|
Color: 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:
|
|
//
|
|
// 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 := "#ffff00"
|
|
if highlight.Color != "" {
|
|
color = 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),
|
|
})
|
|
}
|