Merge branch 'sync-annotations-fix': bidirectional annotation sync for KOReader
Server-side (8 commits): web annotations finally reach KOReader and vice versa. Fixed the 400 bind failures on every annotation-carrying push (loose client types), resolved device-native pos0 locators for every source (device xpointers pass through round-trip identical, web CFIs convert to CRE xpointers with text-search anchoring, PDF anchors map to pages), derived degenerate range ends from selection length, echo-deduplication via served dedup keys (pull→push cycles converge instead of minting duplicates), web↔device color mapping at both boundaries with echo suppression (web colors flow to devices, round-trips never drift them, device edits win), drawer-based annotation classification, and tombstone propagation that can't cross-delete. Perf: parsed-EPUB converter cache (bounded, locked). Plugin-side (bookhoard.koplugin @ 4ea3966): dual-model annotation store (KOReader 2024.07+ v2 ui.annotation + legacy v1), thin-client collection (no per-annotation CRE lookups), dedup-key identity matching, device-default coloring for applied highlights with datetime_updated-based echo suppression, and native-shaped AnnotationsModified dispatches (fixes a ReaderThumbnail crash and paints immediately instead of after restart).
This commit is contained in:
@@ -6852,7 +6852,11 @@ SELECT
|
|||||||
mh.dedup_key,
|
mh.dedup_key,
|
||||||
'highlight' as annotation_type,
|
'highlight' as annotation_type,
|
||||||
mh.device_sync_data,
|
mh.device_sync_data,
|
||||||
mh.deleted_at
|
mh.deleted_at,
|
||||||
|
mh.start_position,
|
||||||
|
mh.end_position,
|
||||||
|
mh.epubcfi_start,
|
||||||
|
mh.epubcfi_end
|
||||||
FROM media_highlights mh
|
FROM media_highlights mh
|
||||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -6861,7 +6865,11 @@ SELECT
|
|||||||
mn.dedup_key,
|
mn.dedup_key,
|
||||||
'note' as annotation_type,
|
'note' as annotation_type,
|
||||||
mn.device_sync_data,
|
mn.device_sync_data,
|
||||||
mn.deleted_at
|
mn.deleted_at,
|
||||||
|
mn.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mn.epubcfi_location as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
FROM media_notes mn
|
FROM media_notes mn
|
||||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -6870,7 +6878,11 @@ SELECT
|
|||||||
mb.dedup_key,
|
mb.dedup_key,
|
||||||
'bookmark' as annotation_type,
|
'bookmark' as annotation_type,
|
||||||
mb.device_sync_data,
|
mb.device_sync_data,
|
||||||
mb.deleted_at
|
mb.deleted_at,
|
||||||
|
mb.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mb.cfi_position as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
FROM media_bookmarks mb
|
FROM media_bookmarks mb
|
||||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||||
ORDER BY deleted_at DESC
|
ORDER BY deleted_at DESC
|
||||||
@@ -6888,6 +6900,10 @@ type GetTombstonedAnnotationsForBookRow struct {
|
|||||||
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||||
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||||
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||||
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) {
|
func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) {
|
||||||
@@ -6905,6 +6921,10 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo
|
|||||||
&i.AnnotationType,
|
&i.AnnotationType,
|
||||||
&i.DeviceSyncData,
|
&i.DeviceSyncData,
|
||||||
&i.DeletedAt,
|
&i.DeletedAt,
|
||||||
|
&i.StartPosition,
|
||||||
|
&i.EndPosition,
|
||||||
|
&i.EpubcfiStart,
|
||||||
|
&i.EpubcfiEnd,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -992,7 +992,11 @@ SELECT
|
|||||||
mh.dedup_key,
|
mh.dedup_key,
|
||||||
'highlight' as annotation_type,
|
'highlight' as annotation_type,
|
||||||
mh.device_sync_data,
|
mh.device_sync_data,
|
||||||
mh.deleted_at
|
mh.deleted_at,
|
||||||
|
mh.start_position,
|
||||||
|
mh.end_position,
|
||||||
|
mh.epubcfi_start,
|
||||||
|
mh.epubcfi_end
|
||||||
FROM media_highlights mh
|
FROM media_highlights mh
|
||||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -1001,7 +1005,11 @@ SELECT
|
|||||||
mn.dedup_key,
|
mn.dedup_key,
|
||||||
'note' as annotation_type,
|
'note' as annotation_type,
|
||||||
mn.device_sync_data,
|
mn.device_sync_data,
|
||||||
mn.deleted_at
|
mn.deleted_at,
|
||||||
|
mn.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mn.epubcfi_location as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
FROM media_notes mn
|
FROM media_notes mn
|
||||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -1010,7 +1018,11 @@ SELECT
|
|||||||
mb.dedup_key,
|
mb.dedup_key,
|
||||||
'bookmark' as annotation_type,
|
'bookmark' as annotation_type,
|
||||||
mb.device_sync_data,
|
mb.device_sync_data,
|
||||||
mb.deleted_at
|
mb.deleted_at,
|
||||||
|
mb.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mb.cfi_position as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
FROM media_bookmarks mb
|
FROM media_bookmarks mb
|
||||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||||
ORDER BY deleted_at DESC;
|
ORDER BY deleted_at DESC;
|
||||||
|
|||||||
+407
-92
@@ -9,7 +9,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -47,7 +50,7 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|||||||
h.annotationSvc = svc
|
h.annotationSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
|
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
||||||
if pos0 == "" || h.libraryService == nil {
|
if pos0 == "" || h.libraryService == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
@@ -59,9 +62,89 @@ func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaIt
|
|||||||
if err != nil || epubPath == "" {
|
if err != nil || epubPath == "" {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
// The annotation's own text is the ideal anchor for the converter's
|
||||||
|
// text-search path: clients (thin, underpowered) send only raw
|
||||||
|
// locators, the server resolves them against the actual book.
|
||||||
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||||
return startLoc.CFI, endLoc.CFI
|
endCFI := endLoc.CFI
|
||||||
|
// The end conversion carries no context text, so unless it resolved
|
||||||
|
// exactly it degenerates to a percentage fallback anchored at the
|
||||||
|
// document start — useless as a range end. When the START resolved
|
||||||
|
// exactly, derive the end from it: same node, character offset
|
||||||
|
// advanced by the selection's UTF-16 length (the CFI offset unit).
|
||||||
|
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
|
||||||
|
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
||||||
|
}
|
||||||
|
return startLoc.CFI, endCFI
|
||||||
|
}
|
||||||
|
|
||||||
|
// extendCFIByLength advances a point CFI's trailing character offset by the
|
||||||
|
// UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units).
|
||||||
|
// Selections spanning multiple text nodes produce an out-of-range offset —
|
||||||
|
// harmless: resolution clamps or fails, and consumers fall back to the start.
|
||||||
|
func extendCFIByLength(cfi, text string) string {
|
||||||
|
if cfi == "" || text == "" {
|
||||||
|
return cfi
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(cfi, ":")
|
||||||
|
if i < 0 || !strings.HasSuffix(cfi, ")") {
|
||||||
|
return cfi
|
||||||
|
}
|
||||||
|
off, err := strconv.Atoi(cfi[i+1 : len(cfi)-1])
|
||||||
|
if err != nil {
|
||||||
|
return cfi
|
||||||
|
}
|
||||||
|
utf16len := 0
|
||||||
|
for _, r := range text {
|
||||||
|
if r > 0xFFFF {
|
||||||
|
utf16len += 2
|
||||||
|
} else {
|
||||||
|
utf16len++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfi[:i+1] + strconv.Itoa(off+utf16len) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// existingHighlightColor returns the stored color of the highlight matching
|
||||||
|
// the dedup key ("" when none) so device echoes that carry no color never
|
||||||
|
// clobber the web color.
|
||||||
|
func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string {
|
||||||
|
if dedupKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||||
|
UserID: userID,
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return existing.Color.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveAnnotationPercentage computes a percentage for device-pushed
|
||||||
|
// annotations when the client didn't send one (thin clients skip their own
|
||||||
|
// per-annotation page lookups; arithmetic is only free on paging documents).
|
||||||
|
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
|
||||||
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||||
|
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
||||||
|
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||||
|
return float64(page) / float64(mediaItem.PageCount.Int32)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
|
||||||
|
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
||||||
|
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||||
@@ -75,24 +158,24 @@ type KOReaderProgressRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderBookProgress struct {
|
type KOReaderBookProgress struct {
|
||||||
UUID string `json:"uuid,omitempty"`
|
UUID string `json:"uuid,omitempty"`
|
||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Authors []string `json:"authors,omitempty"`
|
Authors []string `json:"authors,omitempty"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
LastRead string `json:"last_read,omitempty"`
|
LastRead string `json:"last_read,omitempty"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||||
Chapter *int `json:"chapter,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
ContextText *string `json:"context_text,omitempty"`
|
ContextText *string `json:"context_text,omitempty"`
|
||||||
Page *int `json:"page,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
TotalPages *int `json:"total_pages,omitempty"`
|
TotalPages *int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderDeviceInfo struct {
|
type KOReaderDeviceInfo struct {
|
||||||
@@ -100,53 +183,91 @@ type KOReaderDeviceInfo struct {
|
|||||||
DeviceModel string `json:"device_model,omitempty"`
|
DeviceModel string `json:"device_model,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexInt tolerates the loose types KOReader clients send for optional
|
||||||
|
// numeric fields: JSON numbers, numeric strings ("30"), empty strings
|
||||||
|
// (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE
|
||||||
|
// documents) — the latter decode to 0. Without this, a single annotation
|
||||||
|
// carrying chapter:"" or page:"/body/..." failed the whole request bind
|
||||||
|
// with a 400.
|
||||||
|
type FlexInt int
|
||||||
|
|
||||||
|
func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if s == "null" || s == `""` {
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
*f = FlexInt(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
|
||||||
|
inner := s[1 : len(s)-1]
|
||||||
|
if n, err := strconv.Atoi(inner); err == nil {
|
||||||
|
*f = FlexInt(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if fl, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
|
*f = FlexInt(int(fl))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*f = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type KOReaderBookmark struct {
|
type KOReaderBookmark struct {
|
||||||
Chapter int `json:"chapter,omitempty"`
|
Chapter FlexInt `json:"chapter,omitempty"`
|
||||||
Datetime string `json:"datetime,omitempty"`
|
Datetime string `json:"datetime,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Pos0 string `json:"pos0,omitempty"`
|
Pos0 string `json:"pos0,omitempty"`
|
||||||
Pos1 string `json:"pos1,omitempty"`
|
Pos1 string `json:"pos1,omitempty"`
|
||||||
Page int `json:"page,omitempty"`
|
Page FlexInt `json:"page,omitempty"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Percentage *float64 `json:"percentage,omitempty"`
|
Percentage *float64 `json:"percentage,omitempty"`
|
||||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||||
|
DedupKey string `json:"dedup_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderHighlight struct {
|
type KOReaderHighlight struct {
|
||||||
Chapter int `json:"chapter,omitempty"`
|
Chapter FlexInt `json:"chapter,omitempty"`
|
||||||
Datetime string `json:"datetime,omitempty"`
|
Datetime string `json:"datetime,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Pos0 string `json:"pos0,omitempty"`
|
Pos0 string `json:"pos0,omitempty"`
|
||||||
Pos1 string `json:"pos1,omitempty"`
|
Pos1 string `json:"pos1,omitempty"`
|
||||||
Page int `json:"page,omitempty"`
|
Page FlexInt `json:"page,omitempty"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
Percentage *float64 `json:"percentage,omitempty"`
|
Percentage *float64 `json:"percentage,omitempty"`
|
||||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||||
|
DedupKey string `json:"dedup_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderNote struct {
|
type KOReaderNote struct {
|
||||||
Chapter int `json:"chapter,omitempty"`
|
Chapter FlexInt `json:"chapter,omitempty"`
|
||||||
Datetime string `json:"datetime,omitempty"`
|
Datetime string `json:"datetime,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Pos0 string `json:"pos0,omitempty"`
|
Pos0 string `json:"pos0,omitempty"`
|
||||||
Pos1 string `json:"pos1,omitempty"`
|
Pos1 string `json:"pos1,omitempty"`
|
||||||
Page int `json:"page,omitempty"`
|
Page FlexInt `json:"page,omitempty"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Percentage *float64 `json:"percentage,omitempty"`
|
Percentage *float64 `json:"percentage,omitempty"`
|
||||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||||
|
DedupKey string `json:"dedup_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderSyncResponse struct {
|
type KOReaderSyncResponse struct {
|
||||||
SyncStatus string `json:"sync_status"`
|
SyncStatus string `json:"sync_status"`
|
||||||
BooksSynced int `json:"books_synced"`
|
BooksSynced int `json:"books_synced"`
|
||||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
DeviceUpdated bool `json:"device_updated"`
|
DeviceUpdated bool `json:"device_updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderBookSyncResult struct {
|
type KOReaderBookSyncResult struct {
|
||||||
@@ -174,20 +295,20 @@ type KOReaderMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderProgressData struct {
|
type KOReaderProgressData struct {
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||||
Chapter *int `json:"chapter,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||||
Page *int `json:"page,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
TotalPages *int `json:"total_pages,omitempty"`
|
TotalPages *int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderAnnotations struct {
|
type KOReaderAnnotations struct {
|
||||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||||
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
||||||
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -486,12 +607,16 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
for _, hl := range book.Highlights {
|
for _, hl := range book.Highlights {
|
||||||
startPos := hl.Pos0
|
startPos := hl.Pos0
|
||||||
endPos := hl.Pos1
|
endPos := hl.Pos1
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
// The highlight's own text anchors the conversion exactly.
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if hl.Percentage != nil {
|
if hl.Percentage != nil {
|
||||||
pctStart = *hl.Percentage
|
pctStart = *hl.Percentage
|
||||||
}
|
}
|
||||||
|
if pctStart == 0 {
|
||||||
|
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
||||||
|
}
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": hl.Datetime,
|
"datetime": hl.Datetime,
|
||||||
@@ -500,31 +625,55 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
"page": hl.Page,
|
"page": hl.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Color semantics: devices render their own default and cannot
|
||||||
|
// round-trip web colors. An echo carries NO color — preserve the
|
||||||
|
// stored (web) color so round-trips never change it. A non-empty
|
||||||
|
// color means the user edited the highlight on the device: map the
|
||||||
|
// device color name and let it win.
|
||||||
|
color := ""
|
||||||
|
if hl.Color != "" {
|
||||||
|
color = mapColorFromKOReader(hl.Color)
|
||||||
|
}
|
||||||
|
dedupKey := hl.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos)
|
||||||
|
}
|
||||||
|
if color == "" {
|
||||||
|
color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey)
|
||||||
|
}
|
||||||
|
if color == "" {
|
||||||
|
color = "#ffd54f"
|
||||||
|
}
|
||||||
|
|
||||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
SelectionText: hl.Text,
|
SelectionText: hl.Text,
|
||||||
StartPosition: startPos,
|
StartPosition: startPos,
|
||||||
EndPosition: endPos,
|
EndPosition: endPos,
|
||||||
Color: hl.Color,
|
Color: color,
|
||||||
NoteText: hl.Notes,
|
NoteText: hl.Notes,
|
||||||
PercentageStart: pctStart,
|
PercentageStart: pctStart,
|
||||||
EpubcfiStart: epubcfiStart,
|
EpubcfiStart: epubcfiStart,
|
||||||
EpubcfiEnd: epubcfiEnd,
|
EpubcfiEnd: epubcfiEnd,
|
||||||
Source: "koreader",
|
Source: "koreader",
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
|
DedupKey: dedupKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, note := range book.Notes {
|
for _, note := range book.Notes {
|
||||||
startPos := note.Pos0
|
startPos := note.Pos0
|
||||||
endPos := note.Pos1
|
endPos := note.Pos1
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if note.Percentage != nil {
|
if note.Percentage != nil {
|
||||||
pctStart = *note.Percentage
|
pctStart = *note.Percentage
|
||||||
}
|
}
|
||||||
|
if pctStart == 0 {
|
||||||
|
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
||||||
|
}
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": note.Datetime,
|
"datetime": note.Datetime,
|
||||||
@@ -533,18 +682,25 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
"page": note.Page,
|
"page": note.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dedupKey := note.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos)
|
||||||
|
}
|
||||||
|
|
||||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
SelectionText: note.Text,
|
SelectionText: note.Text,
|
||||||
StartPosition: startPos,
|
StartPosition: startPos,
|
||||||
EndPosition: endPos,
|
EndPosition: endPos,
|
||||||
NoteText: note.Notes,
|
Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey),
|
||||||
PercentageStart: pctStart,
|
NoteText: note.Notes,
|
||||||
EpubcfiStart: epubcfiStart,
|
PercentageStart: pctStart,
|
||||||
EpubcfiEnd: epubcfiEnd,
|
EpubcfiStart: epubcfiStart,
|
||||||
Source: "koreader",
|
EpubcfiEnd: epubcfiEnd,
|
||||||
DeviceSyncData: deviceData,
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
DedupKey: dedupKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +718,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
"page": bookmark.Page,
|
"page": bookmark.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dedupKey := bookmark.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position)
|
||||||
|
}
|
||||||
|
|
||||||
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -570,6 +731,7 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
ChapterNumber: int32(bookmark.Chapter),
|
ChapterNumber: int32(bookmark.Chapter),
|
||||||
Source: "koreader",
|
Source: "koreader",
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
|
DedupKey: dedupKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,34 +971,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, ann := range annotations {
|
for _, ann := range annotations {
|
||||||
if ann.AnnotationType == "highlight" {
|
if ann.AnnotationType == "highlight" {
|
||||||
pos0 := ann.StartPosition.String
|
// Selection text doubles as the converter's text-search context.
|
||||||
pos1 := ann.EndPosition.String
|
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText)
|
||||||
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText)
|
||||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
if pos0 == "" {
|
||||||
pos0 = converted
|
// Nothing the device could place — serving a locator it can't
|
||||||
}
|
// resolve would create junk bookmarks that re-push as
|
||||||
|
// duplicates, so skip instead.
|
||||||
|
log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
// Old web highlights carry no end anchor, and converted range
|
||||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
// CFIs resolve to their start — either way pos1 collapses onto
|
||||||
pos1 = converted
|
// pos0 and the device paints a zero-width highlight. Derive the
|
||||||
}
|
// end by advancing the start's character offset by the length
|
||||||
|
// of the selected text.
|
||||||
|
if pos1 == "" || pos1 == pos0 {
|
||||||
|
pos1 = extendXPointerByLength(pos0, ann.SelectionText)
|
||||||
}
|
}
|
||||||
highlight := KOReaderHighlight{
|
highlight := KOReaderHighlight{
|
||||||
Text: ann.SelectionText,
|
Text: ann.SelectionText,
|
||||||
Pos0: pos0,
|
Pos0: pos0,
|
||||||
Pos1: pos1,
|
Pos1: pos1,
|
||||||
Color: ann.Color.String,
|
// Web colors flow to the device, mapped to KOReader's named
|
||||||
|
// palette. Round-trip safety: the device suppresses the color
|
||||||
|
// when echoing un-edited applied entries (a pink→purple
|
||||||
|
// palette mismatch must not rewrite the stored hex), and an
|
||||||
|
// actual device edit pushes its color, which wins.
|
||||||
|
Color: mapColorToKOReader(ann.Color.String),
|
||||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
DedupKey: ann.DedupKey.String,
|
||||||
}
|
}
|
||||||
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||||
highlight.Notes = ann.NoteText.String
|
highlight.Notes = ann.NoteText.String
|
||||||
}
|
}
|
||||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||||
} else if ann.AnnotationType == "note" {
|
} else if ann.AnnotationType == "note" {
|
||||||
|
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "")
|
||||||
|
if pos0 == "" {
|
||||||
|
log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||||
Text: ann.SelectionText,
|
Text: ann.SelectionText,
|
||||||
Pos0: ann.StartPosition.String,
|
Pos0: pos0,
|
||||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
DedupKey: ann.DedupKey.String,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,21 +1026,23 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
for _, bm := range bookmarks {
|
for _, bm := range bookmarks {
|
||||||
pos0 := bm.Position.String
|
pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "")
|
||||||
if pos0 == "" && bm.CfiPosition.Valid {
|
if pos0 == "" {
|
||||||
pos0 = bm.CfiPosition.String
|
log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
koreaderBookmark := KOReaderBookmark{
|
koreaderBookmark := KOReaderBookmark{
|
||||||
Text: bm.Title,
|
Text: bm.Title,
|
||||||
Pos0: pos0,
|
Pos0: pos0,
|
||||||
Pos1: pos0,
|
Pos1: pos0,
|
||||||
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
DedupKey: bm.DedupKey.String,
|
||||||
}
|
}
|
||||||
if bm.Notes.Valid && bm.Notes.String != "" {
|
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||||
koreaderBookmark.Notes = bm.Notes.String
|
koreaderBookmark.Notes = bm.Notes.String
|
||||||
}
|
}
|
||||||
if bm.ChapterNumber.Valid {
|
if bm.ChapterNumber.Valid {
|
||||||
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32)
|
||||||
}
|
}
|
||||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||||
}
|
}
|
||||||
@@ -880,6 +1062,14 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
dd = map[string]interface{}{}
|
dd = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
dd["dedup_key"] = ts.DedupKey.String
|
dd["dedup_key"] = ts.DedupKey.String
|
||||||
|
// KOReader deletes by matching pos0. Device-pushed annotations carry
|
||||||
|
// it in device_sync_data; web-created ones don't (their locator is
|
||||||
|
// converted at serve time), so resolve it from the stored columns.
|
||||||
|
if dd["pos0"] == nil || dd["pos0"] == "" {
|
||||||
|
if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" {
|
||||||
|
dd["pos0"] = pos0
|
||||||
|
}
|
||||||
|
}
|
||||||
if ts.AnnotationType == "highlight" {
|
if ts.AnnotationType == "highlight" {
|
||||||
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||||
} else if ts.AnnotationType == "bookmark" {
|
} else if ts.AnnotationType == "bookmark" {
|
||||||
@@ -939,7 +1129,7 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
|
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string {
|
||||||
if h.libraryService == nil || epubcfi == "" {
|
if h.libraryService == nil || epubcfi == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -947,13 +1137,138 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
|
|||||||
if err != nil || epubPath == "" {
|
if err != nil || epubPath == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
|
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||||
if loc.Position != "" && loc.Position != epubcfi {
|
if loc.Position != "" && loc.Position != epubcfi {
|
||||||
return loc.Position
|
return loc.Position
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start
|
||||||
|
// for PDF text highlights (page-fraction rects; page index is 0-based).
|
||||||
|
type pdfRectAnchor struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Rects [][]float64 `json:"rects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// koreaderPos0 resolves a device-native KOReader pos0 from an annotation's
|
||||||
|
// stored locators, whatever the source. Resolution order:
|
||||||
|
//
|
||||||
|
// extendXPointerByLength advances a CRE xpointer's trailing text-node
|
||||||
|
// character offset by the rune length of text, so a highlight with only a
|
||||||
|
// start anchor still gets a plausible (non-collapsed) end for drawing.
|
||||||
|
// Overshooting the node just clamps on the device.
|
||||||
|
func extendXPointerByLength(xp, text string) string {
|
||||||
|
if xp == "" || text == "" {
|
||||||
|
return xp
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(xp, ".")
|
||||||
|
if i < 0 {
|
||||||
|
return xp
|
||||||
|
}
|
||||||
|
off, err := strconv.Atoi(xp[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return xp
|
||||||
|
}
|
||||||
|
return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KOReader paints highlight colors from a fixed set of names
|
||||||
|
// (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at
|
||||||
|
// the boundary so each side always receives something it can render;
|
||||||
|
// unmappable values fall back to each side's default (yellow).
|
||||||
|
var koreaderColorFromName = map[string]string{
|
||||||
|
"yellow": "#ffd54f",
|
||||||
|
"orange": "#ffd54f",
|
||||||
|
"green": "#a5d6a7",
|
||||||
|
"olive": "#a5d6a7",
|
||||||
|
"cyan": "#90caf9",
|
||||||
|
"blue": "#90caf9",
|
||||||
|
"purple": "#ce93d8",
|
||||||
|
"red": "#f48fb1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapColorFromKOReader normalizes a device color name to a web hex
|
||||||
|
// swatch (default yellow) when ingesting device pushes.
|
||||||
|
func mapColorFromKOReader(name string) string {
|
||||||
|
if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
return "#ffd54f"
|
||||||
|
}
|
||||||
|
|
||||||
|
var koreaderColorFromHex = map[string]string{
|
||||||
|
"#ffd54f": "yellow",
|
||||||
|
"#a5d6a7": "green",
|
||||||
|
"#90caf9": "blue",
|
||||||
|
"#ce93d8": "purple",
|
||||||
|
"#f48fb1": "purple",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader
|
||||||
|
// color name (default yellow) when serving to devices. Pink maps to purple
|
||||||
|
// (the palette's closest); round-trip drift is prevented on the device by
|
||||||
|
// suppressing echo colors for un-edited applied entries.
|
||||||
|
func mapColorToKOReader(hex string) string {
|
||||||
|
if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "yellow"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. A device-native CRE xpointer ("/body/...") in startPosition wins —
|
||||||
|
// round-trip identical for KOReader-pushed annotations (converting the
|
||||||
|
// stored CFI instead could drift and duplicate on the device).
|
||||||
|
// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging
|
||||||
|
// documents use the page number as pos0).
|
||||||
|
// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the
|
||||||
|
// reader's "cfi:" prefix) → converted to a CRE xpointer, with
|
||||||
|
// contextText (the selection text) enabling the text-search fallback.
|
||||||
|
// 4. A "page:N" or bare-numeric position → the bare number.
|
||||||
|
//
|
||||||
|
// Returns "" when nothing usable exists; callers skip such annotations so
|
||||||
|
// devices never receive locators they cannot place.
|
||||||
|
func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string {
|
||||||
|
if wsync.IsCREXPointer(startPosition) {
|
||||||
|
return startPosition
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(epubcfi, "{") {
|
||||||
|
var anchor pdfRectAnchor
|
||||||
|
if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 {
|
||||||
|
return strconv.Itoa(anchor.Page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfi := epubcfi
|
||||||
|
if cfi == "" && strings.HasPrefix(startPosition, "cfi:") {
|
||||||
|
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
||||||
|
}
|
||||||
|
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
||||||
|
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
// Conversion failed; fall through so numeric positions still work.
|
||||||
|
if wsync.IsCREXPointer(cfi) {
|
||||||
|
return cfi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePageInt(s string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
n = n*10 + int64(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||||
device := c.Get("device").(database.Devices)
|
device := c.Get("device").(database.Devices)
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
@@ -1195,13 +1510,13 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
endPos = startPos
|
endPos = startPos
|
||||||
}
|
}
|
||||||
|
|
||||||
color := "#ffff00"
|
color := "#ffd54f"
|
||||||
if highlight.Color != "" {
|
if highlight.Color != "" {
|
||||||
color = highlight.Color
|
color = mapColorFromKOReader(highlight.Color)
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.annotationSvc != nil {
|
if h.annotationSvc != nil {
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if highlight.Percentage != nil {
|
if highlight.Percentage != nil {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import (
|
|||||||
const TombstoneTTL = 30 * 24 * time.Hour
|
const TombstoneTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
type AnnotationService struct {
|
type AnnotationService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connMgr *ConnectionManager
|
connMgr *ConnectionManager
|
||||||
settings *database.SettingsRegistry
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +75,11 @@ type SaveHighlightRequest struct {
|
|||||||
Source string
|
Source string
|
||||||
ModifiedAt time.Time
|
ModifiedAt time.Time
|
||||||
DeviceSyncData json.RawMessage
|
DeviceSyncData json.RawMessage
|
||||||
|
// DedupKey overrides the computed key when the client echoes back an
|
||||||
|
// annotation it received from us (device echoes carry device-native
|
||||||
|
// locators, so the computed key would never match the original row and
|
||||||
|
// every pull→push cycle would mint a duplicate).
|
||||||
|
DedupKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveHighlightResult struct {
|
type SaveHighlightResult struct {
|
||||||
@@ -84,7 +89,10 @@ type SaveHighlightResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||||
dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
dedupKey := req.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
@@ -124,22 +132,22 @@ func (s *AnnotationService) createHighlight(
|
|||||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
||||||
MediaItemID: req.MediaItemID,
|
MediaItemID: req.MediaItemID,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgText(req.StartPosition),
|
StartPosition: pgText(req.StartPosition),
|
||||||
EndPosition: pgText(req.EndPosition),
|
EndPosition: pgText(req.EndPosition),
|
||||||
Color: pgText(req.Color),
|
Color: pgText(req.Color),
|
||||||
NoteText: pgText(req.NoteText),
|
NoteText: pgText(req.NoteText),
|
||||||
PercentageStart: pgFloat8(req.PercentageStart),
|
PercentageStart: pgFloat8(req.PercentageStart),
|
||||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create highlight: %w", err)
|
return nil, fmt.Errorf("create highlight: %w", err)
|
||||||
@@ -179,20 +187,20 @@ func (s *AnnotationService) applyLWW(
|
|||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgText(req.StartPosition),
|
StartPosition: pgText(req.StartPosition),
|
||||||
EndPosition: pgText(req.EndPosition),
|
EndPosition: pgText(req.EndPosition),
|
||||||
Color: pgText(req.Color),
|
Color: pgText(req.Color),
|
||||||
NoteText: pgText(req.NoteText),
|
NoteText: pgText(req.NoteText),
|
||||||
PercentageStart: pgFloat8(req.PercentageStart),
|
PercentageStart: pgFloat8(req.PercentageStart),
|
||||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update highlight: %w", err)
|
return nil, fmt.Errorf("update highlight: %w", err)
|
||||||
@@ -335,6 +343,7 @@ type SaveNoteRequest struct {
|
|||||||
Source string
|
Source string
|
||||||
ModifiedAt time.Time
|
ModifiedAt time.Time
|
||||||
DeviceSyncData []byte
|
DeviceSyncData []byte
|
||||||
|
DedupKey string // overrides the computed key for device echoes
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveNoteResult struct {
|
type SaveNoteResult struct {
|
||||||
@@ -348,7 +357,10 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
|
|||||||
return nil, errors.New("invalid user_id or media_item_id")
|
return nil, errors.New("invalid user_id or media_item_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
dedupKey := req.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
@@ -485,6 +497,9 @@ type SaveBookmarkRequest struct {
|
|||||||
Source string
|
Source string
|
||||||
ModifiedAt time.Time
|
ModifiedAt time.Time
|
||||||
DeviceSyncData json.RawMessage
|
DeviceSyncData json.RawMessage
|
||||||
|
// DedupKey overrides the computed key for device echoes (see
|
||||||
|
// SaveHighlightRequest).
|
||||||
|
DedupKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveBookmarkResult struct {
|
type SaveBookmarkResult struct {
|
||||||
@@ -494,7 +509,10 @@ type SaveBookmarkResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
||||||
dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
dedupKey := req.DedupKey
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
@@ -530,21 +548,21 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
|
|||||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
||||||
MediaItemID: req.MediaItemID,
|
MediaItemID: req.MediaItemID,
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
PageNumber: pgInt4(req.PageNumber),
|
PageNumber: pgInt4(req.PageNumber),
|
||||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||||
CfiPosition: pgText(req.CFIPosition),
|
CfiPosition: pgText(req.CFIPosition),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Position: pgText(req.Position),
|
Position: pgText(req.Position),
|
||||||
Notes: pgText(req.Notes),
|
Notes: pgText(req.Notes),
|
||||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||||
@@ -573,19 +591,19 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
|||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
PageNumber: pgInt4(req.PageNumber),
|
PageNumber: pgInt4(req.PageNumber),
|
||||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||||
CfiPosition: pgText(req.CFIPosition),
|
CfiPosition: pgText(req.CFIPosition),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Position: pgText(req.Position),
|
Position: pgText(req.Position),
|
||||||
Notes: pgText(req.Notes),
|
Notes: pgText(req.Notes),
|
||||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
@@ -19,6 +20,9 @@ import (
|
|||||||
type CFIConverter struct {
|
type CFIConverter struct {
|
||||||
epubPath string
|
epubPath string
|
||||||
cache *spineCache
|
cache *spineCache
|
||||||
|
// mu guards the lazily-built spine/doc caches: converter instances are
|
||||||
|
// shared across concurrent requests via the package cache in locators.go.
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type spineItem struct {
|
type spineItem struct {
|
||||||
@@ -37,6 +41,8 @@ func NewCFIConverter(epubPath string) *CFIConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if c.cache != nil {
|
if c.cache != nil {
|
||||||
return c.cache, nil
|
return c.cache, nil
|
||||||
}
|
}
|
||||||
@@ -94,6 +100,8 @@ func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, err
|
|||||||
item := spine.items[spineIndex]
|
item := spine.items[spineIndex]
|
||||||
href := item.href
|
href := item.href
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if cached, ok := spine.docCache[href]; ok {
|
if cached, ok := spine.docCache[href]; ok {
|
||||||
return cached, href, nil
|
return cached, href, nil
|
||||||
}
|
}
|
||||||
@@ -243,6 +251,46 @@ type ConversionResult struct {
|
|||||||
Precision string
|
Precision string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionPercentage derives an approximate book-wide percentage for a CRE
|
||||||
|
// xpointer from the char distribution across the spine: the midpoint of the
|
||||||
|
// document it points into. Precision is per-section, which is what
|
||||||
|
// percentage_start is used for (ordering/filtering) — and it lets thin
|
||||||
|
// clients skip their own per-annotation page lookups entirely.
|
||||||
|
func (c *CFIConverter) SectionPercentage(xpointer string) float64 {
|
||||||
|
xp, err := ParseCREXPointer(xpointer)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
spine, err := c.loadSpine()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
charCounts := make([]int, len(spine.items))
|
||||||
|
for i := range spine.items {
|
||||||
|
doc, _, docErr := c.getContentDoc(i + 1)
|
||||||
|
if docErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b := findBody(doc); b != nil {
|
||||||
|
charCounts[i] = countTextChars(b)
|
||||||
|
total += charCounts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
idx := xp.FragmentIndex - 1
|
||||||
|
if idx < 0 || idx >= len(spine.items) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
before := 0
|
||||||
|
for i := 0; i < idx; i++ {
|
||||||
|
before += charCounts[i]
|
||||||
|
}
|
||||||
|
return (float64(before) + float64(charCounts[idx])/2) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
||||||
if IsCREFragmentID(xpointer) {
|
if IsCREFragmentID(xpointer) {
|
||||||
return c.convertFragmentID(xpointer, storedPercentage)
|
return c.convertFragmentID(xpointer, storedPercentage)
|
||||||
@@ -884,8 +932,8 @@ func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type opfContainer struct {
|
type opfContainer struct {
|
||||||
XMLName xml.Name `xml:"container"`
|
XMLName xml.Name `xml:"container"`
|
||||||
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type opfRoot struct {
|
type opfRoot struct {
|
||||||
@@ -906,8 +954,8 @@ func extractOPFPath(data []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type xmlPackage struct {
|
type xmlPackage struct {
|
||||||
XMLName xml.Name `xml:"package"`
|
XMLName xml.Name `xml:"package"`
|
||||||
Spine xmlSpine `xml:"spine"`
|
Spine xmlSpine `xml:"spine"`
|
||||||
Manifest xmlManifest `xml:"manifest"`
|
Manifest xmlManifest `xml:"manifest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,9 +1084,9 @@ func preprocessXHTML(input string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cfiStep struct {
|
type cfiStep struct {
|
||||||
Index int
|
Index int
|
||||||
ID string
|
ID string
|
||||||
Offset int
|
Offset int
|
||||||
HasOffset bool
|
HasOffset bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import "log"
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
type LocatorSource string
|
type LocatorSource string
|
||||||
|
|
||||||
@@ -26,6 +29,35 @@ func isConvertible(formatGroup string) bool {
|
|||||||
return formatGroup == string(FormatGroupReflowable)
|
return formatGroup == string(FormatGroupReflowable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converters parse and cache the whole EPUB (spine + content docs), so
|
||||||
|
// creating one per annotation re-reads the book for every entry. A small
|
||||||
|
// bounded cache lets one request — or several — share a single parse.
|
||||||
|
// Servers are the right place for this work: clients stay thin.
|
||||||
|
var (
|
||||||
|
converterMu sync.Mutex
|
||||||
|
converterCache = map[string]*CFIConverter{}
|
||||||
|
converterOrder []string // insertion order for eviction
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxCachedConverters = 8
|
||||||
|
|
||||||
|
func cachedConverter(epubPath string) *CFIConverter {
|
||||||
|
converterMu.Lock()
|
||||||
|
defer converterMu.Unlock()
|
||||||
|
if c, ok := converterCache[epubPath]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c := NewCFIConverter(epubPath)
|
||||||
|
converterCache[epubPath] = c
|
||||||
|
converterOrder = append(converterOrder, epubPath)
|
||||||
|
for len(converterOrder) > maxCachedConverters {
|
||||||
|
oldest := converterOrder[0]
|
||||||
|
converterOrder = converterOrder[1:]
|
||||||
|
delete(converterCache, oldest)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func ConvertToCanonical(
|
func ConvertToCanonical(
|
||||||
source LocatorSource,
|
source LocatorSource,
|
||||||
devicePos string,
|
devicePos string,
|
||||||
@@ -48,7 +80,7 @@ func ConvertToCanonical(
|
|||||||
if !IsCREXPointer(devicePos) {
|
if !IsCREXPointer(devicePos) {
|
||||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||||
}
|
}
|
||||||
converter := NewCFIConverter(epubPath)
|
converter := cachedConverter(epubPath)
|
||||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||||
@@ -101,7 +133,7 @@ func ConvertFromCanonical(
|
|||||||
|
|
||||||
switch source {
|
switch source {
|
||||||
case LocatorSourceKOReader:
|
case LocatorSourceKOReader:
|
||||||
converter := NewCFIConverter(epubPath)
|
converter := cachedConverter(epubPath)
|
||||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
note: string;
|
note: string;
|
||||||
color: string;
|
color: string;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
|
cfiEnd: string;
|
||||||
|
renderCfi: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
pdfPage: number;
|
pdfPage: number;
|
||||||
pdfRects: number[][];
|
pdfRects: number[][];
|
||||||
@@ -443,6 +445,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
y: 0,
|
y: 0,
|
||||||
text: "",
|
text: "",
|
||||||
cfi: "",
|
cfi: "",
|
||||||
|
cfiEnd: "",
|
||||||
id: "",
|
id: "",
|
||||||
color: "#ffd54f",
|
color: "#ffd54f",
|
||||||
note: "",
|
note: "",
|
||||||
@@ -701,8 +704,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
let cfi: string;
|
let cfi: string;
|
||||||
|
let cfiEnd: string;
|
||||||
try {
|
try {
|
||||||
cfi = this.view.getCFI(index, range);
|
cfi = this.view.getCFI(index, range);
|
||||||
|
// Collapse to the end point for a distinct end anchor —
|
||||||
|
// KOReader sync renders the highlight box from pos0/pos1, and
|
||||||
|
// pos1 == pos0 would be a degenerate (zero-length) range.
|
||||||
|
const endRange = range.cloneRange();
|
||||||
|
endRange.collapse(false);
|
||||||
|
cfiEnd = this.view.getCFI(index, endRange);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -715,6 +725,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
y: (iframeRect?.top ?? 0) + rect.top,
|
y: (iframeRect?.top ?? 0) + rect.top,
|
||||||
text,
|
text,
|
||||||
cfi,
|
cfi,
|
||||||
|
cfiEnd,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
@@ -827,6 +838,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
y: (iframeRect?.top ?? 0) + rect.top,
|
y: (iframeRect?.top ?? 0) + rect.top,
|
||||||
text: h.text,
|
text: h.text,
|
||||||
cfi: h.cfi,
|
cfi: h.cfi,
|
||||||
|
cfiEnd: h.cfiEnd,
|
||||||
id: h.id,
|
id: h.id,
|
||||||
color: h.color,
|
color: h.color,
|
||||||
note: h.note,
|
note: h.note,
|
||||||
@@ -1070,6 +1082,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
y: number;
|
y: number;
|
||||||
text: string;
|
text: string;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
|
cfiEnd?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -1080,6 +1093,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
p.mode = opts.mode;
|
p.mode = opts.mode;
|
||||||
p.text = opts.text;
|
p.text = opts.text;
|
||||||
p.cfi = opts.cfi;
|
p.cfi = opts.cfi;
|
||||||
|
p.cfiEnd = opts.cfiEnd ?? "";
|
||||||
p.id = opts.id ?? "";
|
p.id = opts.id ?? "";
|
||||||
p.color = opts.color || "#ffd54f";
|
p.color = opts.color || "#ffd54f";
|
||||||
p.note = opts.note ?? "";
|
p.note = opts.note ?? "";
|
||||||
@@ -1109,7 +1123,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
} else {
|
} else {
|
||||||
this.view
|
this.view
|
||||||
?.addAnnotation({
|
?.addAnnotation({
|
||||||
value: hl.cfi,
|
value: hl.renderCfi || hl.cfi,
|
||||||
color: hl.color,
|
color: hl.color,
|
||||||
note: hl.note,
|
note: hl.note,
|
||||||
id: hl.id,
|
id: hl.id,
|
||||||
@@ -1141,17 +1155,68 @@ document.addEventListener("alpine:init", () => {
|
|||||||
/* not ours; leave as-is */
|
/* not ours; leave as-is */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const cfiEnd = r.epubcfi_end ?? "";
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
text: r.selection_text ?? "",
|
text: r.selection_text ?? "",
|
||||||
note: r.note_text ?? "",
|
note: r.note_text ?? "",
|
||||||
color: r.color ?? "#ffff00",
|
color: r.color ?? "#ffff00",
|
||||||
cfi,
|
cfi,
|
||||||
|
cfiEnd,
|
||||||
|
// Rendering/navigating anchor: device-synced highlights store
|
||||||
|
// POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a
|
||||||
|
// collapsed range and paint nothing. Foliate's overlayer needs a
|
||||||
|
// RANGE CFI — same shape getCFI() produces natively
|
||||||
|
// (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from
|
||||||
|
// the stored start and end points when both share a base path.
|
||||||
|
renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""),
|
||||||
percentage: r.percentage_start ?? 0,
|
percentage: r.percentage_start ?? 0,
|
||||||
pdfPage,
|
pdfPage,
|
||||||
pdfRects,
|
pdfRects,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Build a foliate-renderable RANGE CFI from stored (possibly point)
|
||||||
|
// CFIs. Repairs two stale shapes using the selection text: a missing
|
||||||
|
// end (old web highlights), and a degenerate end — the device-push
|
||||||
|
// converter used to fall back to a document-start CFI when the end
|
||||||
|
// xpointer didn't resolve exactly. In both cases the end is derived
|
||||||
|
// from the start offset advanced by the text's UTF-16 length (EPUB
|
||||||
|
// CFI offsets are UTF-16 code units); multi-node selections just fail
|
||||||
|
// resolution harmlessly and fall back to the point CFI.
|
||||||
|
toRangeCfi(start: string, end: string, text: string): string {
|
||||||
|
if (!start) return end || start;
|
||||||
|
if (start.includes(",")) return start; // already a range CFI
|
||||||
|
const re =
|
||||||
|
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
|
||||||
|
const ms = re.exec(start);
|
||||||
|
if (!ms) return start;
|
||||||
|
const base = ms[1];
|
||||||
|
const startLocal = ms[2];
|
||||||
|
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
|
||||||
|
const utf16len = [...(text ?? "")].reduce(
|
||||||
|
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let endLocal = "";
|
||||||
|
if (end && !end.includes(",")) {
|
||||||
|
const me = re.exec(end);
|
||||||
|
if (me && me[1] === base) {
|
||||||
|
const endOff = me[3] ? parseInt(me[3], 10) : -1;
|
||||||
|
// Degenerate: end resolves to the document start (the old
|
||||||
|
// converter fallback) or sits before the start offset.
|
||||||
|
const degenerate =
|
||||||
|
endOff === 0 ||
|
||||||
|
(startOff >= 0 && endOff >= 0 && endOff < startOff);
|
||||||
|
if (!degenerate) endLocal = me[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!endLocal) {
|
||||||
|
if (startOff < 0 || utf16len <= 0) return start; // point CFI
|
||||||
|
const cut = startLocal.lastIndexOf(":");
|
||||||
|
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
|
||||||
|
}
|
||||||
|
return `${base},${startLocal},${endLocal})`;
|
||||||
|
},
|
||||||
async refreshAnnotations() {
|
async refreshAnnotations() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId) return;
|
if (!token || !this.mediaItemId) return;
|
||||||
@@ -1205,6 +1270,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
start_position: "",
|
start_position: "",
|
||||||
end_position: "",
|
end_position: "",
|
||||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||||
|
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||||
color,
|
color,
|
||||||
note_text: "",
|
note_text: "",
|
||||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||||
@@ -1230,7 +1296,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.view?.addAnnotation({
|
this.view?.addAnnotation({
|
||||||
value: p.cfi,
|
value:
|
||||||
|
p.pdfPage >= 0
|
||||||
|
? ""
|
||||||
|
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||||
color,
|
color,
|
||||||
note: "",
|
note: "",
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -1263,6 +1332,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
start_position: "",
|
start_position: "",
|
||||||
end_position: "",
|
end_position: "",
|
||||||
epubcfi_start: anchor,
|
epubcfi_start: anchor,
|
||||||
|
epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
note_text: p.note,
|
note_text: p.note,
|
||||||
}),
|
}),
|
||||||
@@ -1282,7 +1352,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.view?.addAnnotation({
|
this.view?.addAnnotation({
|
||||||
value: p.cfi,
|
value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
note: p.note,
|
note: p.note,
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -1324,6 +1394,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToHighlight(hl: {
|
goToHighlight(hl: {
|
||||||
cfi: string;
|
cfi: string;
|
||||||
|
renderCfi: string;
|
||||||
pdfPage: number;
|
pdfPage: number;
|
||||||
}) {
|
}) {
|
||||||
if (hl.pdfPage >= 0) {
|
if (hl.pdfPage >= 0) {
|
||||||
@@ -1331,9 +1402,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.goTo?.(hl.pdfPage);
|
this.view?.goTo?.(hl.pdfPage);
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
} else if (hl.cfi) {
|
} else if (hl.renderCfi || hl.cfi) {
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
this.view
|
||||||
|
?.showAnnotation({ value: hl.renderCfi || hl.cfi })
|
||||||
|
?.catch?.(() => {});
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user