Reverses the earlier "no colors to the device" decision now that the echo machinery makes it safe: GetMetadata maps the stored web hex to KOReader's fixed color names (#ce93d8→purple, #90caf9→blue, #a5d6a7→green, #ffd54f→yellow; pink maps to purple as the closest — round-trip drift is prevented on the device by echo suppression, and a device edit still wins). mapColorToKOReader restored for serving; ingest (name→hex, preserve-on-echo) unchanged.
1575 lines
50 KiB
Go
1575 lines
50 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) + ")"
|
|
}
|
|
|
|
// existingHighlightColor returns the stored color of the highlight matching
|
|
// the dedup key ("" when none) so device echoes that carry no color never
|
|
// clobber the web color.
|
|
func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string {
|
|
if dedupKey == "" {
|
|
return ""
|
|
}
|
|
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
|
UserID: userID,
|
|
MediaItemID: mediaItemID,
|
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return existing.Color.String
|
|
}
|
|
|
|
// deriveAnnotationPercentage computes a percentage for device-pushed
|
|
// annotations when the client didn't send one (thin clients skip their own
|
|
// per-annotation page lookups; arithmetic is only free on paging documents).
|
|
func (h *KOReaderHandler) deriveAnnotationPercentage(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"`
|
|
DedupKey string `json:"dedup_key,omitempty"`
|
|
}
|
|
|
|
type KOReaderHighlight struct {
|
|
Chapter FlexInt `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page FlexInt `json:"page,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Percentage *float64 `json:"percentage,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
DedupKey string `json:"dedup_key,omitempty"`
|
|
}
|
|
|
|
type KOReaderNote struct {
|
|
Chapter FlexInt `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page FlexInt `json:"page,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Percentage *float64 `json:"percentage,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
DedupKey string `json:"dedup_key,omitempty"`
|
|
}
|
|
|
|
type KOReaderSyncResponse struct {
|
|
SyncStatus string `json:"sync_status"`
|
|
BooksSynced int `json:"books_synced"`
|
|
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
|
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
|
Timestamp string `json:"timestamp"`
|
|
DeviceUpdated bool `json:"device_updated"`
|
|
}
|
|
|
|
type KOReaderBookSyncResult struct {
|
|
SHA256 string `json:"sha256"`
|
|
BookUUID string `json:"book_uuid"`
|
|
Synced bool `json:"synced"`
|
|
}
|
|
|
|
type KOReaderConflict struct {
|
|
BookUUID string `json:"book_uuid"`
|
|
ConflictType string `json:"conflict_type"`
|
|
DeviceProgress float64 `json:"device_progress"`
|
|
ServerProgress float64 `json:"server_progress"`
|
|
Resolution string `json:"resolution"`
|
|
}
|
|
|
|
type KOReaderMetadata struct {
|
|
UUID string `json:"uuid"`
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
Title string `json:"title"`
|
|
Authors []string `json:"authors"`
|
|
Progress KOReaderProgressData `json:"progress"`
|
|
Annotations KOReaderAnnotations `json:"annotations"`
|
|
LastSync string `json:"last_sync"`
|
|
}
|
|
|
|
type KOReaderProgressData struct {
|
|
Percentage float64 `json:"percentage"`
|
|
Character *int64 `json:"character,omitempty"`
|
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
|
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
|
Chapter *int `json:"chapter,omitempty"`
|
|
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
|
Page *int `json:"page,omitempty"`
|
|
TotalPages *int `json:"total_pages,omitempty"`
|
|
}
|
|
|
|
type KOReaderAnnotations struct {
|
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
|
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
|
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
|
}
|
|
|
|
type KOReaderLibraryResponse struct {
|
|
LibrarySync []KOReaderLibraryBook `json:"library_sync"`
|
|
TotalBooks int `json:"total_books"`
|
|
LastSync string `json:"last_sync"`
|
|
}
|
|
|
|
type KOReaderLibraryBook struct {
|
|
UUID string `json:"uuid"`
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
ContentType string `json:"content_type"`
|
|
PercentRead float64 `json:"percent_read"`
|
|
PagesRemaining *int `json:"pages_remaining,omitempty"`
|
|
BookmarkCount int `json:"bookmark_count"`
|
|
LastModified string `json:"last_modified"`
|
|
}
|
|
|
|
func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|
device := c.Get("device").(database.Devices)
|
|
userID := device.UserID.Bytes
|
|
|
|
var req KOReaderProgressRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "invalid request format",
|
|
"details": err.Error(),
|
|
})
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
|
|
syncMode := req.SyncMode
|
|
if syncMode == "" {
|
|
syncMode = "immediate"
|
|
}
|
|
|
|
if syncMode == "checkpoint" {
|
|
return h.handleCheckpointSync(c, device, pgUserID, req)
|
|
}
|
|
|
|
booksSynced := 0
|
|
conflicts := []KOReaderConflict{}
|
|
bookResults := []KOReaderBookSyncResult{}
|
|
|
|
for _, book := range req.Books {
|
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
|
if !mediaItemID.Valid {
|
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
SHA256: book.SHA256,
|
|
Synced: false,
|
|
})
|
|
continue
|
|
}
|
|
|
|
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
|
synced := err == nil
|
|
if synced {
|
|
booksSynced++
|
|
}
|
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
SHA256: book.SHA256,
|
|
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
|
Synced: synced,
|
|
})
|
|
}
|
|
|
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to update device timestamp",
|
|
})
|
|
}
|
|
|
|
if syncMode == "immediate" {
|
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
|
SyncStatus: "accepted",
|
|
BooksSynced: booksSynced,
|
|
BookResults: bookResults,
|
|
Conflicts: conflicts,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
DeviceUpdated: true,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
|
SyncStatus: "completed",
|
|
BooksSynced: booksSynced,
|
|
BookResults: bookResults,
|
|
Conflicts: conflicts,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
DeviceUpdated: true,
|
|
})
|
|
}
|
|
|
|
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
|
|
ctx := c.Request().Context()
|
|
|
|
// Priority 1: UUID provided (highest confidence - 1.0)
|
|
if book.UUID != "" {
|
|
mediaUUID, err := uuid.Parse(book.UUID)
|
|
if err == nil {
|
|
mediaItem, err := h.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err == nil {
|
|
// Create device file alias if FilePath is also provided
|
|
if book.FilePath != "" {
|
|
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
|
}
|
|
return mediaItem.ID, 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 2: SHA-256 provided (medium confidence - 0.9)
|
|
// Uses the shared BookResolver, which also checks per-format hashes
|
|
// (media_item_formats) so a converted file (KEPUB/PDF) matches too.
|
|
if book.SHA256 != "" && len(book.SHA256) == 64 {
|
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, book.SHA256)
|
|
if err == nil {
|
|
// Create device file alias if FilePath is provided
|
|
if book.FilePath != "" {
|
|
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
|
}
|
|
return mediaItem.ID, 0.9
|
|
}
|
|
}
|
|
|
|
// Priority 3: FilePath provided (check existing alias or create new - confidence 0.7)
|
|
if book.FilePath != "" {
|
|
// Check if alias already exists
|
|
alias, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
|
|
DeviceID: deviceID,
|
|
FilePath: book.FilePath,
|
|
})
|
|
if err == nil {
|
|
return alias.MediaItemID, alias.ConfidenceScore.Float64
|
|
}
|
|
|
|
// Try to find by file path (search any library)
|
|
mediaItem, err := h.db.GetMediaItemByFilePathAnyLibrary(ctx, book.FilePath)
|
|
if err == nil {
|
|
// Create new alias
|
|
confidence := 0.7
|
|
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
|
return mediaItem.ID, confidence
|
|
}
|
|
}
|
|
|
|
// Priority 4: Title + Author match (lowest confidence - 0.5)
|
|
if book.Title != "" {
|
|
mediaItems, err := h.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
|
Limit: 100,
|
|
Offset: 0,
|
|
})
|
|
if err == nil {
|
|
for _, mi := range mediaItems {
|
|
if mi.Title == book.Title {
|
|
// Check author match if provided
|
|
if len(book.Authors) > 0 && mi.Author.Valid {
|
|
if mi.Author.String == book.Authors[0] {
|
|
// Create device file alias if FilePath is provided
|
|
if book.FilePath != "" {
|
|
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
|
|
}
|
|
return mi.ID, 0.5
|
|
}
|
|
} else {
|
|
// Title only match
|
|
if book.FilePath != "" {
|
|
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
|
|
}
|
|
return mi.ID, 0.4
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return pgtype.UUID{}, 0.0
|
|
}
|
|
|
|
func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
|
ctx := c.Request().Context()
|
|
|
|
if book.FilePath == "" {
|
|
return
|
|
}
|
|
|
|
sha256 := book.SHA256
|
|
if sha256 == "" {
|
|
// Try to get SHA256 from media item
|
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
|
if err == nil && mediaItem.FileSha256.Valid {
|
|
sha256 = mediaItem.FileSha256.String
|
|
}
|
|
}
|
|
|
|
confidence := 0.7
|
|
// Check if alias already exists
|
|
_, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
|
|
DeviceID: deviceID,
|
|
FilePath: book.FilePath,
|
|
})
|
|
if err != nil {
|
|
// Create new alias
|
|
_, _ = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
|
MediaItemID: mediaItemID,
|
|
DeviceID: deviceID,
|
|
FilePath: book.FilePath,
|
|
FileSha256: pgtype.Text{String: sha256, Valid: sha256 != ""},
|
|
ConfidenceScore: pgtype.Float8{Float64: confidence, Valid: true},
|
|
})
|
|
}
|
|
}
|
|
|
|
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
|
booksEnqueued := 0
|
|
bookResults := []KOReaderBookSyncResult{}
|
|
|
|
for _, book := range req.Books {
|
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
|
if !mediaItemID.Valid {
|
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
SHA256: book.SHA256,
|
|
Synced: false,
|
|
})
|
|
continue
|
|
}
|
|
|
|
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
|
synced := err == nil
|
|
if synced {
|
|
booksEnqueued++
|
|
}
|
|
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
|
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
|
SHA256: book.SHA256,
|
|
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
|
Synced: synced,
|
|
})
|
|
}
|
|
|
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to update device timestamp",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
|
SyncStatus: "checkpoint_enqueued",
|
|
BooksSynced: booksEnqueued,
|
|
BookResults: bookResults,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
|
if h.queue == nil {
|
|
return fmt.Errorf("sync queue not available")
|
|
}
|
|
|
|
update := &wsync.ProgressUpdate{
|
|
DeviceID: deviceID,
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Percentage: book.Percentage,
|
|
Epubcfi: book.Epubcfi,
|
|
ContextText: book.ContextText,
|
|
Chapter: book.Chapter,
|
|
Character: book.Character,
|
|
Page: book.Page,
|
|
TotalPages: book.TotalPages,
|
|
Source: "koreader",
|
|
SyncMode: "checkpoint",
|
|
}
|
|
|
|
return h.queue.EnqueueProgress(update)
|
|
}
|
|
|
|
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
|
if h.annotationSvc == nil {
|
|
return
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
// Color semantics: devices render their own default and cannot
|
|
// round-trip web colors. An echo carries NO color — preserve the
|
|
// stored (web) color so round-trips never change it. A non-empty
|
|
// color means the user edited the highlight on the device: map the
|
|
// device color name and let it win.
|
|
color := ""
|
|
if hl.Color != "" {
|
|
color = mapColorFromKOReader(hl.Color)
|
|
}
|
|
dedupKey := hl.DedupKey
|
|
if dedupKey == "" {
|
|
dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos)
|
|
}
|
|
if color == "" {
|
|
color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey)
|
|
}
|
|
if color == "" {
|
|
color = "#ffd54f"
|
|
}
|
|
|
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
SelectionText: hl.Text,
|
|
StartPosition: startPos,
|
|
EndPosition: endPos,
|
|
Color: color,
|
|
NoteText: hl.Notes,
|
|
PercentageStart: pctStart,
|
|
EpubcfiStart: epubcfiStart,
|
|
EpubcfiEnd: epubcfiEnd,
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
DedupKey: dedupKey,
|
|
})
|
|
}
|
|
|
|
for _, note := range book.Notes {
|
|
startPos := note.Pos0
|
|
endPos := note.Pos1
|
|
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,
|
|
})
|
|
|
|
dedupKey := note.DedupKey
|
|
if dedupKey == "" {
|
|
dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos)
|
|
}
|
|
|
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
SelectionText: note.Text,
|
|
StartPosition: startPos,
|
|
EndPosition: endPos,
|
|
Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey),
|
|
NoteText: note.Notes,
|
|
PercentageStart: pctStart,
|
|
EpubcfiStart: epubcfiStart,
|
|
EpubcfiEnd: epubcfiEnd,
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
DedupKey: dedupKey,
|
|
})
|
|
}
|
|
|
|
for _, bookmark := range book.Bookmarks {
|
|
position := ""
|
|
if bookmark.Pos0 != "" {
|
|
position = bookmark.Pos0
|
|
} else if bookmark.Page > 0 {
|
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
|
}
|
|
|
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
|
"datetime": bookmark.Datetime,
|
|
"pos0": bookmark.Pos0,
|
|
"page": bookmark.Page,
|
|
})
|
|
|
|
dedupKey := bookmark.DedupKey
|
|
if dedupKey == "" {
|
|
dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position)
|
|
}
|
|
|
|
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Title: bookmark.Text,
|
|
Position: position,
|
|
ChapterNumber: int32(bookmark.Chapter),
|
|
Source: "koreader",
|
|
DeviceSyncData: deviceData,
|
|
DedupKey: dedupKey,
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
// Web colors flow to the device, mapped to KOReader's named
|
|
// palette. Round-trip safety: the device suppresses the color
|
|
// when echoing un-edited applied entries (a pink→purple
|
|
// palette mismatch must not rewrite the stored hex), and an
|
|
// actual device edit pushes its color, which wins.
|
|
Color: mapColorToKOReader(ann.Color.String),
|
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
|
DedupKey: ann.DedupKey.String,
|
|
}
|
|
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
|
highlight.Notes = ann.NoteText.String
|
|
}
|
|
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
|
} else if ann.AnnotationType == "note" {
|
|
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "")
|
|
if pos0 == "" {
|
|
log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID)
|
|
continue
|
|
}
|
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
|
Text: ann.SelectionText,
|
|
Pos0: pos0,
|
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
|
DedupKey: ann.DedupKey.String,
|
|
})
|
|
}
|
|
}
|
|
|
|
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
|
MediaItemID: pgBookUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
for _, bm := range bookmarks {
|
|
pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "")
|
|
if pos0 == "" {
|
|
log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID)
|
|
continue
|
|
}
|
|
koreaderBookmark := KOReaderBookmark{
|
|
Text: bm.Title,
|
|
Pos0: pos0,
|
|
Pos1: pos0,
|
|
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
|
DedupKey: bm.DedupKey.String,
|
|
}
|
|
if bm.Notes.Valid && bm.Notes.String != "" {
|
|
koreaderBookmark.Notes = bm.Notes.String
|
|
}
|
|
if bm.ChapterNumber.Valid {
|
|
koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32)
|
|
}
|
|
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
|
}
|
|
|
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
|
MediaItemID: pgBookUUID,
|
|
UserID: pgUserID,
|
|
DeletedAt: cutoff,
|
|
})
|
|
for _, ts := range tombstones {
|
|
var dd map[string]interface{}
|
|
if len(ts.DeviceSyncData) > 0 {
|
|
json.Unmarshal(ts.DeviceSyncData, &dd)
|
|
}
|
|
if dd == nil {
|
|
dd = map[string]interface{}{}
|
|
}
|
|
dd["dedup_key"] = ts.DedupKey.String
|
|
// KOReader deletes by matching pos0. Device-pushed annotations carry
|
|
// it in device_sync_data; web-created ones don't (their locator is
|
|
// converted at serve time), so resolve it from the stored columns.
|
|
if dd["pos0"] == nil || dd["pos0"] == "" {
|
|
if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" {
|
|
dd["pos0"] = pos0
|
|
}
|
|
}
|
|
if ts.AnnotationType == "highlight" {
|
|
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
|
} else if ts.AnnotationType == "bookmark" {
|
|
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
|
|
}
|
|
}
|
|
|
|
lastSync := "never"
|
|
if progress.LastSyncTimestamp.Valid {
|
|
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
|
}
|
|
|
|
metadata := KOReaderMetadata{
|
|
UUID: bookUUID.String(),
|
|
SHA256: mediaItem.FileSha256.String,
|
|
Title: mediaItem.Title,
|
|
Authors: []string{mediaItem.Author.String},
|
|
Progress: progressData,
|
|
Annotations: annotationsResponse,
|
|
LastSync: lastSync,
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, metadata)
|
|
}
|
|
|
|
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
|
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",
|
|
}
|
|
|
|
// mapColorFromKOReader normalizes a device color name to a web hex
|
|
// swatch (default yellow) when ingesting device pushes.
|
|
func mapColorFromKOReader(name string) string {
|
|
if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok {
|
|
return hex
|
|
}
|
|
return "#ffd54f"
|
|
}
|
|
|
|
var koreaderColorFromHex = map[string]string{
|
|
"#ffd54f": "yellow",
|
|
"#a5d6a7": "green",
|
|
"#90caf9": "blue",
|
|
"#ce93d8": "purple",
|
|
"#f48fb1": "purple",
|
|
}
|
|
|
|
// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader
|
|
// color name (default yellow) when serving to devices. Pink maps to purple
|
|
// (the palette's closest); round-trip drift is prevented on the device by
|
|
// suppressing echo colors for un-edited applied entries.
|
|
func mapColorToKOReader(hex string) string {
|
|
if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok {
|
|
return name
|
|
}
|
|
return "yellow"
|
|
}
|
|
|
|
// 1. A device-native CRE xpointer ("/body/...") in startPosition wins —
|
|
// round-trip identical for KOReader-pushed annotations (converting the
|
|
// stored CFI instead could drift and duplicate on the device).
|
|
// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging
|
|
// documents use the page number as pos0).
|
|
// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the
|
|
// reader's "cfi:" prefix) → converted to a CRE xpointer, with
|
|
// contextText (the selection text) enabling the text-search fallback.
|
|
// 4. A "page:N" or bare-numeric position → the bare number.
|
|
//
|
|
// Returns "" when nothing usable exists; callers skip such annotations so
|
|
// devices never receive locators they cannot place.
|
|
func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string {
|
|
if wsync.IsCREXPointer(startPosition) {
|
|
return startPosition
|
|
}
|
|
if strings.HasPrefix(epubcfi, "{") {
|
|
var anchor pdfRectAnchor
|
|
if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 {
|
|
return strconv.Itoa(anchor.Page)
|
|
}
|
|
}
|
|
cfi := epubcfi
|
|
if cfi == "" && strings.HasPrefix(startPosition, "cfi:") {
|
|
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
|
}
|
|
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
|
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); 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),
|
|
})
|
|
}
|