feat(sync): add AnnotationService with dedup, LWW, and tombstone management
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
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user