feat(sync): server-side position authority — verify/heal progress anchors

Progress submissions now carry (percentage, context_text, epubcfi) and
the server becomes the position authority:

- VerifyProgressAnchor resolves the submitted standard CFI against the
  book's own XHTML, extracts the text at the anchor, and cross-checks it
  with the submitted context_text. A mismatch heals the anchor by text
  search (percentage disambiguates repeats) instead of storing a bad
  position.
- The anchor's block element is derived as a cssSelector plus a block-
  relative character offset, and served on progress GET alongside the
  anchor document's href — readium-native handles that let clients
  re-open a book without parsing CFIs themselves.
- context_text-only submissions (no CFI — the dumb-client tier) are
  anchored structurally from the context text.

Motivation: cross-client progress sync (web foliate CFIs, KOReader CRE
xpointers, readium-native apps) previously trusted each client's own
locator math; the app's EPUB restore drifted ±pages because readium's
paginator does not lay out far-from-viewport columns and the foliate-
ported CFI walk ran against readium's mutated WebView DOM. Server-side
verification heals both classes at ingest.
This commit is contained in:
John O'Keefe
2026-09-26 14:43:34 -04:00
parent 1c1f5cf1bb
commit aec226af1a
6 changed files with 572 additions and 0 deletions
+43
View File
@@ -274,8 +274,18 @@ func EstimatedPages(totalCharacters int64) int {
type ProgressService struct {
db *database.Queries
connManager *ConnectionManager
mediaPaths MediaPathResolver
}
// MediaPathResolver resolves a media item's library-relative file path to
// an absolute path, so the progress service can verify submitted anchors
// against the book itself.
type MediaPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
}
func (s *ProgressService) SetMediaPathResolver(r MediaPathResolver) { s.mediaPaths = r }
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
return &ProgressService{db: db, connManager: connManager}
}
@@ -435,6 +445,39 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
}
}
// Canonical position authority: resolve the submitted CFI against the
// book itself, cross-check the submitted context text, and heal the
// anchor by text search on any mismatch. Clients submit what their
// renderer can reliably observe (percentage + visible text); the
// server owns the structural math and keeps buggy clients from
// poisoning stored positions.
if !isFixed && s.mediaPaths != nil && params.ContextText.Valid &&
isConvertible(string(formatGroup)) && formatGroup != "" {
if path, perr := s.mediaPaths.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
submittedCFI := ""
if params.Epubcfi.Valid {
submittedCFI = params.Epubcfi.String
}
submittedPct := 0.0
if params.Percentage.Valid {
submittedPct = params.Percentage.Float64
}
if finalCFI, _, _, charOff, healedPct, healed, verr := VerifyProgressAnchor(path, submittedCFI, params.ContextText.String, submittedPct); verr != nil {
log.Printf("Bookhoard: progress anchor verify failed for %s: %v", req.MediaItemID.String(), verr)
} else {
if healed || (!params.Epubcfi.Valid && finalCFI != "") {
params.Epubcfi = pgtype.Text{String: finalCFI, Valid: true}
if healedPct != nil {
params.Percentage = pgtype.Float8{Float64: *healedPct, Valid: true}
}
if charOff != nil {
params.CharacterOffset = pgtype.Int8{Int64: int64(*charOff), Valid: true}
}
}
}
}
}
conflictDetected := false
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
if existing.LastSyncTimestamp.Valid {