The 30-day retention window for soft-deleted annotations was a package const; move it behind the registry so it can be tuned live. annotations.go: - AnnotationService gains an optional *database.SettingsRegistry and a tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff now call it instead of reading the TombstoneTTL const directly. - Add ActiveTombstoneTTL() so callers outside the sync package can compute cutoffs consistently with the service. - The package-level TombstoneTTL const is retained as the fallback for tests / unwired code paths. kobo.go, koreader.go: - The per-book tombstone sweep cutoff now uses h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL const, so both the service and the handlers honor the configured TTL.
1247 lines
38 KiB
Go
1247 lines
38 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"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
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
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 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 "", ""
|
|
}
|
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
|
return startLoc.CFI, endLoc.CFI
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type KOReaderBookmark struct {
|
|
Chapter int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `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 int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `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 int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `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"`
|
|
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"`
|
|
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)
|
|
if book.SHA256 != "" && len(book.SHA256) == 64 {
|
|
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: book.SHA256, Valid: true})
|
|
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
|
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
|
|
|
pctStart := 0.0
|
|
if hl.Percentage != nil {
|
|
pctStart = *hl.Percentage
|
|
}
|
|
|
|
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)
|
|
|
|
pctStart := 0.0
|
|
if note.Percentage != nil {
|
|
pctStart = *note.Percentage
|
|
}
|
|
|
|
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" {
|
|
pos0 := ann.StartPosition.String
|
|
pos1 := ann.EndPosition.String
|
|
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
|
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
|
pos0 = converted
|
|
}
|
|
}
|
|
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
|
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
|
pos1 = converted
|
|
}
|
|
}
|
|
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" {
|
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
|
Text: ann.SelectionText,
|
|
Pos0: ann.StartPosition.String,
|
|
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 := bm.Position.String
|
|
if pos0 == "" && bm.CfiPosition.Valid {
|
|
pos0 = bm.CfiPosition.String
|
|
}
|
|
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 = int(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
|
|
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(),
|
|
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) 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, "", mediaItem.FormatGroup, epubPath, "")
|
|
if loc.Position != "" && loc.Position != epubcfi {
|
|
return loc.Position
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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(),
|
|
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
|
|
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true})
|
|
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.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: bookmark.BookSHA256, Valid: true})
|
|
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.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: note.BookSHA256, Valid: true})
|
|
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.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: highlight.BookSHA256, Valid: true})
|
|
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)
|
|
|
|
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),
|
|
})
|
|
}
|