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,
|
||||
'highlight' as annotation_type,
|
||||
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
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -6861,7 +6865,11 @@ SELECT
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
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
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -6870,7 +6878,11 @@ SELECT
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
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
|
||||
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
|
||||
@@ -6888,6 +6900,10 @@ type GetTombstonedAnnotationsForBookRow struct {
|
||||
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
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) {
|
||||
@@ -6905,6 +6921,10 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo
|
||||
&i.AnnotationType,
|
||||
&i.DeviceSyncData,
|
||||
&i.DeletedAt,
|
||||
&i.StartPosition,
|
||||
&i.EndPosition,
|
||||
&i.EpubcfiStart,
|
||||
&i.EpubcfiEnd,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -992,7 +992,11 @@ SELECT
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
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
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -1001,7 +1005,11 @@ SELECT
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
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
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -1010,7 +1018,11 @@ SELECT
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
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
|
||||
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;
|
||||
|
||||
+407
-92
@@ -9,7 +9,10 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -47,7 +50,7 @@ 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) {
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
||||
if pos0 == "" || h.libraryService == nil {
|
||||
return "", ""
|
||||
}
|
||||
@@ -59,9 +62,89 @@ func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaIt
|
||||
if err != nil || epubPath == "" {
|
||||
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, "")
|
||||
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) {
|
||||
@@ -75,24 +158,24 @@ type KOReaderProgressRequest struct {
|
||||
}
|
||||
|
||||
type KOReaderBookProgress struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderDeviceInfo struct {
|
||||
@@ -100,53 +183,91 @@ type KOReaderDeviceInfo struct {
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
}
|
||||
|
||||
// FlexInt tolerates the loose types KOReader clients send for optional
|
||||
// numeric fields: JSON numbers, numeric strings ("30"), empty strings
|
||||
// (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE
|
||||
// documents) — the latter decode to 0. Without this, a single annotation
|
||||
// carrying chapter:"" or page:"/body/..." failed the whole request bind
|
||||
// with a 400.
|
||||
type FlexInt int
|
||||
|
||||
func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "null" || s == `""` {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
*f = FlexInt(n)
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
|
||||
inner := s[1 : len(s)-1]
|
||||
if n, err := strconv.Atoi(inner); err == nil {
|
||||
*f = FlexInt(n)
|
||||
return nil
|
||||
}
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
if fl, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
*f = FlexInt(int(fl))
|
||||
return nil
|
||||
}
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
type KOReaderBookmark struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderHighlight struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderNote struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
}
|
||||
|
||||
type KOReaderBookSyncResult struct {
|
||||
@@ -174,20 +295,20 @@ type KOReaderMetadata struct {
|
||||
}
|
||||
|
||||
type KOReaderProgressData struct {
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
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"`
|
||||
}
|
||||
@@ -486,12 +607,16 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
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
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
@@ -500,31 +625,55 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": hl.Page,
|
||||
})
|
||||
|
||||
// Color semantics: devices render their own default and cannot
|
||||
// round-trip web colors. An echo carries NO color — preserve the
|
||||
// stored (web) color so round-trips never change it. A non-empty
|
||||
// color means the user edited the highlight on the device: map the
|
||||
// device color name and let it win.
|
||||
color := ""
|
||||
if hl.Color != "" {
|
||||
color = mapColorFromKOReader(hl.Color)
|
||||
}
|
||||
dedupKey := hl.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos)
|
||||
}
|
||||
if color == "" {
|
||||
color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey)
|
||||
}
|
||||
if color == "" {
|
||||
color = "#ffd54f"
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: hl.Color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
for _, note := range book.Notes {
|
||||
startPos := note.Pos0
|
||||
endPos := note.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if note.Percentage != nil {
|
||||
pctStart = *note.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
@@ -533,18 +682,25 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
dedupKey := note.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos)
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey),
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +718,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
dedupKey := bookmark.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position)
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
@@ -570,6 +731,7 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -809,34 +971,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
pos0 := ann.StartPosition.String
|
||||
pos1 := ann.EndPosition.String
|
||||
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
||||
pos0 = converted
|
||||
}
|
||||
// Selection text doubles as the converter's text-search context.
|
||||
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText)
|
||||
pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText)
|
||||
if pos0 == "" {
|
||||
// Nothing the device could place — serving a locator it can't
|
||||
// resolve would create junk bookmarks that re-push as
|
||||
// duplicates, so skip instead.
|
||||
log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID)
|
||||
continue
|
||||
}
|
||||
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
||||
pos1 = converted
|
||||
}
|
||||
// Old web highlights carry no end anchor, and converted range
|
||||
// CFIs resolve to their start — either way pos1 collapses onto
|
||||
// pos0 and the device paints a zero-width highlight. Derive the
|
||||
// end by advancing the start's character offset by the length
|
||||
// of the selected text.
|
||||
if pos1 == "" || pos1 == pos0 {
|
||||
pos1 = extendXPointerByLength(pos0, ann.SelectionText)
|
||||
}
|
||||
highlight := KOReaderHighlight{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
Color: ann.Color.String,
|
||||
Text: ann.SelectionText,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
// Web colors flow to the device, mapped to KOReader's named
|
||||
// palette. Round-trip safety: the device suppresses the color
|
||||
// when echoing un-edited applied entries (a pink→purple
|
||||
// palette mismatch must not rewrite the stored hex), and an
|
||||
// actual device edit pushes its color, which wins.
|
||||
Color: mapColorToKOReader(ann.Color.String),
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
DedupKey: ann.DedupKey.String,
|
||||
}
|
||||
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||
highlight.Notes = ann.NoteText.String
|
||||
}
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||
} else if ann.AnnotationType == "note" {
|
||||
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "")
|
||||
if pos0 == "" {
|
||||
log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID)
|
||||
continue
|
||||
}
|
||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos0: pos0,
|
||||
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,
|
||||
})
|
||||
for _, bm := range bookmarks {
|
||||
pos0 := bm.Position.String
|
||||
if pos0 == "" && bm.CfiPosition.Valid {
|
||||
pos0 = bm.CfiPosition.String
|
||||
pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "")
|
||||
if pos0 == "" {
|
||||
log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID)
|
||||
continue
|
||||
}
|
||||
koreaderBookmark := KOReaderBookmark{
|
||||
Text: bm.Title,
|
||||
Pos0: pos0,
|
||||
Pos1: pos0,
|
||||
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||
DedupKey: bm.DedupKey.String,
|
||||
}
|
||||
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||
koreaderBookmark.Notes = bm.Notes.String
|
||||
}
|
||||
if bm.ChapterNumber.Valid {
|
||||
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
||||
koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32)
|
||||
}
|
||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||
}
|
||||
@@ -880,6 +1062,14 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
dd = map[string]interface{}{}
|
||||
}
|
||||
dd["dedup_key"] = ts.DedupKey.String
|
||||
// KOReader deletes by matching pos0. Device-pushed annotations carry
|
||||
// it in device_sync_data; web-created ones don't (their locator is
|
||||
// converted at serve time), so resolve it from the stored columns.
|
||||
if dd["pos0"] == nil || dd["pos0"] == "" {
|
||||
if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" {
|
||||
dd["pos0"] = pos0
|
||||
}
|
||||
}
|
||||
if ts.AnnotationType == "highlight" {
|
||||
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||
} else if ts.AnnotationType == "bookmark" {
|
||||
@@ -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 == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -947,13 +1137,138 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
|
||||
if err != nil || epubPath == "" {
|
||||
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 {
|
||||
return loc.Position
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start
|
||||
// for PDF text highlights (page-fraction rects; page index is 0-based).
|
||||
type pdfRectAnchor struct {
|
||||
V int `json:"v"`
|
||||
Page int `json:"page"`
|
||||
Rects [][]float64 `json:"rects"`
|
||||
}
|
||||
|
||||
// koreaderPos0 resolves a device-native KOReader pos0 from an annotation's
|
||||
// stored locators, whatever the source. Resolution order:
|
||||
//
|
||||
// extendXPointerByLength advances a CRE xpointer's trailing text-node
|
||||
// character offset by the rune length of text, so a highlight with only a
|
||||
// start anchor still gets a plausible (non-collapsed) end for drawing.
|
||||
// Overshooting the node just clamps on the device.
|
||||
func extendXPointerByLength(xp, text string) string {
|
||||
if xp == "" || text == "" {
|
||||
return xp
|
||||
}
|
||||
i := strings.LastIndex(xp, ".")
|
||||
if i < 0 {
|
||||
return xp
|
||||
}
|
||||
off, err := strconv.Atoi(xp[i+1:])
|
||||
if err != nil {
|
||||
return xp
|
||||
}
|
||||
return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text))
|
||||
}
|
||||
|
||||
// KOReader paints highlight colors from a fixed set of names
|
||||
// (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at
|
||||
// the boundary so each side always receives something it can render;
|
||||
// unmappable values fall back to each side's default (yellow).
|
||||
var koreaderColorFromName = map[string]string{
|
||||
"yellow": "#ffd54f",
|
||||
"orange": "#ffd54f",
|
||||
"green": "#a5d6a7",
|
||||
"olive": "#a5d6a7",
|
||||
"cyan": "#90caf9",
|
||||
"blue": "#90caf9",
|
||||
"purple": "#ce93d8",
|
||||
"red": "#f48fb1",
|
||||
}
|
||||
|
||||
// mapColorFromKOReader normalizes a device color name to a web hex
|
||||
// swatch (default yellow) when ingesting device pushes.
|
||||
func mapColorFromKOReader(name string) string {
|
||||
if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return hex
|
||||
}
|
||||
return "#ffd54f"
|
||||
}
|
||||
|
||||
var koreaderColorFromHex = map[string]string{
|
||||
"#ffd54f": "yellow",
|
||||
"#a5d6a7": "green",
|
||||
"#90caf9": "blue",
|
||||
"#ce93d8": "purple",
|
||||
"#f48fb1": "purple",
|
||||
}
|
||||
|
||||
// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader
|
||||
// color name (default yellow) when serving to devices. Pink maps to purple
|
||||
// (the palette's closest); round-trip drift is prevented on the device by
|
||||
// suppressing echo colors for un-edited applied entries.
|
||||
func mapColorToKOReader(hex string) string {
|
||||
if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok {
|
||||
return name
|
||||
}
|
||||
return "yellow"
|
||||
}
|
||||
|
||||
// 1. A device-native CRE xpointer ("/body/...") in startPosition wins —
|
||||
// round-trip identical for KOReader-pushed annotations (converting the
|
||||
// stored CFI instead could drift and duplicate on the device).
|
||||
// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging
|
||||
// documents use the page number as pos0).
|
||||
// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the
|
||||
// reader's "cfi:" prefix) → converted to a CRE xpointer, with
|
||||
// contextText (the selection text) enabling the text-search fallback.
|
||||
// 4. A "page:N" or bare-numeric position → the bare number.
|
||||
//
|
||||
// Returns "" when nothing usable exists; callers skip such annotations so
|
||||
// devices never receive locators they cannot place.
|
||||
func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string {
|
||||
if wsync.IsCREXPointer(startPosition) {
|
||||
return startPosition
|
||||
}
|
||||
if strings.HasPrefix(epubcfi, "{") {
|
||||
var anchor pdfRectAnchor
|
||||
if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 {
|
||||
return strconv.Itoa(anchor.Page)
|
||||
}
|
||||
}
|
||||
cfi := epubcfi
|
||||
if cfi == "" && strings.HasPrefix(startPosition, "cfi:") {
|
||||
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
||||
}
|
||||
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
|
||||
return converted
|
||||
}
|
||||
// Conversion failed; fall through so numeric positions still work.
|
||||
if wsync.IsCREXPointer(cfi) {
|
||||
return cfi
|
||||
}
|
||||
}
|
||||
if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parsePageInt(s string) int64 {
|
||||
var n int64
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return -1
|
||||
}
|
||||
n = n*10 + int64(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -1195,13 +1510,13 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
endPos = startPos
|
||||
}
|
||||
|
||||
color := "#ffff00"
|
||||
color := "#ffd54f"
|
||||
if highlight.Color != "" {
|
||||
color = highlight.Color
|
||||
color = mapColorFromKOReader(highlight.Color)
|
||||
}
|
||||
|
||||
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
|
||||
if highlight.Percentage != nil {
|
||||
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
const TombstoneTTL = 30 * 24 * time.Hour
|
||||
|
||||
type AnnotationService struct {
|
||||
db *database.Queries
|
||||
connMgr *ConnectionManager
|
||||
db *database.Queries
|
||||
connMgr *ConnectionManager
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ type SaveHighlightRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
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 {
|
||||
@@ -84,7 +89,10 @@ type SaveHighlightResult struct {
|
||||
}
|
||||
|
||||
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{
|
||||
UserID: req.UserID,
|
||||
@@ -124,22 +132,22 @@ func (s *AnnotationService) createHighlight(
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create highlight: %w", err)
|
||||
@@ -179,20 +187,20 @@ func (s *AnnotationService) applyLWW(
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update highlight: %w", err)
|
||||
@@ -335,6 +343,7 @@ type SaveNoteRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData []byte
|
||||
DedupKey string // overrides the computed key for device echoes
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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{
|
||||
UserID: req.UserID,
|
||||
@@ -485,6 +497,9 @@ type SaveBookmarkRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
// DedupKey overrides the computed key for device echoes (see
|
||||
// SaveHighlightRequest).
|
||||
DedupKey string
|
||||
}
|
||||
|
||||
type SaveBookmarkResult struct {
|
||||
@@ -494,7 +509,10 @@ type SaveBookmarkResult struct {
|
||||
}
|
||||
|
||||
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{
|
||||
UserID: req.UserID,
|
||||
@@ -530,21 +548,21 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
type CFIConverter struct {
|
||||
epubPath string
|
||||
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 {
|
||||
@@ -37,6 +41,8 @@ func NewCFIConverter(epubPath string) *CFIConverter {
|
||||
}
|
||||
|
||||
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if 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]
|
||||
href := item.href
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if cached, ok := spine.docCache[href]; ok {
|
||||
return cached, href, nil
|
||||
}
|
||||
@@ -243,6 +251,46 @@ type ConversionResult struct {
|
||||
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) {
|
||||
if IsCREFragmentID(xpointer) {
|
||||
return c.convertFragmentID(xpointer, storedPercentage)
|
||||
@@ -884,8 +932,8 @@ func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
||||
}
|
||||
|
||||
type opfContainer struct {
|
||||
XMLName xml.Name `xml:"container"`
|
||||
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
||||
XMLName xml.Name `xml:"container"`
|
||||
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
||||
}
|
||||
|
||||
type opfRoot struct {
|
||||
@@ -906,8 +954,8 @@ func extractOPFPath(data []byte) (string, error) {
|
||||
}
|
||||
|
||||
type xmlPackage struct {
|
||||
XMLName xml.Name `xml:"package"`
|
||||
Spine xmlSpine `xml:"spine"`
|
||||
XMLName xml.Name `xml:"package"`
|
||||
Spine xmlSpine `xml:"spine"`
|
||||
Manifest xmlManifest `xml:"manifest"`
|
||||
}
|
||||
|
||||
@@ -1036,9 +1084,9 @@ func preprocessXHTML(input string) string {
|
||||
}
|
||||
|
||||
type cfiStep struct {
|
||||
Index int
|
||||
ID string
|
||||
Offset int
|
||||
Index int
|
||||
ID string
|
||||
Offset int
|
||||
HasOffset bool
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package sync
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type LocatorSource string
|
||||
|
||||
@@ -26,6 +29,35 @@ func isConvertible(formatGroup string) bool {
|
||||
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(
|
||||
source LocatorSource,
|
||||
devicePos string,
|
||||
@@ -48,7 +80,7 @@ func ConvertToCanonical(
|
||||
if !IsCREXPointer(devicePos) {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||
}
|
||||
converter := NewCFIConverter(epubPath)
|
||||
converter := cachedConverter(epubPath)
|
||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||
@@ -101,7 +133,7 @@ func ConvertFromCanonical(
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
converter := NewCFIConverter(epubPath)
|
||||
converter := cachedConverter(epubPath)
|
||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||
|
||||
@@ -412,6 +412,8 @@ document.addEventListener("alpine:init", () => {
|
||||
note: string;
|
||||
color: string;
|
||||
cfi: string;
|
||||
cfiEnd: string;
|
||||
renderCfi: string;
|
||||
percentage: number;
|
||||
pdfPage: number;
|
||||
pdfRects: number[][];
|
||||
@@ -443,6 +445,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: 0,
|
||||
text: "",
|
||||
cfi: "",
|
||||
cfiEnd: "",
|
||||
id: "",
|
||||
color: "#ffd54f",
|
||||
note: "",
|
||||
@@ -701,8 +704,15 @@ document.addEventListener("alpine:init", () => {
|
||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||
if (!text) return;
|
||||
let cfi: string;
|
||||
let cfiEnd: string;
|
||||
try {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
@@ -715,6 +725,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text,
|
||||
cfi,
|
||||
cfiEnd,
|
||||
});
|
||||
};
|
||||
doc.addEventListener(
|
||||
@@ -827,6 +838,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text: h.text,
|
||||
cfi: h.cfi,
|
||||
cfiEnd: h.cfiEnd,
|
||||
id: h.id,
|
||||
color: h.color,
|
||||
note: h.note,
|
||||
@@ -1070,6 +1082,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: number;
|
||||
text: string;
|
||||
cfi: string;
|
||||
cfiEnd?: string;
|
||||
id?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
@@ -1080,6 +1093,7 @@ document.addEventListener("alpine:init", () => {
|
||||
p.mode = opts.mode;
|
||||
p.text = opts.text;
|
||||
p.cfi = opts.cfi;
|
||||
p.cfiEnd = opts.cfiEnd ?? "";
|
||||
p.id = opts.id ?? "";
|
||||
p.color = opts.color || "#ffd54f";
|
||||
p.note = opts.note ?? "";
|
||||
@@ -1109,7 +1123,7 @@ document.addEventListener("alpine:init", () => {
|
||||
} else {
|
||||
this.view
|
||||
?.addAnnotation({
|
||||
value: hl.cfi,
|
||||
value: hl.renderCfi || hl.cfi,
|
||||
color: hl.color,
|
||||
note: hl.note,
|
||||
id: hl.id,
|
||||
@@ -1141,17 +1155,68 @@ document.addEventListener("alpine:init", () => {
|
||||
/* not ours; leave as-is */
|
||||
}
|
||||
}
|
||||
const cfiEnd = r.epubcfi_end ?? "";
|
||||
return {
|
||||
id: r.id,
|
||||
text: r.selection_text ?? "",
|
||||
note: r.note_text ?? "",
|
||||
color: r.color ?? "#ffff00",
|
||||
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,
|
||||
pdfPage,
|
||||
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() {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
@@ -1205,6 +1270,7 @@ document.addEventListener("alpine:init", () => {
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||
color,
|
||||
note_text: "",
|
||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||
@@ -1230,7 +1296,10 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
} else {
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
value:
|
||||
p.pdfPage >= 0
|
||||
? ""
|
||||
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||
color,
|
||||
note: "",
|
||||
id: row.id,
|
||||
@@ -1263,6 +1332,7 @@ document.addEventListener("alpine:init", () => {
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: anchor,
|
||||
epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd,
|
||||
color: p.color,
|
||||
note_text: p.note,
|
||||
}),
|
||||
@@ -1282,7 +1352,7 @@ document.addEventListener("alpine:init", () => {
|
||||
});
|
||||
} else {
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||
color: p.color,
|
||||
note: p.note,
|
||||
id: p.id,
|
||||
@@ -1324,6 +1394,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToHighlight(hl: {
|
||||
cfi: string;
|
||||
renderCfi: string;
|
||||
pdfPage: number;
|
||||
}) {
|
||||
if (hl.pdfPage >= 0) {
|
||||
@@ -1331,9 +1402,11 @@ document.addEventListener("alpine:init", () => {
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(hl.pdfPage);
|
||||
this.closeDrawers();
|
||||
} else if (hl.cfi) {
|
||||
} else if (hl.renderCfi || hl.cfi) {
|
||||
this.pushBackStack();
|
||||
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
||||
this.view
|
||||
?.showAnnotation({ value: hl.renderCfi || hl.cfi })
|
||||
?.catch?.(() => {});
|
||||
this.closeDrawers();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user