feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.
INGEST (device → server):
KOReader (koreader.go):
- Add processBookAnnotations helper that processes inline highlights,
notes, and bookmarks from every progress push (immediate + checkpoint)
- Highlights get CRE→CFI position conversion before SaveHighlight
- KOReader 'notes' (text + notes) stored as highlights with NoteText
to ensure correct round-trip classification
- Bookmarks routed through SaveBookmark with device sync data
- Called from both updateProgressForBook and handleCheckpointSync
Kobo (kobo.go):
- Markup handler: annotations and bookmarks route through
AnnotationService (SaveHighlight/SaveBookmark)
- Bookmark handler: same routing with device sync data
- SyncFromServer handler: same routing
- All handlers fall back to direct DB calls when annotationSvc == nil
Web reader (media.go):
- CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
- CreateMediaNote → SaveNote (Source="web")
- DeleteMediaHighlight → TombstoneHighlightByID
- DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
- All fall back to old behavior when annotationSvc == nil
SERVE (server → device):
KOReader GetMetadata (koreader.go):
- Query and serve bookmarks from media_bookmarks table (was missing)
- Serve deleted_highlights and deleted_bookmarks arrays containing
device_sync_data + dedup_key for client-side deletion
- Highlights/notes already served with reverse CFI conversion
Kobo Markup handler (kobo.go):
- Track processed books during sync
- Query tombstones per book, extract bookmark_id from device_sync_data
- Return DeletedAnnotations array in KoboSyncStatus response
Conflict resolution (conflicts.go):
- Enable annotation conflict types in ResolveConflict handler
- Add applyAnnotationResolution dispatching to:
applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
- Each looks up by dedup_key and applies winner's fields
- Allow manual override of auto_resolved conflicts
(changed check from != "unresolved" to == "user_resolved")
Infrastructure:
- AnnotationService field + SetAnnotationService in router Config
- Inject AnnotationService into KOReader, Kobo, Media handlers
- Start tombstone purger goroutine in main.go (24h interval)
- Test helpers: construct AnnotationService in test setup
This commit is contained in:
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
if conflict.ResolutionStatus.String != "unresolved" {
|
||||
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
appliedTo["annotations"] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolutionData := map[string]interface{}{
|
||||
"winner": req.Winner,
|
||||
"applied_to": appliedTo,
|
||||
@@ -356,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||
if dedupKey == "" {
|
||||
return errors.New("missing dedup_key in winner data")
|
||||
}
|
||||
|
||||
switch conflictType {
|
||||
case "annotation_highlight":
|
||||
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_bookmark":
|
||||
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_note":
|
||||
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
default:
|
||||
return errors.New("unknown annotation conflict type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
params.SelectionText = v
|
||||
}
|
||||
if v, ok := data["color"].(string); ok {
|
||||
params.Color = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["note_text"].(string); ok {
|
||||
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["start_position"].(string); ok {
|
||||
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["end_position"].(string); ok {
|
||||
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
params.Title = v
|
||||
}
|
||||
if v, ok := data["notes"].(string); ok {
|
||||
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: existing.Content,
|
||||
Position: existing.Position,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
CharacterStart: existing.CharacterStart,
|
||||
CharacterEnd: existing.CharacterEnd,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
ParagraphReference: existing.ParagraphReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
params.Content = v
|
||||
}
|
||||
if v, ok := data["position"].(string); ok {
|
||||
params.Position = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||
if err != nil {
|
||||
|
||||
+230
-61
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
@@ -30,6 +32,10 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
@@ -245,9 +251,16 @@ type KoboInitResponse struct {
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||
}
|
||||
|
||||
type KoboDeletedAnnotation struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
BookmarkId string `json:"BookmarkId"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type KoboServerSyncData struct {
|
||||
@@ -311,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
}
|
||||
|
||||
bookmarkCount := 0
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -403,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
processedBooks := make(map[pgtype.UUID]string)
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||
@@ -412,6 +426,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||
percentage := readingSync.PercentRead / 100.0
|
||||
|
||||
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||
@@ -465,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "last-read-place":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
@@ -564,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
for mediaItemID, contentId := range processedBooks {
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||
if bookmarkID == "" {
|
||||
continue
|
||||
}
|
||||
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||
ContentId: contentId,
|
||||
BookmarkId: bookmarkID,
|
||||
Type: ts.AnnotationType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include unlinked books count if any
|
||||
if unlinkedBooks > 0 {
|
||||
// For now, just log it. In production, this should trigger an alert
|
||||
@@ -606,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
|
||||
for _, bookmark := range syncData.Bookmarks {
|
||||
if bookmark.BookmarkType == "bookmark" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.BookmarkText,
|
||||
Position: bookmark.BookmarkId,
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
}
|
||||
} else if bookmark.BookmarkType == "annotation" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: bookmark.BookmarkId,
|
||||
EndPosition: bookmark.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range syncData.Highlights {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: highlight.BookmarkId,
|
||||
EndPosition: highlight.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+316
-36
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ type KOReaderHandler struct {
|
||||
connManager *wsync.ConnectionManager
|
||||
queue *wsync.SyncQueueProcessor
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
@@ -34,6 +36,27 @@ 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
|
||||
}
|
||||
@@ -154,9 +177,11 @@ type KOReaderProgressData struct {
|
||||
}
|
||||
|
||||
type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
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 {
|
||||
@@ -396,6 +421,7 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
||||
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(),
|
||||
@@ -441,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
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()
|
||||
|
||||
@@ -522,7 +644,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
@@ -558,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
},
|
||||
)
|
||||
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -657,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
progressData.TotalPages = &progress
|
||||
}
|
||||
|
||||
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -670,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
||||
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: ann.StartPosition.String,
|
||||
Pos1: ann.EndPosition.String,
|
||||
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,
|
||||
@@ -686,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
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(-wsync.TombstoneTTL), 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)
|
||||
@@ -737,6 +927,21 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -776,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgItemUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -876,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
bookmarksSynced++
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
notesSynced++
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
color = highlight.Color
|
||||
}
|
||||
|
||||
_, 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 h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||
|
||||
if err == nil {
|
||||
highlightsSynced++
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-12
@@ -19,6 +19,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -136,6 +137,7 @@ type MediaHandler struct {
|
||||
libraryService *services.LibraryService
|
||||
searchService *services.SearchService
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
}
|
||||
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||
@@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
mh.progressSvc = svc
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
@@ -1388,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
var note database.MediaNotes
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: req.Position,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
note = result.Note
|
||||
} else {
|
||||
var err error
|
||||
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, note)
|
||||
@@ -1456,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1525,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
||||
color = req.Color
|
||||
}
|
||||
|
||||
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
Color: color,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusCreated, result.Highlight)
|
||||
}
|
||||
|
||||
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
@@ -1613,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ type Config struct {
|
||||
ConnManager *sync.ConnectionManager
|
||||
QueueProcessor *sync.SyncQueueProcessor
|
||||
ProgressService *sync.ProgressService
|
||||
AnnotationService *sync.AnnotationService
|
||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
|
||||
@@ -33,6 +33,7 @@ func registerSyncRoutes(cfg *Config) {
|
||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||
koboHandler.SetProgressService(cfg.ProgressService)
|
||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||
koboSync := e.Group("/api/sync/kobo/:token")
|
||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
|
||||
Reference in New Issue
Block a user