import Foundation struct RDEPUBJumpSession { let anchorSpineIndex: Int let createdAt: CFAbsoluteTime let protectedSpineIndices: Set let sequenceNumber: Int let expiresAt: CFAbsoluteTime let reason: Reason enum Reason { case tableOfContentsJump case bookmarkJump case searchJump } enum EndReason { case coverageComplete case navigatedAway case timeout case superseded } } public struct RDEPUBJumpSessionPolicy: Equatable { public let exitPageThreshold: Int public let timeout: TimeInterval public let idleGracePeriod: TimeInterval public let protectedNeighborRadius: Int public static let `default` = RDEPUBJumpSessionPolicy( exitPageThreshold: 6, timeout: 20, idleGracePeriod: 1.5, protectedNeighborRadius: 1 ) public init( exitPageThreshold: Int = 6, timeout: TimeInterval = 20, idleGracePeriod: TimeInterval = 1.5, protectedNeighborRadius: Int = 1 ) { self.exitPageThreshold = exitPageThreshold self.timeout = timeout self.idleGracePeriod = idleGracePeriod self.protectedNeighborRadius = protectedNeighborRadius } } final class RDEPUBJumpSessionManager { private unowned let context: RDEPUBReaderContext private(set) var activeSession: RDEPUBJumpSession? private var nextSequenceNumber: Int = 0 private var consecutivePageCount: Int = 0 private var lastPageDirection: PageDirection? private var lastActivityTime: CFAbsoluteTime = 0 enum PageDirection { case forward case backward } init(context: RDEPUBReaderContext) { self.context = context } @discardableResult func createSession( anchorSpineIndex: Int, reason: RDEPUBJumpSession.Reason, totalSpineCount: Int ) -> RDEPUBJumpSession { let policy = context.configuration.jumpSessionPolicy let now = CFAbsoluteTimeGetCurrent() var protectedIndices: Set = [anchorSpineIndex] for offset in 1...policy.protectedNeighborRadius { let lower = anchorSpineIndex - offset let upper = anchorSpineIndex + offset if lower >= 0 { protectedIndices.insert(lower) } if upper < totalSpineCount { protectedIndices.insert(upper) } } nextSequenceNumber += 1 let session = RDEPUBJumpSession( anchorSpineIndex: anchorSpineIndex, createdAt: now, protectedSpineIndices: protectedIndices, sequenceNumber: nextSequenceNumber, expiresAt: now + policy.timeout, reason: reason ) activeSession = session consecutivePageCount = 0 lastPageDirection = nil lastActivityTime = now return session } func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) { guard activeSession != nil else { return } let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward lastActivityTime = CFAbsoluteTimeGetCurrent() if direction == lastPageDirection { consecutivePageCount += 1 } else { consecutivePageCount = 1 lastPageDirection = direction } } func shouldAllowPageMapTakeover(candidateSpineIndices: Set) -> Bool { guard let session = activeSession else { return true } let protectedIndices = session.protectedSpineIndices let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) / Double(protectedIndices.count) return coverageRatio >= 0.8 } func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? { guard let session = activeSession else { return nil } let now = CFAbsoluteTimeGetCurrent() let policy = context.configuration.jumpSessionPolicy if now >= session.expiresAt { if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod { return .timeout } } if !session.protectedSpineIndices.contains(currentSpineIndex) { if consecutivePageCount >= policy.exitPageThreshold { return .navigatedAway } } else { consecutivePageCount = 0 } return nil } func endSession(_ reason: RDEPUBJumpSession.EndReason) { guard let session = activeSession else { return } activeSession = nil consecutivePageCount = 0 lastPageDirection = nil } func clearSession() { activeSession = nil consecutivePageCount = 0 lastPageDirection = nil nextSequenceNumber = 0 } }