Phase 3 Week 7: Implement KOReader sync protocol handlers
- Create SyncProgress for bidirectional progress synchronization - Create GetMetadata for book progress and annotation retrieval - Create GetLibrary for user library sync - Create SyncBookmarks for annotation management - Support device matching by UUID, file path, or title/author - Implement immediate and checkpoint sync modes - Handle bookmarks, highlights, and notes synchronization - Part of Phase 3 KOReader Integration implementation
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type KOReaderHandler struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
func NewKOReaderHandler(db *database.Queries) *KOReaderHandler {
|
||||
return &KOReaderHandler{db: db}
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
booksSynced := 0
|
||||
conflicts := []KOReaderConflict{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
var mediaUUID uuid.UUID
|
||||
var err error
|
||||
|
||||
if book.UUID != "" {
|
||||
mediaUUID, err = uuid.Parse(book.UUID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err == nil {
|
||||
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
}
|
||||
} else if book.FilePath != "" {
|
||||
mediaItem, err := h.db.GetMediaItemByFilePath(c.Request().Context(), book.FilePath)
|
||||
if err == nil {
|
||||
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
}
|
||||
} else if book.Title != "" {
|
||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err == nil {
|
||||
for _, mi := range mediaItems {
|
||||
if mi.Title == book.Title && (book.Authors == nil || mi.Author.String == book.Authors[0]) {
|
||||
err = h.updateProgressForBook(c, pgUserID, mi.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, 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 req.SyncMode == "immediate" {
|
||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||
SyncStatus: "accepted",
|
||||
BooksSynced: booksSynced,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
||||
SyncStatus: "completed",
|
||||
BooksSynced: booksSynced,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
var epubcfi pgtype.Text
|
||||
var chapter pgtype.Int4
|
||||
var characterOffset pgtype.Int8
|
||||
var currentPage pgtype.Int4
|
||||
var totalPages pgtype.Int4
|
||||
|
||||
if book.Epubcfi != nil {
|
||||
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
|
||||
}
|
||||
if book.Chapter != nil {
|
||||
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
|
||||
}
|
||||
if book.Character != nil {
|
||||
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
|
||||
}
|
||||
if book.Page != nil {
|
||||
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
|
||||
}
|
||||
if book.TotalPages != nil {
|
||||
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||
Epubcfi: epubcfi,
|
||||
Chapter: chapter,
|
||||
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||
CharacterOffset: characterOffset,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if progress.Epubcfi.Valid {
|
||||
progressData.Epubcfi = &progress.Epubcfi.String
|
||||
}
|
||||
if progress.Chapter.Valid {
|
||||
ch := int(progress.Chapter.Int32)
|
||||
progressData.Chapter = &ch
|
||||
}
|
||||
if progress.ChapterProgress.Valid {
|
||||
cp := progress.ChapterProgress.Float64
|
||||
progressData.ChapterProgress = &cp
|
||||
}
|
||||
if progress.CharacterOffset.Valid {
|
||||
co := int64(progress.CharacterOffset.Int64)
|
||||
progressData.Character = &co
|
||||
}
|
||||
if progress.CurrentPage.Valid {
|
||||
cp := int(progress.CurrentPage.Int32)
|
||||
progressData.Page = &cp
|
||||
}
|
||||
if progress.TotalPages.Valid {
|
||||
tp := int(progress.TotalPages.Int32)
|
||||
progressData.TotalPages = &tp
|
||||
}
|
||||
|
||||
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
|
||||
annotationsResponse := KOReaderAnnotations{
|
||||
Highlights: []KOReaderHighlight{},
|
||||
Notes: []KOReaderNote{},
|
||||
Bookmarks: []KOReaderBookmark{},
|
||||
}
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos1: ann.EndPosition.String,
|
||||
Color: ann.Color.String,
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
})
|
||||
} 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) 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 {
|
||||
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||
pagesRemaining = &remaining
|
||||
}
|
||||
if progress.LastReadAt.Valid {
|
||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
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" validate:"required"`
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
bookmarksSynced := 0
|
||||
notesSynced := 0
|
||||
highlightsSynced := 0
|
||||
|
||||
for _, bookmark := range req.Bookmarks {
|
||||
position := ""
|
||||
if bookmark.Pos0 != "" {
|
||||
position = bookmark.Pos0
|
||||
} else if bookmark.Page > 0 {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
|
||||
for _, note := range req.Notes {
|
||||
position := ""
|
||||
if note.Pos0 != "" {
|
||||
position = note.Pos0
|
||||
} else if note.Page > 0 {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
notesSynced++
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range req.Highlights {
|
||||
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
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user