AnnotationService is the central service for cross-device annotation sync.
It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle
the full sync lifecycle:
Identity (3-layer):
1. Server UUID (primary key)
2. Per-device native ID stored in device_sync_data JSONB
3. Content dedup_key: sha1(normalize(selection_text) + bucket_position)
- CFI character offsets are stripped for bucketing so the same
highlight at slightly different offsets still deduplicates
- Raw positions are preserved in the DB for precise restoration
Resolution policy (LWW):
- When the incoming annotation has an explicit ModifiedAt timestamp,
last_modified_at wins
- When the device sends zero ModifiedAt (creation time only), field-diff
mode compares content fields (text/color/note/percentage) — if all
match, the save is skipped; if any differ, the save is applied with
server-receive-time as the new last_modified_at
Conflict detection:
- When incoming and existing annotations have different sources (e.g.
koreader vs kobo) and content differs, an auto_resolved sync_conflict
is recorded with both sides' data for audit trail
- Broadcasts a WebSocket conflict notification for real-time UI updates
Tombstone management:
- Delete-wins: tombstoned annotations block recreation from stale pushes
- 30-day TTL before physical purge
- PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker)
Add locators.go with unified bidirectional CFI conversion:
ConvertToCanonical / ConvertFromCanonical
- CRE XPointer <-> standard EPUB CFI (for KOReader)
- KEPUB CFI passthrough (for Kobo)
- Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs)
Add 25 unit tests covering:
- Dedup key determinism, text normalization, position sensitivity
- Offset insensitivity (CFI char-offset bucketing)
- Device sync data merge (preserves existing, overwrites same source)
- Cross-source detection
- LWW comparison (newer wins, older skipped, fallback to updated_at)
- Field-diff mode (identical content skipped, changes applied)
- Tombstone TTL constant
- CRE XPointer parsing and classification
- Standard EPUB CFI classification
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
package sync
|
|
|
|
import "log"
|
|
|
|
type LocatorSource string
|
|
|
|
const (
|
|
LocatorSourceKOReader LocatorSource = "koreader"
|
|
LocatorSourceKobo LocatorSource = "kobo"
|
|
LocatorSourceWeb LocatorSource = "web"
|
|
)
|
|
|
|
type CanonicalLocator struct {
|
|
CFI string
|
|
Precision string
|
|
Percentage float64
|
|
}
|
|
|
|
type DeviceLocator struct {
|
|
Position string
|
|
Precision string
|
|
Percentage float64
|
|
}
|
|
|
|
func isConvertible(formatGroup string) bool {
|
|
return formatGroup == string(FormatGroupReflowable)
|
|
}
|
|
|
|
func ConvertToCanonical(
|
|
source LocatorSource,
|
|
devicePos string,
|
|
percentage float64,
|
|
contextText string,
|
|
formatGroup string,
|
|
epubPath string,
|
|
kepubPath string,
|
|
) CanonicalLocator {
|
|
if !isConvertible(formatGroup) || epubPath == "" {
|
|
return CanonicalLocator{
|
|
CFI: devicePos,
|
|
Precision: "passthrough",
|
|
Percentage: percentage,
|
|
}
|
|
}
|
|
|
|
switch source {
|
|
case LocatorSourceKOReader:
|
|
if !IsCREXPointer(devicePos) {
|
|
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
|
}
|
|
converter := NewCFIConverter(epubPath)
|
|
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.EPUBCFI != "" {
|
|
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
if result.Href != "" {
|
|
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
|
|
|
case LocatorSourceKobo:
|
|
if kepubPath == "" {
|
|
return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage}
|
|
}
|
|
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err)
|
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.CFI != "" {
|
|
return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
|
|
|
default:
|
|
return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage}
|
|
}
|
|
}
|
|
|
|
func ConvertFromCanonical(
|
|
source LocatorSource,
|
|
canonicalCFI string,
|
|
percentage float64,
|
|
contextText string,
|
|
formatGroup string,
|
|
epubPath string,
|
|
kepubPath string,
|
|
) DeviceLocator {
|
|
if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" {
|
|
return DeviceLocator{
|
|
Position: canonicalCFI,
|
|
Precision: "passthrough",
|
|
Percentage: percentage,
|
|
}
|
|
}
|
|
|
|
switch source {
|
|
case LocatorSourceKOReader:
|
|
converter := NewCFIConverter(epubPath)
|
|
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.XPointer != "" {
|
|
return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
|
|
|
case LocatorSourceKobo:
|
|
if kepubPath == "" {
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage}
|
|
}
|
|
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err)
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.CFI != "" {
|
|
return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
|
|
|
default:
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage}
|
|
}
|
|
}
|