diff --git a/bindings/ios/ZxDocumentRuntime.xcframework/Info.plist b/bindings/ios/ZxDocumentRuntime.xcframework/Info.plist index 612f37c..4526e4c 100644 --- a/bindings/ios/ZxDocumentRuntime.xcframework/Info.plist +++ b/bindings/ios/ZxDocumentRuntime.xcframework/Info.plist @@ -8,12 +8,13 @@ BinaryPath libzx_document_ffi.a LibraryIdentifier - ios-arm64-simulator + ios-arm64_x86_64-simulator LibraryPath libzx_document_ffi.a SupportedArchitectures arm64 + x86_64 SupportedPlatform ios diff --git a/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64/libzx_document_ffi.a b/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64/libzx_document_ffi.a new file mode 100644 index 0000000..e762a9c Binary files /dev/null and b/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64/libzx_document_ffi.a differ diff --git a/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64_x86_64-simulator/libzx_document_ffi.a b/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64_x86_64-simulator/libzx_document_ffi.a new file mode 100644 index 0000000..e1166df Binary files /dev/null and b/bindings/ios/ZxDocumentRuntime.xcframework/ios-arm64_x86_64-simulator/libzx_document_ffi.a differ diff --git a/bindings/ios/device/libzx_document_ffi.a b/bindings/ios/device/libzx_document_ffi.a new file mode 100644 index 0000000..e762a9c Binary files /dev/null and b/bindings/ios/device/libzx_document_ffi.a differ diff --git a/bindings/ios/generated/zx_document.swift b/bindings/ios/generated/zx_document.swift index 33c9d7d..a948d16 100644 --- a/bindings/ios/generated/zx_document.swift +++ b/bindings/ios/generated/zx_document.swift @@ -533,7 +533,7 @@ fileprivate struct FfiConverterString: FfiConverter { if value.data == nil { return String() } - let bytes = UnsafeBufferPointer(start: value.data!.assumingMemoryBound(to: UInt8.self), count: Int(value.len)) + let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len)) return String(bytes: bytes, encoding: String.Encoding.utf8)! } @@ -569,11 +569,20 @@ public struct DocumentInfo: Equatable, Hashable { public var fileSize: UInt64 public var pageCount: UInt32? public var wordCount: UInt32? + public var charCount: UInt32? + public var lineCount: UInt32? + public var imageWidth: UInt32? + public var imageHeight: UInt32? + public var imageFormat: String? + public var epubChapterCount: UInt32? + public var isSearchable: Bool + public var supportsPosition: Bool + public var supportsAnchor: Bool public var createdAt: String? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(materialId: String, title: String, materialType: MaterialType, previewMode: PreviewMode, fileSize: UInt64, pageCount: UInt32?, wordCount: UInt32?, createdAt: String?) { + public init(materialId: String, title: String, materialType: MaterialType, previewMode: PreviewMode, fileSize: UInt64, pageCount: UInt32?, wordCount: UInt32?, charCount: UInt32?, lineCount: UInt32?, imageWidth: UInt32?, imageHeight: UInt32?, imageFormat: String?, epubChapterCount: UInt32?, isSearchable: Bool, supportsPosition: Bool, supportsAnchor: Bool, createdAt: String?) { self.materialId = materialId self.title = title self.materialType = materialType @@ -581,6 +590,15 @@ public struct DocumentInfo: Equatable, Hashable { self.fileSize = fileSize self.pageCount = pageCount self.wordCount = wordCount + self.charCount = charCount + self.lineCount = lineCount + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.imageFormat = imageFormat + self.epubChapterCount = epubChapterCount + self.isSearchable = isSearchable + self.supportsPosition = supportsPosition + self.supportsAnchor = supportsAnchor self.createdAt = createdAt } @@ -607,6 +625,15 @@ public struct FfiConverterTypeDocumentInfo: FfiConverterRustBuffer { fileSize: FfiConverterUInt64.read(from: &buf), pageCount: FfiConverterOptionUInt32.read(from: &buf), wordCount: FfiConverterOptionUInt32.read(from: &buf), + charCount: FfiConverterOptionUInt32.read(from: &buf), + lineCount: FfiConverterOptionUInt32.read(from: &buf), + imageWidth: FfiConverterOptionUInt32.read(from: &buf), + imageHeight: FfiConverterOptionUInt32.read(from: &buf), + imageFormat: FfiConverterOptionString.read(from: &buf), + epubChapterCount: FfiConverterOptionUInt32.read(from: &buf), + isSearchable: FfiConverterBool.read(from: &buf), + supportsPosition: FfiConverterBool.read(from: &buf), + supportsAnchor: FfiConverterBool.read(from: &buf), createdAt: FfiConverterOptionString.read(from: &buf) ) } @@ -619,6 +646,15 @@ public struct FfiConverterTypeDocumentInfo: FfiConverterRustBuffer { FfiConverterUInt64.write(value.fileSize, into: &buf) FfiConverterOptionUInt32.write(value.pageCount, into: &buf) FfiConverterOptionUInt32.write(value.wordCount, into: &buf) + FfiConverterOptionUInt32.write(value.charCount, into: &buf) + FfiConverterOptionUInt32.write(value.lineCount, into: &buf) + FfiConverterOptionUInt32.write(value.imageWidth, into: &buf) + FfiConverterOptionUInt32.write(value.imageHeight, into: &buf) + FfiConverterOptionString.write(value.imageFormat, into: &buf) + FfiConverterOptionUInt32.write(value.epubChapterCount, into: &buf) + FfiConverterBool.write(value.isSearchable, into: &buf) + FfiConverterBool.write(value.supportsPosition, into: &buf) + FfiConverterBool.write(value.supportsAnchor, into: &buf) FfiConverterOptionString.write(value.createdAt, into: &buf) } } @@ -763,6 +799,68 @@ public func FfiConverterTypeEpubMetadata_lower(_ value: EpubMetadata) -> RustBuf } +public struct EventBufferStateV2: Equatable, Hashable { + public var total: UInt32 + public var pending: UInt32 + public var exported: UInt32 + public var failed: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(total: UInt32, pending: UInt32, exported: UInt32, failed: UInt32) { + self.total = total + self.pending = pending + self.exported = exported + self.failed = failed + } + + + + +} + +#if compiler(>=6) +extension EventBufferStateV2: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeEventBufferStateV2: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> EventBufferStateV2 { + return + try EventBufferStateV2( + total: FfiConverterUInt32.read(from: &buf), + pending: FfiConverterUInt32.read(from: &buf), + exported: FfiConverterUInt32.read(from: &buf), + failed: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: EventBufferStateV2, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.total, into: &buf) + FfiConverterUInt32.write(value.pending, into: &buf) + FfiConverterUInt32.write(value.exported, into: &buf) + FfiConverterUInt32.write(value.failed, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeEventBufferStateV2_lift(_ buf: RustBuffer) throws -> EventBufferStateV2 { + return try FfiConverterTypeEventBufferStateV2.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeEventBufferStateV2_lower(_ value: EventBufferStateV2) -> RustBuffer { + return FfiConverterTypeEventBufferStateV2.lower(value) +} + + public struct ImageMeta: Equatable, Hashable { public var width: UInt32 public var height: UInt32 @@ -1003,24 +1101,104 @@ public func FfiConverterTypePdfPageText_lower(_ value: PdfPageText) -> RustBuffe } +public struct ReadingCheckpoint: Equatable, Hashable { + public var schemaVersion: UInt32 + public var materialId: String + public var representationFingerprint: String + public var rendererKind: RendererKind + public var capturedAtMs: UInt64 + public var deviceId: String + public var deviceSequence: UInt64 + public var position: ReadingPosition + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(schemaVersion: UInt32, materialId: String, representationFingerprint: String, rendererKind: RendererKind, capturedAtMs: UInt64, deviceId: String, deviceSequence: UInt64, position: ReadingPosition) { + self.schemaVersion = schemaVersion + self.materialId = materialId + self.representationFingerprint = representationFingerprint + self.rendererKind = rendererKind + self.capturedAtMs = capturedAtMs + self.deviceId = deviceId + self.deviceSequence = deviceSequence + self.position = position + } + + + + +} + +#if compiler(>=6) +extension ReadingCheckpoint: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeReadingCheckpoint: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ReadingCheckpoint { + return + try ReadingCheckpoint( + schemaVersion: FfiConverterUInt32.read(from: &buf), + materialId: FfiConverterString.read(from: &buf), + representationFingerprint: FfiConverterString.read(from: &buf), + rendererKind: FfiConverterTypeRendererKind.read(from: &buf), + capturedAtMs: FfiConverterUInt64.read(from: &buf), + deviceId: FfiConverterString.read(from: &buf), + deviceSequence: FfiConverterUInt64.read(from: &buf), + position: FfiConverterTypeReadingPosition.read(from: &buf) + ) + } + + public static func write(_ value: ReadingCheckpoint, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.schemaVersion, into: &buf) + FfiConverterString.write(value.materialId, into: &buf) + FfiConverterString.write(value.representationFingerprint, into: &buf) + FfiConverterTypeRendererKind.write(value.rendererKind, into: &buf) + FfiConverterUInt64.write(value.capturedAtMs, into: &buf) + FfiConverterString.write(value.deviceId, into: &buf) + FfiConverterUInt64.write(value.deviceSequence, into: &buf) + FfiConverterTypeReadingPosition.write(value.position, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeReadingCheckpoint_lift(_ buf: RustBuffer) throws -> ReadingCheckpoint { + return try FfiConverterTypeReadingCheckpoint.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeReadingCheckpoint_lower(_ value: ReadingCheckpoint) -> RustBuffer { + return FfiConverterTypeReadingCheckpoint.lower(value) +} + + public struct ReadingEventV2: Equatable, Hashable { public var eventId: String public var clientSessionId: String public var materialId: String public var eventType: ReadingEventTypeV2 public var position: ReadingPosition? + public var checkpoint: ReadingCheckpoint? public var activeSecondsDelta: UInt32 public var timestampMs: Int64 public var sequence: UInt64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(eventId: String, clientSessionId: String, materialId: String, eventType: ReadingEventTypeV2, position: ReadingPosition?, activeSecondsDelta: UInt32, timestampMs: Int64, sequence: UInt64) { + public init(eventId: String, clientSessionId: String, materialId: String, eventType: ReadingEventTypeV2, position: ReadingPosition?, checkpoint: ReadingCheckpoint?, activeSecondsDelta: UInt32, timestampMs: Int64, sequence: UInt64) { self.eventId = eventId self.clientSessionId = clientSessionId self.materialId = materialId self.eventType = eventType self.position = position + self.checkpoint = checkpoint self.activeSecondsDelta = activeSecondsDelta self.timestampMs = timestampMs self.sequence = sequence @@ -1047,6 +1225,7 @@ public struct FfiConverterTypeReadingEventV2: FfiConverterRustBuffer { materialId: FfiConverterString.read(from: &buf), eventType: FfiConverterTypeReadingEventTypeV2.read(from: &buf), position: FfiConverterOptionTypeReadingPosition.read(from: &buf), + checkpoint: FfiConverterOptionTypeReadingCheckpoint.read(from: &buf), activeSecondsDelta: FfiConverterUInt32.read(from: &buf), timestampMs: FfiConverterInt64.read(from: &buf), sequence: FfiConverterUInt64.read(from: &buf) @@ -1059,6 +1238,7 @@ public struct FfiConverterTypeReadingEventV2: FfiConverterRustBuffer { FfiConverterString.write(value.materialId, into: &buf) FfiConverterTypeReadingEventTypeV2.write(value.eventType, into: &buf) FfiConverterOptionTypeReadingPosition.write(value.position, into: &buf) + FfiConverterOptionTypeReadingCheckpoint.write(value.checkpoint, into: &buf) FfiConverterUInt32.write(value.activeSecondsDelta, into: &buf) FfiConverterInt64.write(value.timestampMs, into: &buf) FfiConverterUInt64.write(value.sequence, into: &buf) @@ -1131,26 +1311,108 @@ public func FfiConverterTypeReadingMaterialRef_lower(_ value: ReadingMaterialRef } +public struct ReadingMaterialRefV2: Equatable, Hashable { + public var materialId: String + public var filePath: String + public var fileName: String + public var fileType: String + public var mimeType: String? + public var fileSize: UInt64? + public var contentHash: String? + public var createdAtMs: Int64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(materialId: String, filePath: String, fileName: String, fileType: String, mimeType: String?, fileSize: UInt64?, contentHash: String?, createdAtMs: Int64?) { + self.materialId = materialId + self.filePath = filePath + self.fileName = fileName + self.fileType = fileType + self.mimeType = mimeType + self.fileSize = fileSize + self.contentHash = contentHash + self.createdAtMs = createdAtMs + } + + + + +} + +#if compiler(>=6) +extension ReadingMaterialRefV2: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeReadingMaterialRefV2: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ReadingMaterialRefV2 { + return + try ReadingMaterialRefV2( + materialId: FfiConverterString.read(from: &buf), + filePath: FfiConverterString.read(from: &buf), + fileName: FfiConverterString.read(from: &buf), + fileType: FfiConverterString.read(from: &buf), + mimeType: FfiConverterOptionString.read(from: &buf), + fileSize: FfiConverterOptionUInt64.read(from: &buf), + contentHash: FfiConverterOptionString.read(from: &buf), + createdAtMs: FfiConverterOptionInt64.read(from: &buf) + ) + } + + public static func write(_ value: ReadingMaterialRefV2, into buf: inout [UInt8]) { + FfiConverterString.write(value.materialId, into: &buf) + FfiConverterString.write(value.filePath, into: &buf) + FfiConverterString.write(value.fileName, into: &buf) + FfiConverterString.write(value.fileType, into: &buf) + FfiConverterOptionString.write(value.mimeType, into: &buf) + FfiConverterOptionUInt64.write(value.fileSize, into: &buf) + FfiConverterOptionString.write(value.contentHash, into: &buf) + FfiConverterOptionInt64.write(value.createdAtMs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeReadingMaterialRefV2_lift(_ buf: RustBuffer) throws -> ReadingMaterialRefV2 { + return try FfiConverterTypeReadingMaterialRefV2.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeReadingMaterialRefV2_lower(_ value: ReadingMaterialRefV2) -> RustBuffer { + return FfiConverterTypeReadingMaterialRefV2.lower(value) +} + + public struct ReadingSessionV2: Equatable, Hashable { public var clientSessionId: String - public var material: ReadingMaterialRef + public var material: ReadingMaterialRefV2 public var startedAtMs: Int64 + public var closedAtMs: Int64? public var lastEventAtMs: Int64 public var nextSequence: UInt64 public var totalActiveSeconds: UInt32 public var lastPosition: ReadingPosition? + public var lastCheckpoint: ReadingCheckpoint? public var status: ReadingSessionStatus // Default memberwise initializers are never public by default, so we // declare one manually. - public init(clientSessionId: String, material: ReadingMaterialRef, startedAtMs: Int64, lastEventAtMs: Int64, nextSequence: UInt64, totalActiveSeconds: UInt32, lastPosition: ReadingPosition?, status: ReadingSessionStatus) { + public init(clientSessionId: String, material: ReadingMaterialRefV2, startedAtMs: Int64, closedAtMs: Int64?, lastEventAtMs: Int64, nextSequence: UInt64, totalActiveSeconds: UInt32, lastPosition: ReadingPosition?, lastCheckpoint: ReadingCheckpoint?, status: ReadingSessionStatus) { self.clientSessionId = clientSessionId self.material = material self.startedAtMs = startedAtMs + self.closedAtMs = closedAtMs self.lastEventAtMs = lastEventAtMs self.nextSequence = nextSequence self.totalActiveSeconds = totalActiveSeconds self.lastPosition = lastPosition + self.lastCheckpoint = lastCheckpoint self.status = status } @@ -1171,24 +1433,28 @@ public struct FfiConverterTypeReadingSessionV2: FfiConverterRustBuffer { return try ReadingSessionV2( clientSessionId: FfiConverterString.read(from: &buf), - material: FfiConverterTypeReadingMaterialRef.read(from: &buf), + material: FfiConverterTypeReadingMaterialRefV2.read(from: &buf), startedAtMs: FfiConverterInt64.read(from: &buf), + closedAtMs: FfiConverterOptionInt64.read(from: &buf), lastEventAtMs: FfiConverterInt64.read(from: &buf), nextSequence: FfiConverterUInt64.read(from: &buf), totalActiveSeconds: FfiConverterUInt32.read(from: &buf), lastPosition: FfiConverterOptionTypeReadingPosition.read(from: &buf), + lastCheckpoint: FfiConverterOptionTypeReadingCheckpoint.read(from: &buf), status: FfiConverterTypeReadingSessionStatus.read(from: &buf) ) } public static func write(_ value: ReadingSessionV2, into buf: inout [UInt8]) { FfiConverterString.write(value.clientSessionId, into: &buf) - FfiConverterTypeReadingMaterialRef.write(value.material, into: &buf) + FfiConverterTypeReadingMaterialRefV2.write(value.material, into: &buf) FfiConverterInt64.write(value.startedAtMs, into: &buf) + FfiConverterOptionInt64.write(value.closedAtMs, into: &buf) FfiConverterInt64.write(value.lastEventAtMs, into: &buf) FfiConverterUInt64.write(value.nextSequence, into: &buf) FfiConverterUInt32.write(value.totalActiveSeconds, into: &buf) FfiConverterOptionTypeReadingPosition.write(value.lastPosition, into: &buf) + FfiConverterOptionTypeReadingCheckpoint.write(value.lastCheckpoint, into: &buf) FfiConverterTypeReadingSessionStatus.write(value.status, into: &buf) } } @@ -1496,6 +1762,16 @@ public enum DocumentError: Swift.Error, Equatable, Hashable, Foundation.Localize case IoError(message: String) + case SessionNotFound(message: String) + + case SessionAlreadyActive(message: String) + + case SessionNotActive(message: String) + + case BufferOverflow(message: String) + + case Unsupported(message: String) + @@ -1545,6 +1821,26 @@ public struct FfiConverterTypeDocumentError: FfiConverterRustBuffer { message: try FfiConverterString.read(from: &buf) ) + case 6: return .SessionNotFound( + message: try FfiConverterString.read(from: &buf) + ) + + case 7: return .SessionAlreadyActive( + message: try FfiConverterString.read(from: &buf) + ) + + case 8: return .SessionNotActive( + message: try FfiConverterString.read(from: &buf) + ) + + case 9: return .BufferOverflow( + message: try FfiConverterString.read(from: &buf) + ) + + case 10: return .Unsupported( + message: try FfiConverterString.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } @@ -1566,6 +1862,16 @@ public struct FfiConverterTypeDocumentError: FfiConverterRustBuffer { writeInt(&buf, Int32(4)) case .IoError(_ /* message is ignored*/): writeInt(&buf, Int32(5)) + case .SessionNotFound(_ /* message is ignored*/): + writeInt(&buf, Int32(6)) + case .SessionAlreadyActive(_ /* message is ignored*/): + writeInt(&buf, Int32(7)) + case .SessionNotActive(_ /* message is ignored*/): + writeInt(&buf, Int32(8)) + case .BufferOverflow(_ /* message is ignored*/): + writeInt(&buf, Int32(9)) + case .Unsupported(_ /* message is ignored*/): + writeInt(&buf, Int32(10)) } @@ -2218,14 +2524,24 @@ public func FfiConverterTypeReadingEventTypeV2_lower(_ value: ReadingEventTypeV2 public enum ReadingPosition: Equatable, Hashable { + case block(blockId: String, blockProgress: Float, overallProgress: Float + ) case markdown(blockId: String, scrollProgress: Float ) case text(lineNumber: UInt32, scrollProgress: Float ) case pdf(pageNumber: UInt32, pageProgress: Float, overallProgress: Float ) + case pdfViewport(pageNumber: UInt32, anchorX: Float, anchorY: Float, zoomScale: Float, overallProgress: Float + ) case image(zoomScale: Float, offsetX: Float, offsetY: Float ) + case imageViewport(zoomScale: Float, anchorX: Float, anchorY: Float + ) + case workbook(sheetId: String, topRow: UInt32, leftColumn: UInt32, selectedCell: String?, zoomScale: Float, overallProgress: Float? + ) + case slide(slideIndex: UInt32, zoomScale: Float, anchorX: Float, anchorY: Float, overallProgress: Float + ) case epub(chapterId: String, chapterProgress: Float, overallProgress: Float ) case unknown @@ -2250,22 +2566,37 @@ public struct FfiConverterTypeReadingPosition: FfiConverterRustBuffer { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return .markdown(blockId: try FfiConverterString.read(from: &buf), scrollProgress: try FfiConverterFloat.read(from: &buf) + case 1: return .block(blockId: try FfiConverterString.read(from: &buf), blockProgress: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) ) - case 2: return .text(lineNumber: try FfiConverterUInt32.read(from: &buf), scrollProgress: try FfiConverterFloat.read(from: &buf) + case 2: return .markdown(blockId: try FfiConverterString.read(from: &buf), scrollProgress: try FfiConverterFloat.read(from: &buf) ) - case 3: return .pdf(pageNumber: try FfiConverterUInt32.read(from: &buf), pageProgress: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) + case 3: return .text(lineNumber: try FfiConverterUInt32.read(from: &buf), scrollProgress: try FfiConverterFloat.read(from: &buf) ) - case 4: return .image(zoomScale: try FfiConverterFloat.read(from: &buf), offsetX: try FfiConverterFloat.read(from: &buf), offsetY: try FfiConverterFloat.read(from: &buf) + case 4: return .pdf(pageNumber: try FfiConverterUInt32.read(from: &buf), pageProgress: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) ) - case 5: return .epub(chapterId: try FfiConverterString.read(from: &buf), chapterProgress: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) + case 5: return .pdfViewport(pageNumber: try FfiConverterUInt32.read(from: &buf), anchorX: try FfiConverterFloat.read(from: &buf), anchorY: try FfiConverterFloat.read(from: &buf), zoomScale: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) ) - case 6: return .unknown + case 6: return .image(zoomScale: try FfiConverterFloat.read(from: &buf), offsetX: try FfiConverterFloat.read(from: &buf), offsetY: try FfiConverterFloat.read(from: &buf) + ) + + case 7: return .imageViewport(zoomScale: try FfiConverterFloat.read(from: &buf), anchorX: try FfiConverterFloat.read(from: &buf), anchorY: try FfiConverterFloat.read(from: &buf) + ) + + case 8: return .workbook(sheetId: try FfiConverterString.read(from: &buf), topRow: try FfiConverterUInt32.read(from: &buf), leftColumn: try FfiConverterUInt32.read(from: &buf), selectedCell: try FfiConverterOptionString.read(from: &buf), zoomScale: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterOptionFloat.read(from: &buf) + ) + + case 9: return .slide(slideIndex: try FfiConverterUInt32.read(from: &buf), zoomScale: try FfiConverterFloat.read(from: &buf), anchorX: try FfiConverterFloat.read(from: &buf), anchorY: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) + ) + + case 10: return .epub(chapterId: try FfiConverterString.read(from: &buf), chapterProgress: try FfiConverterFloat.read(from: &buf), overallProgress: try FfiConverterFloat.read(from: &buf) + ) + + case 11: return .unknown default: throw UniffiInternalError.unexpectedEnumCase } @@ -2275,41 +2606,83 @@ public struct FfiConverterTypeReadingPosition: FfiConverterRustBuffer { switch value { - case let .markdown(blockId,scrollProgress): + case let .block(blockId,blockProgress,overallProgress): writeInt(&buf, Int32(1)) FfiConverterString.write(blockId, into: &buf) + FfiConverterFloat.write(blockProgress, into: &buf) + FfiConverterFloat.write(overallProgress, into: &buf) + + + case let .markdown(blockId,scrollProgress): + writeInt(&buf, Int32(2)) + FfiConverterString.write(blockId, into: &buf) FfiConverterFloat.write(scrollProgress, into: &buf) case let .text(lineNumber,scrollProgress): - writeInt(&buf, Int32(2)) + writeInt(&buf, Int32(3)) FfiConverterUInt32.write(lineNumber, into: &buf) FfiConverterFloat.write(scrollProgress, into: &buf) case let .pdf(pageNumber,pageProgress,overallProgress): - writeInt(&buf, Int32(3)) + writeInt(&buf, Int32(4)) FfiConverterUInt32.write(pageNumber, into: &buf) FfiConverterFloat.write(pageProgress, into: &buf) FfiConverterFloat.write(overallProgress, into: &buf) + case let .pdfViewport(pageNumber,anchorX,anchorY,zoomScale,overallProgress): + writeInt(&buf, Int32(5)) + FfiConverterUInt32.write(pageNumber, into: &buf) + FfiConverterFloat.write(anchorX, into: &buf) + FfiConverterFloat.write(anchorY, into: &buf) + FfiConverterFloat.write(zoomScale, into: &buf) + FfiConverterFloat.write(overallProgress, into: &buf) + + case let .image(zoomScale,offsetX,offsetY): - writeInt(&buf, Int32(4)) + writeInt(&buf, Int32(6)) FfiConverterFloat.write(zoomScale, into: &buf) FfiConverterFloat.write(offsetX, into: &buf) FfiConverterFloat.write(offsetY, into: &buf) + case let .imageViewport(zoomScale,anchorX,anchorY): + writeInt(&buf, Int32(7)) + FfiConverterFloat.write(zoomScale, into: &buf) + FfiConverterFloat.write(anchorX, into: &buf) + FfiConverterFloat.write(anchorY, into: &buf) + + + case let .workbook(sheetId,topRow,leftColumn,selectedCell,zoomScale,overallProgress): + writeInt(&buf, Int32(8)) + FfiConverterString.write(sheetId, into: &buf) + FfiConverterUInt32.write(topRow, into: &buf) + FfiConverterUInt32.write(leftColumn, into: &buf) + FfiConverterOptionString.write(selectedCell, into: &buf) + FfiConverterFloat.write(zoomScale, into: &buf) + FfiConverterOptionFloat.write(overallProgress, into: &buf) + + + case let .slide(slideIndex,zoomScale,anchorX,anchorY,overallProgress): + writeInt(&buf, Int32(9)) + FfiConverterUInt32.write(slideIndex, into: &buf) + FfiConverterFloat.write(zoomScale, into: &buf) + FfiConverterFloat.write(anchorX, into: &buf) + FfiConverterFloat.write(anchorY, into: &buf) + FfiConverterFloat.write(overallProgress, into: &buf) + + case let .epub(chapterId,chapterProgress,overallProgress): - writeInt(&buf, Int32(5)) + writeInt(&buf, Int32(10)) FfiConverterString.write(chapterId, into: &buf) FfiConverterFloat.write(chapterProgress, into: &buf) FfiConverterFloat.write(overallProgress, into: &buf) case .unknown: - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(11)) } } @@ -2339,6 +2712,8 @@ public enum ReadingSessionStatus: Equatable, Hashable { case active case paused case closed + case interrupted + case failed @@ -2366,6 +2741,10 @@ public struct FfiConverterTypeReadingSessionStatus: FfiConverterRustBuffer { case 3: return .closed + case 4: return .interrupted + + case 5: return .failed + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -2385,6 +2764,14 @@ public struct FfiConverterTypeReadingSessionStatus: FfiConverterRustBuffer { case .closed: writeInt(&buf, Int32(3)) + + case .interrupted: + writeInt(&buf, Int32(4)) + + + case .failed: + writeInt(&buf, Int32(5)) + } } } @@ -2405,6 +2792,108 @@ public func FfiConverterTypeReadingSessionStatus_lower(_ value: ReadingSessionSt } +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum RendererKind: Equatable, Hashable { + + case block + case pdf + case image + case workbook + case slide + case epub + case unknown + + + + + +} + +#if compiler(>=6) +extension RendererKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeRendererKind: FfiConverterRustBuffer { + typealias SwiftType = RendererKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> RendererKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .block + + case 2: return .pdf + + case 3: return .image + + case 4: return .workbook + + case 5: return .slide + + case 6: return .epub + + case 7: return .unknown + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: RendererKind, into buf: inout [UInt8]) { + switch value { + + + case .block: + writeInt(&buf, Int32(1)) + + + case .pdf: + writeInt(&buf, Int32(2)) + + + case .image: + writeInt(&buf, Int32(3)) + + + case .workbook: + writeInt(&buf, Int32(4)) + + + case .slide: + writeInt(&buf, Int32(5)) + + + case .epub: + writeInt(&buf, Int32(6)) + + + case .unknown: + writeInt(&buf, Int32(7)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRendererKind_lift(_ buf: RustBuffer) throws -> RendererKind { + return try FfiConverterTypeRendererKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeRendererKind_lower(_ value: RendererKind) -> RustBuffer { + return FfiConverterTypeRendererKind.lower(value) +} + + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2429,6 +2918,78 @@ fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer { + typealias SwiftType = Int64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionFloat: FfiConverterRustBuffer { + typealias SwiftType = Float? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterFloat.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterFloat.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2453,6 +3014,54 @@ fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeReadingCheckpoint: FfiConverterRustBuffer { + typealias SwiftType = ReadingCheckpoint? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeReadingCheckpoint.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeReadingCheckpoint.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeReadingSessionV2: FfiConverterRustBuffer { + typealias SwiftType = ReadingSessionV2? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeReadingSessionV2.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeReadingSessionV2.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2708,6 +3317,16 @@ public func ackEventsV2(eventIds: [String]) -> UInt32 { ) }) } +public func buildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_zx_document_ffi_fn_func_build_representation_fingerprint( + FfiConverterString.lower(sourceFingerprint), + FfiConverterTypeRendererKind_lower(rendererKind), + FfiConverterString.lower(parserVersion), + FfiConverterUInt32.lower(schemaVersion),$0 + ) +}) +} public func cleanupStaleSessionsFfi(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return try! FfiConverterUInt32.lift(try! rustCall() { uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi( @@ -2722,12 +3341,20 @@ public func clearExportedEvents(count: UInt32) {try! rustCall() { ) } } -public func closeReadingSessionV2(sessionId: String)throws {try rustCallWithError(FfiConverterTypeDocumentError_lift) { +public func closeReadingSessionV2(sessionId: String, timestampMs: Int64)throws {try rustCallWithError(FfiConverterTypeDocumentError_lift) { uniffi_zx_document_ffi_fn_func_close_reading_session_v2( - FfiConverterString.lower(sessionId),$0 + FfiConverterString.lower(sessionId), + FfiConverterInt64.lower(timestampMs),$0 ) } } +public func computeFileFingerprint(filePath: String)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_compute_file_fingerprint( + FfiConverterString.lower(filePath),$0 + ) +}) +} public func createNoteAnchor(materialId: String, position: ReadingPosition?) -> NoteAnchor { return try! FfiConverterTypeNoteAnchor_lift(try! rustCall() { uniffi_zx_document_ffi_fn_func_create_note_anchor( @@ -2772,6 +3399,19 @@ public func extractPdfTextFfi(filePath: String)throws -> [PdfPageText] { ) }) } +public func getActiveSessionV2(materialId: String) -> ReadingSessionV2? { + return try! FfiConverterOptionTypeReadingSessionV2.lift(try! rustCall() { + uniffi_zx_document_ffi_fn_func_get_active_session_v2( + FfiConverterString.lower(materialId),$0 + ) +}) +} +public func getEventBufferStateV2() -> EventBufferStateV2 { + return try! FfiConverterTypeEventBufferStateV2_lift(try! rustCall() { + uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2($0 + ) +}) +} public func getOfficePreviewConfigFfi(materialType: MaterialType, fileSize: UInt64)throws -> OfficePreviewConfig { return try FfiConverterTypeOfficePreviewConfig_lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi( @@ -2780,6 +3420,13 @@ public func getOfficePreviewConfigFfi(materialType: MaterialType, fileSize: UInt ) }) } +public func getSessionV2Ffi(sessionId: String) -> ReadingSessionV2? { + return try! FfiConverterOptionTypeReadingSessionV2.lift(try! rustCall() { + uniffi_zx_document_ffi_fn_func_get_session_v2_ffi( + FfiConverterString.lower(sessionId),$0 + ) +}) +} public func isOfficeTypeFfi(materialType: MaterialType) -> Bool { return try! FfiConverterBool.lift(try! rustCall() { uniffi_zx_document_ffi_fn_func_is_office_type_ffi( @@ -2794,6 +3441,18 @@ public func markEventsFailedV2(eventIds: [String]) -> UInt32 { ) }) } +public func markSessionFailedV2(sessionId: String)throws {try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_mark_session_failed_v2( + FfiConverterString.lower(sessionId),$0 + ) +} +} +public func markSessionInterruptedV2(sessionId: String)throws {try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_mark_session_interrupted_v2( + FfiConverterString.lower(sessionId),$0 + ) +} +} public func parseMarkdown(content: String)throws -> [DocumentBlock] { return try FfiConverterSequenceTypeDocumentBlock.lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { uniffi_zx_document_ffi_fn_func_parse_markdown( @@ -2814,6 +3473,27 @@ public func pauseReadingSessionV2(sessionId: String)throws {try rustCallWithEr ) } } +public func pushCheckpointChangedV2(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64)throws -> ReadingEventV2 { + return try FfiConverterTypeReadingEventV2_lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_push_checkpoint_changed_v2( + FfiConverterString.lower(sessionId), + FfiConverterString.lower(materialId), + FfiConverterTypeReadingCheckpoint_lower(checkpoint), + FfiConverterInt64.lower(timestampMs),$0 + ) +}) +} +public func pushHeartbeatCheckpointV2(sessionId: String, materialId: String, activeSecondsDelta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64)throws -> ReadingEventV2 { + return try FfiConverterTypeReadingEventV2_lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_push_heartbeat_checkpoint_v2( + FfiConverterString.lower(sessionId), + FfiConverterString.lower(materialId), + FfiConverterUInt32.lower(activeSecondsDelta), + FfiConverterOptionTypeReadingCheckpoint.lower(checkpoint), + FfiConverterInt64.lower(timestampMs),$0 + ) +}) +} public func pushHeartbeatV2(sessionId: String, materialId: String, activeSecondsDelta: UInt32, position: ReadingPosition?, timestampMs: Int64)throws -> ReadingEventV2 { return try FfiConverterTypeReadingEventV2_lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { uniffi_zx_document_ffi_fn_func_push_heartbeat_v2( @@ -2957,10 +3637,17 @@ public func searchTextContent(content: String, query: String) -> [SearchResult] ) }) } -public func startReadingSessionV2(material: ReadingMaterialRef, timestampMs: Int64)throws -> String { +public func setSessionClosedAtMsV2(sessionId: String, timestampMs: Int64)throws {try rustCallWithError(FfiConverterTypeDocumentError_lift) { + uniffi_zx_document_ffi_fn_func_set_session_closed_at_ms_v2( + FfiConverterString.lower(sessionId), + FfiConverterInt64.lower(timestampMs),$0 + ) +} +} +public func startReadingSessionV2(material: ReadingMaterialRefV2, timestampMs: Int64)throws -> String { return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeDocumentError_lift) { uniffi_zx_document_ffi_fn_func_start_reading_session_v2( - FfiConverterTypeReadingMaterialRef_lower(material), + FfiConverterTypeReadingMaterialRefV2_lower(material), FfiConverterInt64.lower(timestampMs),$0 ) }) diff --git a/bindings/ios/generated/zx_documentFFI.h b/bindings/ios/generated/zx_documentFFI.h index 04d1257..4073836 100644 --- a/bindings/ios/generated/zx_documentFFI.h +++ b/bindings/ios/generated/zx_documentFFI.h @@ -248,6 +248,11 @@ typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureRes uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT +RustBuffer uniffi_zx_document_ffi_fn_func_build_representation_fingerprint(RustBuffer source_fingerprint, RustBuffer renderer_kind, RustBuffer parser_version, uint32_t schema_version, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status @@ -260,7 +265,12 @@ void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCa #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2 #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2 -void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status +void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT +RustBuffer uniffi_zx_document_ffi_fn_func_compute_file_fingerprint(RustBuffer file_path, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR @@ -292,6 +302,17 @@ RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limi #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2 +RustBuffer uniffi_zx_document_ffi_fn_func_get_active_session_v2(RustBuffer material_id, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2 +RustBuffer uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI @@ -299,6 +320,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_p RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI +RustBuffer uniffi_zx_document_ffi_fn_func_get_session_v2_ffi(RustBuffer session_id, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status @@ -309,6 +335,16 @@ int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_typ uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2 +void uniffi_zx_document_ffi_fn_func_mark_session_failed_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2 +void uniffi_zx_document_ffi_fn_func_mark_session_interrupted_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status @@ -324,6 +360,16 @@ RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCal void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2 +RustBuffer uniffi_zx_document_ffi_fn_func_push_checkpoint_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2 +RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_checkpoint_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2 #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2 RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status @@ -415,6 +461,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbe RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2 +void uniffi_zx_document_ffi_fn_func_set_session_closed_at_ms_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2 #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2 RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status @@ -689,6 +740,12 @@ void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStat #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2 uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT +uint16_t uniffi_zx_document_ffi_checksum_func_build_representation_fingerprint(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI @@ -707,6 +764,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2 uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT +uint16_t uniffi_zx_document_ffi_checksum_func_compute_file_fingerprint(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR @@ -743,12 +806,30 @@ uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_get_active_session_v2(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_get_event_buffer_state_v2(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI +uint16_t uniffi_zx_document_ffi_checksum_func_get_session_v2_ffi(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI @@ -761,6 +842,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2 uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_failed_v2(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_interrupted_v2(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN @@ -779,6 +872,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2 uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_push_checkpoint_changed_v2(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_checkpoint_v2(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2 @@ -887,6 +992,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void #define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2 +#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2 +uint16_t uniffi_zx_document_ffi_checksum_func_set_session_closed_at_ms_v2(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2 diff --git a/bindings/ios/simulator/libzx_document_ffi.a b/bindings/ios/simulator/libzx_document_ffi.a new file mode 100644 index 0000000..e1166df Binary files /dev/null and b/bindings/ios/simulator/libzx_document_ffi.a differ diff --git a/bindings/ios/simulator/libzx_document_ffi_arm64.a b/bindings/ios/simulator/libzx_document_ffi_arm64.a new file mode 100644 index 0000000..8167a6f Binary files /dev/null and b/bindings/ios/simulator/libzx_document_ffi_arm64.a differ diff --git a/bindings/ios/simulator/libzx_document_ffi_x86_64.a b/bindings/ios/simulator/libzx_document_ffi_x86_64.a new file mode 100644 index 0000000..39e7f1a Binary files /dev/null and b/bindings/ios/simulator/libzx_document_ffi_x86_64.a differ diff --git a/crates/zx_document_core/Cargo.toml b/crates/zx_document_core/Cargo.toml index 921b231..bfe0131 100644 --- a/crates/zx_document_core/Cargo.toml +++ b/crates/zx_document_core/Cargo.toml @@ -13,6 +13,7 @@ uuid = { version = "1", features = ["v4"] } zip = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } uniffi = "0.31" +sha2 = "0.10" [build-dependencies] uniffi = { version = "0.31", features = ["build"] } diff --git a/crates/zx_document_core/src/anchors.rs b/crates/zx_document_core/src/anchors.rs index b336655..fa4471d 100644 --- a/crates/zx_document_core/src/anchors.rs +++ b/crates/zx_document_core/src/anchors.rs @@ -56,6 +56,13 @@ pub enum NoteAnchor { impl NoteAnchor { pub fn from_position(material_id: &str, position: Option<&ReadingPosition>) -> Self { match position { + Some(pos @ ReadingPosition::Block { block_id, .. }) => { + NoteAnchor::MarkdownBlock { + material_id: material_id.to_string(), + block_id: block_id.clone(), + position_snapshot: Some(pos.clone()), + } + } Some(pos @ ReadingPosition::Markdown { block_id, .. }) => { NoteAnchor::MarkdownBlock { material_id: material_id.to_string(), @@ -77,7 +84,17 @@ impl NoteAnchor { position_snapshot: Some(pos.clone()), } } - Some(pos @ ReadingPosition::Image { .. }) => NoteAnchor::Image { + Some(pos @ ReadingPosition::PdfViewport { page_number, .. }) => { + NoteAnchor::PdfPage { + material_id: material_id.to_string(), + page_number: *page_number, + position_snapshot: Some(pos.clone()), + } + } + Some(pos @ ReadingPosition::Image { .. }) + | Some(pos @ ReadingPosition::ImageViewport { .. }) + | Some(pos @ ReadingPosition::Slide { .. }) + | Some(pos @ ReadingPosition::Workbook { .. }) => NoteAnchor::Image { material_id: material_id.to_string(), position_snapshot: Some(pos.clone()), }, diff --git a/crates/zx_document_core/src/events_v2.rs b/crates/zx_document_core/src/events_v2.rs index 482af39..3bac48c 100644 --- a/crates/zx_document_core/src/events_v2.rs +++ b/crates/zx_document_core/src/events_v2.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; -use crate::progress::ReadingPosition; +use crate::progress::{ReadingCheckpoint, ReadingPosition}; use crate::session_v2; const MAX_BUFFER_SIZE: usize = 1000; @@ -50,6 +50,7 @@ pub struct ReadingEventV2 { pub material_id: String, pub event_type: ReadingEventTypeV2, pub position: Option, + pub checkpoint: Option, pub active_seconds_delta: u32, pub timestamp_ms: i64, pub sequence: u64, @@ -62,14 +63,16 @@ fn push_event( material_id: &str, event_type: ReadingEventTypeV2, position: Option, + checkpoint: Option, active_seconds_delta: u32, timestamp_ms: i64, ) -> Result { - let seq = session_v2::record_session_event_v2( + let seq = session_v2::record_session_event_with_checkpoint_v2( session_id, timestamp_ms, active_seconds_delta, position.clone(), + checkpoint.clone(), )?; let event = ReadingEventV2 { @@ -78,6 +81,7 @@ fn push_event( material_id: material_id.to_string(), event_type, position: position.map(|p| p.normalized()), + checkpoint: checkpoint.map(|c| c.normalized()), active_seconds_delta, timestamp_ms, sequence: seq, @@ -115,7 +119,7 @@ pub fn push_material_opened_v2( material_id: &str, timestamp_ms: i64, ) -> Result { - push_event(session_id, material_id, ReadingEventTypeV2::MaterialOpened, None, 0, timestamp_ms) + push_event(session_id, material_id, ReadingEventTypeV2::MaterialOpened, None, None, 0, timestamp_ms) } pub fn push_material_closed_v2( @@ -124,7 +128,7 @@ pub fn push_material_closed_v2( active_seconds_delta: u32, timestamp_ms: i64, ) -> Result { - push_event(session_id, material_id, ReadingEventTypeV2::MaterialClosed, None, active_seconds_delta, timestamp_ms) + push_event(session_id, material_id, ReadingEventTypeV2::MaterialClosed, None, None, active_seconds_delta, timestamp_ms) } pub fn push_position_changed_v2( @@ -133,7 +137,24 @@ pub fn push_position_changed_v2( position: ReadingPosition, timestamp_ms: i64, ) -> Result { - push_event(session_id, material_id, ReadingEventTypeV2::PositionChanged, Some(position), 0, timestamp_ms) + push_event(session_id, material_id, ReadingEventTypeV2::PositionChanged, Some(position), None, 0, timestamp_ms) +} + +pub fn push_checkpoint_changed_v2( + session_id: &str, + material_id: &str, + checkpoint: ReadingCheckpoint, + timestamp_ms: i64, +) -> Result { + push_event( + session_id, + material_id, + ReadingEventTypeV2::PositionChanged, + Some(checkpoint.legacy_position()), + Some(checkpoint), + 0, + timestamp_ms, + ) } pub fn push_heartbeat_v2( @@ -143,7 +164,26 @@ pub fn push_heartbeat_v2( position: Option, timestamp_ms: i64, ) -> Result { - push_event(session_id, material_id, ReadingEventTypeV2::Heartbeat, position, active_seconds_delta, timestamp_ms) + push_event(session_id, material_id, ReadingEventTypeV2::Heartbeat, position, None, active_seconds_delta, timestamp_ms) +} + +pub fn push_heartbeat_checkpoint_v2( + session_id: &str, + material_id: &str, + active_seconds_delta: u32, + checkpoint: Option, + timestamp_ms: i64, +) -> Result { + let legacy_position = checkpoint.as_ref().map(ReadingCheckpoint::legacy_position); + push_event( + session_id, + material_id, + ReadingEventTypeV2::Heartbeat, + legacy_position, + checkpoint, + active_seconds_delta, + timestamp_ms, + ) } pub fn push_marked_as_read_v2( @@ -151,7 +191,7 @@ pub fn push_marked_as_read_v2( material_id: &str, timestamp_ms: i64, ) -> Result { - push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, 0, timestamp_ms) + push_event(session_id, material_id, ReadingEventTypeV2::MarkedAsRead, None, None, 0, timestamp_ms) } // ── Buffer Management (V2 with ack) ── diff --git a/crates/zx_document_core/src/fingerprint.rs b/crates/zx_document_core/src/fingerprint.rs new file mode 100644 index 0000000..48afd7b --- /dev/null +++ b/crates/zx_document_core/src/fingerprint.rs @@ -0,0 +1,54 @@ +use sha2::{Digest, Sha256}; + +use crate::error::DocumentError; +use crate::progress::RendererKind; + +pub const BLOCK_PARSER_VERSION: &str = "block-parser-v1"; +pub const WORKBOOK_PARSER_VERSION: &str = "workbook-parser-v1"; +pub const SLIDE_PARSER_VERSION: &str = "slide-parser-v1"; +pub const PDF_LOCATOR_SCHEMA_VERSION: u32 = 1; +pub const IMAGE_LOCATOR_SCHEMA_VERSION: u32 = 1; + +pub fn compute_file_fingerprint(file_path: &str) -> Result { + let bytes = std::fs::read(file_path)?; + Ok(hex_sha256(&bytes)) +} + +pub fn build_representation_fingerprint( + source_fingerprint: &str, + renderer_kind: &RendererKind, + parser_version: &str, + schema_version: u32, +) -> String { + let payload = format!( + "{source_fingerprint}:{renderer_kind}:{parser_version}:schema-v{schema_version}" + ); + hex_sha256(payload.as_bytes()) +} + +fn hex_sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn representation_fingerprint_is_stable() { + let a = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2); + let b = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2); + assert_eq!(a, b); + } + + #[test] + fn representation_fingerprint_changes_on_inputs() { + let base = build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 2); + assert_ne!(base, build_representation_fingerprint("def", &RendererKind::Block, BLOCK_PARSER_VERSION, 2)); + assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Pdf, BLOCK_PARSER_VERSION, 2)); + assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Block, "block-parser-v2", 2)); + assert_ne!(base, build_representation_fingerprint("abc", &RendererKind::Block, BLOCK_PARSER_VERSION, 3)); + } +} diff --git a/crates/zx_document_core/src/lib.rs b/crates/zx_document_core/src/lib.rs index 7d016d7..8c533b9 100644 --- a/crates/zx_document_core/src/lib.rs +++ b/crates/zx_document_core/src/lib.rs @@ -7,6 +7,7 @@ pub mod epub; pub mod error; pub mod events; pub mod events_v2; +pub mod fingerprint; pub mod image_meta; pub mod markdown; pub mod material_type; @@ -16,5 +17,6 @@ pub mod progress; pub mod reading_material; pub mod search; pub mod session_v2; +pub mod stable_id; pub mod text; pub mod time_tracker; diff --git a/crates/zx_document_core/src/markdown.rs b/crates/zx_document_core/src/markdown.rs index 07f60d9..467560b 100644 --- a/crates/zx_document_core/src/markdown.rs +++ b/crates/zx_document_core/src/markdown.rs @@ -1,12 +1,19 @@ use comrak::nodes::{AstNode, NodeValue}; use comrak::{Arena, ComrakOptions}; -use uuid::Uuid; use crate::blocks::DocumentBlock; use crate::error::DocumentError; +use crate::stable_id::stable_content_id; -fn block_id() -> String { - Uuid::new_v4().to_string() +/// Generate a stable, deterministic block ID from the block's text content +/// and its zero-based position within the document. +/// +/// Uses djb2 — a simple, fixed-algorithm hash — so the same `(text, position)` +/// pair always produces the same hex string **across app launches / processes**. +/// This is critical for reading-position restore: the saved `lastPosition.blockId` +/// must match the id of the same block when the document is re-parsed later. +fn block_id(text: &str, position: usize) -> String { + stable_content_id("markdown", position, text) } fn gfm_options() -> ComrakOptions<'static> { @@ -20,12 +27,14 @@ fn gfm_options() -> ComrakOptions<'static> { /// Parse a Markdown string into a list of DocumentBlock. pub fn parse_markdown(md_content: &str) -> Result, DocumentError> { + let start = std::time::Instant::now(); let arena = Arena::new(); let options = gfm_options(); let root = comrak::parse_document(&arena, md_content, &options); let mut blocks = Vec::new(); collect_blocks(root, &mut blocks); + eprintln!("[Rust] parse_markdown — len={} block_count={} elapsed_ms={}", md_content.len(), blocks.len(), start.elapsed().as_millis()); Ok(blocks) } @@ -35,14 +44,17 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { let value = &data.value; let block = match value { - NodeValue::Heading(heading) => Some(DocumentBlock::Heading { - id: block_id(), - level: heading.level, - text: collect_text(child), - }), + NodeValue::Heading(heading) => { + let text = collect_text(child); + Some(DocumentBlock::Heading { + id: block_id(&text, blocks.len()), + level: heading.level, + text, + }) + } NodeValue::Paragraph => { // Check for inline image — extract as standalone Image block - if let Some(image_block) = extract_image_from_paragraph(child) { + if let Some(image_block) = extract_image_from_paragraph(child, blocks.len()) { Some(image_block) } else { let text = collect_text(child); @@ -50,7 +62,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::Paragraph { - id: block_id(), + id: block_id(&text, blocks.len()), text, }) } @@ -71,14 +83,14 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::List { - id: block_id(), + id: block_id(&items.join("\n"), blocks.len()), ordered: list.list_type == comrak::nodes::ListType::Ordered, items, }) } } NodeValue::CodeBlock(code) => Some(DocumentBlock::CodeBlock { - id: block_id(), + id: block_id(&code.literal, blocks.len()), language: if code.info.is_empty() { None } else { @@ -92,7 +104,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::Quote { - id: block_id(), + id: block_id(&text, blocks.len()), text, }) } @@ -124,7 +136,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::Table { - id: block_id(), + id: block_id(&headers.join("|"), blocks.len()), headers, rows, }) @@ -133,7 +145,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { NodeValue::Image(image) => { let alt_text = collect_text(child); Some(DocumentBlock::Image { - id: block_id(), + id: block_id(&image.url, blocks.len()), src: image.url.clone(), alt: if alt_text.is_empty() { None @@ -143,7 +155,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { }) } NodeValue::ThematicBreak => Some(DocumentBlock::HorizontalRule { - id: block_id(), + id: block_id("---", blocks.len()), }), // Recurse into containers: Document, Item, TableCell, TableRow, etc. NodeValue::HtmlBlock(block) => { @@ -152,7 +164,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::CodeBlock { - id: block_id(), + id: block_id(&html, blocks.len()), language: Some("html".into()), code: html, }) @@ -163,7 +175,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { None } else { Some(DocumentBlock::CodeBlock { - id: block_id(), + id: block_id(&content, blocks.len()), language: Some("yaml".into()), code: content.clone(), }) @@ -174,7 +186,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { if text.is_empty() { None } else { - Some(DocumentBlock::Paragraph { id: block_id(), text }) + Some(DocumentBlock::Paragraph { id: block_id(&text, blocks.len()), text }) } } NodeValue::Document @@ -198,7 +210,7 @@ fn collect_blocks<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { } /// Check if a paragraph contains only an image, and extract it. -fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>) -> Option { +fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>, position: usize) -> Option { let mut image_node: Option<&AstNode> = None; let mut has_other_content = false; @@ -222,7 +234,7 @@ fn extract_image_from_paragraph<'a>(node: &'a AstNode<'a>) -> Option(node: &'a AstNode<'a>, buf: &mut String) { mod tests { use super::*; + fn block_id_str(b: &DocumentBlock) -> &str { + match b { + DocumentBlock::Heading { id, .. } => id, + DocumentBlock::Paragraph { id, .. } => id, + DocumentBlock::List { id, .. } => id, + DocumentBlock::CodeBlock { id, .. } => id, + DocumentBlock::Quote { id, .. } => id, + DocumentBlock::Table { id, .. } => id, + DocumentBlock::Image { id, .. } => id, + DocumentBlock::HorizontalRule { id, .. } => id, + } + } + #[test] fn test_empty_markdown() { let blocks = parse_markdown("").unwrap(); @@ -516,6 +541,20 @@ mod tests { assert!(blocks.iter().any(|b| matches!(b, DocumentBlock::List { .. }))); } + #[test] + fn test_block_ids_are_deterministic_across_parses() { + let md = "# Title\n\nHello world.\n\n- item 1\n- item 2\n\n```rust\nfn main() {}\n```\n\n> Quote"; + let blocks1 = parse_markdown(md).unwrap(); + let blocks2 = parse_markdown(md).unwrap(); + + assert_eq!(blocks1.len(), blocks2.len()); + for (a, b) in blocks1.iter().zip(blocks2.iter()) { + let id_a = block_id_str(a); + let id_b = block_id_str(b); + assert_eq!(id_a, id_b, "block_id mismatch: {:?} vs {:?}", a, b); + } + } + #[test] fn test_all_eight_block_types() { let md = "# H\n\nPara.\n\n- item\n\n```rs\nlet x=1;\n```\n\n> Quote\n\n|A|B|\n|---|---|\n|1|2|\n\n![alt](x.png)\n\n---"; diff --git a/crates/zx_document_core/src/progress.rs b/crates/zx_document_core/src/progress.rs index d182c00..b2b4907 100644 --- a/crates/zx_document_core/src/progress.rs +++ b/crates/zx_document_core/src/progress.rs @@ -2,14 +2,52 @@ use serde::{Deserialize, Serialize, Serializer}; /// Clamp a progress value to 0..1. NaN→0, <0→0, >1→1. pub fn clamp_progress(v: f32) -> f32 { - if v.is_nan() || v < 0.0 { return 0.0; } - if v > 1.0 { return 1.0; } + if v.is_nan() || v < 0.0 { + return 0.0; + } + if v > 1.0 { + return 1.0; + } v } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum)] +#[serde(rename_all = "camelCase")] +pub enum RendererKind { + Block, + Pdf, + Image, + Workbook, + Slide, + Epub, + Unknown, +} + +impl std::fmt::Display for RendererKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Block => write!(f, "block"), + Self::Pdf => write!(f, "pdf"), + Self::Image => write!(f, "image"), + Self::Workbook => write!(f, "workbook"), + Self::Slide => write!(f, "slide"), + Self::Epub => write!(f, "epub"), + Self::Unknown => write!(f, "unknown"), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, uniffi::Enum)] #[serde(tag = "type")] pub enum ReadingPosition { + Block { + #[serde(rename = "blockId")] + block_id: String, + #[serde(rename = "blockProgress")] + block_progress: f32, + #[serde(rename = "overallProgress")] + overall_progress: f32, + }, Markdown { #[serde(rename = "blockId")] block_id: String, @@ -30,6 +68,18 @@ pub enum ReadingPosition { #[serde(rename = "overallProgress")] overall_progress: f32, }, + PdfViewport { + #[serde(rename = "pageNumber")] + page_number: u32, + #[serde(rename = "anchorX")] + anchor_x: f32, + #[serde(rename = "anchorY")] + anchor_y: f32, + #[serde(rename = "zoomScale")] + zoom_scale: f32, + #[serde(rename = "overallProgress")] + overall_progress: f32, + }, Image { #[serde(rename = "zoomScale")] zoom_scale: f32, @@ -38,6 +88,40 @@ pub enum ReadingPosition { #[serde(rename = "offsetY")] offset_y: f32, }, + ImageViewport { + #[serde(rename = "zoomScale")] + zoom_scale: f32, + #[serde(rename = "anchorX")] + anchor_x: f32, + #[serde(rename = "anchorY")] + anchor_y: f32, + }, + Workbook { + #[serde(rename = "sheetId")] + sheet_id: String, + #[serde(rename = "topRow")] + top_row: u32, + #[serde(rename = "leftColumn")] + left_column: u32, + #[serde(rename = "selectedCell")] + selected_cell: Option, + #[serde(rename = "zoomScale")] + zoom_scale: f32, + #[serde(rename = "overallProgress")] + overall_progress: Option, + }, + Slide { + #[serde(rename = "slideIndex")] + slide_index: u32, + #[serde(rename = "zoomScale")] + zoom_scale: f32, + #[serde(rename = "anchorX")] + anchor_x: f32, + #[serde(rename = "anchorY")] + anchor_y: f32, + #[serde(rename = "overallProgress")] + overall_progress: f32, + }, Epub { #[serde(rename = "chapterId")] chapter_id: String, @@ -49,47 +133,127 @@ pub enum ReadingPosition { Unknown, } -// Manual Serialize to enforce camelCase field names + clamped progress values. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, uniffi::Record)] +#[serde(rename_all = "camelCase")] +pub struct ReadingCheckpoint { + pub schema_version: u32, + pub material_id: String, + pub representation_fingerprint: String, + pub renderer_kind: RendererKind, + pub captured_at_ms: u64, + pub device_id: String, + pub device_sequence: u64, + pub position: ReadingPosition, +} + +impl ReadingCheckpoint { + pub fn normalized(&self) -> Self { + Self { + schema_version: self.schema_version, + material_id: self.material_id.clone(), + representation_fingerprint: self.representation_fingerprint.clone(), + renderer_kind: self.renderer_kind.clone(), + captured_at_ms: self.captured_at_ms, + device_id: self.device_id.clone(), + device_sequence: self.device_sequence, + position: self.position.normalized(), + } + } + + pub fn legacy_position(&self) -> ReadingPosition { + self.position.clone() + } +} + +// Manual Serialize to enforce camelCase field names + normalized values. impl Serialize for ReadingPosition { fn serialize(&self, s: S) -> Result { use serde::ser::SerializeStruct; - match self { + + match self.normalized() { + Self::Block { block_id, block_progress, overall_progress } => { + let mut st = s.serialize_struct("Block", 4)?; + st.serialize_field("type", "Block")?; + st.serialize_field("blockId", &block_id)?; + st.serialize_field("blockProgress", &block_progress)?; + st.serialize_field("overallProgress", &overall_progress)?; + st.end() + } Self::Markdown { block_id, scroll_progress } => { let mut st = s.serialize_struct("Markdown", 3)?; st.serialize_field("type", "Markdown")?; - st.serialize_field("blockId", block_id)?; - st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; + st.serialize_field("blockId", &block_id)?; + st.serialize_field("scrollProgress", &scroll_progress)?; st.end() } Self::Text { line_number, scroll_progress } => { let mut st = s.serialize_struct("Text", 3)?; st.serialize_field("type", "Text")?; - st.serialize_field("lineNumber", line_number)?; - st.serialize_field("scrollProgress", &clamp_progress(*scroll_progress))?; + st.serialize_field("lineNumber", &line_number)?; + st.serialize_field("scrollProgress", &scroll_progress)?; st.end() } Self::Pdf { page_number, page_progress, overall_progress } => { let mut st = s.serialize_struct("Pdf", 4)?; st.serialize_field("type", "Pdf")?; - st.serialize_field("pageNumber", page_number)?; - st.serialize_field("pageProgress", &clamp_progress(*page_progress))?; - st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; + st.serialize_field("pageNumber", &page_number)?; + st.serialize_field("pageProgress", &page_progress)?; + st.serialize_field("overallProgress", &overall_progress)?; + st.end() + } + Self::PdfViewport { page_number, anchor_x, anchor_y, zoom_scale, overall_progress } => { + let mut st = s.serialize_struct("PdfViewport", 6)?; + st.serialize_field("type", "PdfViewport")?; + st.serialize_field("pageNumber", &page_number)?; + st.serialize_field("anchorX", &anchor_x)?; + st.serialize_field("anchorY", &anchor_y)?; + st.serialize_field("zoomScale", &zoom_scale)?; + st.serialize_field("overallProgress", &overall_progress)?; st.end() } Self::Image { zoom_scale, offset_x, offset_y } => { let mut st = s.serialize_struct("Image", 4)?; st.serialize_field("type", "Image")?; - st.serialize_field("zoomScale", zoom_scale)?; - st.serialize_field("offsetX", offset_x)?; - st.serialize_field("offsetY", offset_y)?; + st.serialize_field("zoomScale", &zoom_scale)?; + st.serialize_field("offsetX", &offset_x)?; + st.serialize_field("offsetY", &offset_y)?; + st.end() + } + Self::ImageViewport { zoom_scale, anchor_x, anchor_y } => { + let mut st = s.serialize_struct("ImageViewport", 4)?; + st.serialize_field("type", "ImageViewport")?; + st.serialize_field("zoomScale", &zoom_scale)?; + st.serialize_field("anchorX", &anchor_x)?; + st.serialize_field("anchorY", &anchor_y)?; + st.end() + } + Self::Workbook { sheet_id, top_row, left_column, selected_cell, zoom_scale, overall_progress } => { + let mut st = s.serialize_struct("Workbook", 7)?; + st.serialize_field("type", "Workbook")?; + st.serialize_field("sheetId", &sheet_id)?; + st.serialize_field("topRow", &top_row)?; + st.serialize_field("leftColumn", &left_column)?; + st.serialize_field("selectedCell", &selected_cell)?; + st.serialize_field("zoomScale", &zoom_scale)?; + st.serialize_field("overallProgress", &overall_progress)?; + st.end() + } + Self::Slide { slide_index, zoom_scale, anchor_x, anchor_y, overall_progress } => { + let mut st = s.serialize_struct("Slide", 6)?; + st.serialize_field("type", "Slide")?; + st.serialize_field("slideIndex", &slide_index)?; + st.serialize_field("zoomScale", &zoom_scale)?; + st.serialize_field("anchorX", &anchor_x)?; + st.serialize_field("anchorY", &anchor_y)?; + st.serialize_field("overallProgress", &overall_progress)?; st.end() } Self::Epub { chapter_id, chapter_progress, overall_progress } => { let mut st = s.serialize_struct("Epub", 4)?; st.serialize_field("type", "Epub")?; - st.serialize_field("chapterId", chapter_id)?; - st.serialize_field("chapterProgress", &clamp_progress(*chapter_progress))?; - st.serialize_field("overallProgress", &clamp_progress(*overall_progress))?; + st.serialize_field("chapterId", &chapter_id)?; + st.serialize_field("chapterProgress", &chapter_progress)?; + st.serialize_field("overallProgress", &overall_progress)?; st.end() } Self::Unknown => { @@ -105,6 +269,11 @@ impl ReadingPosition { /// Return a normalized copy with all progress fields clamped to 0..1. pub fn normalized(&self) -> Self { match self { + Self::Block { block_id, block_progress, overall_progress } => Self::Block { + block_id: block_id.clone(), + block_progress: clamp_progress(*block_progress), + overall_progress: clamp_progress(*overall_progress), + }, Self::Markdown { block_id, scroll_progress } => Self::Markdown { block_id: block_id.clone(), scroll_progress: clamp_progress(*scroll_progress), @@ -118,8 +287,37 @@ impl ReadingPosition { page_progress: clamp_progress(*page_progress), overall_progress: clamp_progress(*overall_progress), }, + Self::PdfViewport { page_number, anchor_x, anchor_y, zoom_scale, overall_progress } => Self::PdfViewport { + page_number: *page_number, + anchor_x: clamp_progress(*anchor_x), + anchor_y: clamp_progress(*anchor_y), + zoom_scale: *zoom_scale, + overall_progress: clamp_progress(*overall_progress), + }, Self::Image { zoom_scale, offset_x, offset_y } => Self::Image { - zoom_scale: *zoom_scale, offset_x: *offset_x, offset_y: *offset_y, + zoom_scale: *zoom_scale, + offset_x: *offset_x, + offset_y: *offset_y, + }, + Self::ImageViewport { zoom_scale, anchor_x, anchor_y } => Self::ImageViewport { + zoom_scale: *zoom_scale, + anchor_x: clamp_progress(*anchor_x), + anchor_y: clamp_progress(*anchor_y), + }, + Self::Workbook { sheet_id, top_row, left_column, selected_cell, zoom_scale, overall_progress } => Self::Workbook { + sheet_id: sheet_id.clone(), + top_row: *top_row, + left_column: *left_column, + selected_cell: selected_cell.clone(), + zoom_scale: *zoom_scale, + overall_progress: overall_progress.map(clamp_progress), + }, + Self::Slide { slide_index, zoom_scale, anchor_x, anchor_y, overall_progress } => Self::Slide { + slide_index: *slide_index, + zoom_scale: *zoom_scale, + anchor_x: clamp_progress(*anchor_x), + anchor_y: clamp_progress(*anchor_y), + overall_progress: clamp_progress(*overall_progress), }, Self::Epub { chapter_id, chapter_progress, overall_progress } => Self::Epub { chapter_id: chapter_id.clone(), @@ -135,10 +333,17 @@ impl ReadingPosition { use crate::material_type::MaterialType; matches!( (self, mt), - (ReadingPosition::Markdown { .. }, MaterialType::Markdown) + (ReadingPosition::Block { .. }, MaterialType::Markdown) + | (ReadingPosition::Block { .. }, MaterialType::Text) + | (ReadingPosition::Block { .. }, MaterialType::Word) + | (ReadingPosition::Markdown { .. }, MaterialType::Markdown) | (ReadingPosition::Text { .. }, MaterialType::Text) | (ReadingPosition::Pdf { .. }, MaterialType::Pdf) + | (ReadingPosition::PdfViewport { .. }, MaterialType::Pdf) | (ReadingPosition::Image { .. }, MaterialType::Image) + | (ReadingPosition::ImageViewport { .. }, MaterialType::Image) + | (ReadingPosition::Workbook { .. }, MaterialType::Excel) + | (ReadingPosition::Slide { .. }, MaterialType::PowerPoint) | (ReadingPosition::Epub { .. }, MaterialType::Epub) | (ReadingPosition::Unknown, _) ) @@ -146,11 +351,15 @@ impl ReadingPosition { pub fn progress_value(&self) -> Option { match self { + Self::Block { overall_progress, .. } => Some(clamp_progress(*overall_progress)), Self::Markdown { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), Self::Text { scroll_progress, .. } => Some(clamp_progress(*scroll_progress)), Self::Pdf { overall_progress, .. } => Some(clamp_progress(*overall_progress)), + Self::PdfViewport { overall_progress, .. } => Some(clamp_progress(*overall_progress)), + Self::Workbook { overall_progress, .. } => overall_progress.map(clamp_progress), + Self::Slide { overall_progress, .. } => Some(clamp_progress(*overall_progress)), Self::Epub { overall_progress, .. } => Some(clamp_progress(*overall_progress)), - Self::Image { .. } | Self::Unknown => None, + Self::Image { .. } | Self::ImageViewport { .. } | Self::Unknown => None, } } } @@ -160,154 +369,89 @@ mod tests { use super::*; #[test] - fn test_markdown_camel_case() { + fn test_block_roundtrip() { + let pos = ReadingPosition::Block { + block_id: "b1".into(), + block_progress: 0.2, + overall_progress: 0.4, + }; + let json = serde_json::to_string(&pos).unwrap(); + assert!(json.contains("\"type\":\"Block\"")); + assert!(json.contains("\"blockId\":\"b1\"")); + let back: ReadingPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(back, pos); + } + + #[test] + fn test_pdf_viewport_clamps_anchor_and_progress() { + let pos = ReadingPosition::PdfViewport { + page_number: 1, + anchor_x: 2.0, + anchor_y: -1.0, + zoom_scale: 1.5, + overall_progress: f32::NAN, + }; + let normalized = pos.normalized(); + assert_eq!( + normalized, + ReadingPosition::PdfViewport { + page_number: 1, + anchor_x: 1.0, + anchor_y: 0.0, + zoom_scale: 1.5, + overall_progress: 0.0, + } + ); + } + + #[test] + fn test_legacy_markdown_camel_case() { let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }; let json = serde_json::to_string(&pos).unwrap(); - eprintln!("SERIALIZED: {json}"); - assert!(json.contains("\"type\":\"Markdown\""), "missing type: {json}"); - assert!(json.contains("\"blockId\":\"h1\""), "missing blockId: {json}"); - assert!(json.contains("\"scrollProgress\":0.5"), "missing progress: {json}"); + assert!(json.contains("\"blockId\":\"h1\"")); + assert!(json.contains("\"scrollProgress\":0.5")); } #[test] - fn test_pdf_camel_case() { - let pos = ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }; - let json = serde_json::to_string(&pos).unwrap(); - assert!(json.contains("\"pageNumber\":7")); - assert!(json.contains("\"pageProgress\":0.8")); - assert!(json.contains("\"overallProgress\":0.35")); + fn test_checkpoint_roundtrip() { + let checkpoint = ReadingCheckpoint { + schema_version: 2, + material_id: "mat-1".into(), + representation_fingerprint: "fp-1".into(), + renderer_kind: RendererKind::Block, + captured_at_ms: 100, + device_id: "device-1".into(), + device_sequence: 7, + position: ReadingPosition::Block { + block_id: "b1".into(), + block_progress: 0.25, + overall_progress: 0.5, + }, + }; + let json = serde_json::to_string(&checkpoint).unwrap(); + assert!(json.contains("\"rendererKind\":\"block\"")); + let back: ReadingCheckpoint = serde_json::from_str(&json).unwrap(); + assert_eq!(back, checkpoint); } #[test] - fn test_clamp_nan() { - let pos = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: f32::NAN }; - let json = serde_json::to_string(&pos).unwrap(); - assert!(json.contains("\"scrollProgress\":0.0")); - } - - #[test] - fn test_clamp_negative() { - let pos = ReadingPosition::Text { line_number: 1, scroll_progress: -0.5 }; - let json = serde_json::to_string(&pos).unwrap(); - assert!(json.contains("\"scrollProgress\":0.0")); - } - - #[test] - fn test_clamp_above_one() { - let pos = ReadingPosition::Pdf { page_number: 1, page_progress: 2.5, overall_progress: 10.0 }; - let json = serde_json::to_string(&pos).unwrap(); - assert!(json.contains("\"pageProgress\":1.0")); - assert!(json.contains("\"overallProgress\":1.0")); - } - - #[test] - fn test_is_compatible() { - use crate::material_type::MaterialType; - let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }; - assert!(md.is_compatible_with(&MaterialType::Markdown)); - assert!(!md.is_compatible_with(&MaterialType::Pdf)); - assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Pdf)); - assert!(ReadingPosition::Unknown.is_compatible_with(&MaterialType::Markdown)); // Unknown = compatible with all - } - - #[test] - fn test_progress_value() { - assert_eq!(ReadingPosition::Unknown.progress_value(), None); - let md = ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.75 }; - assert_eq!(md.progress_value(), Some(0.75)); - } - - #[test] - fn test_roundtrip() { - let positions = vec![ - ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 }, - ReadingPosition::Pdf { page_number: 7, page_progress: 0.8, overall_progress: 0.35 }, - ReadingPosition::Unknown, - ]; - for pos in &positions { - let json = serde_json::to_string(pos).unwrap(); - eprintln!("ROUNDTRIP JSON: {json}"); - let back: ReadingPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(&back, pos); - } - } - - #[test] - fn test_deserialize_from_camel_case_json() { - // Simulate JSON from iOS app / API server: all camelCase field names - let json = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); + fn test_deserialize_legacy_and_new_json() { + let md_json = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#; + let md: ReadingPosition = serde_json::from_str(md_json).unwrap(); assert_eq!( - pos, + md, ReadingPosition::Markdown { block_id: "h1".into(), scroll_progress: 0.5 } ); - let json = r#"{"type":"Text","lineNumber":5,"scrollProgress":0.3}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); + let block_json = r#"{"type":"Block","blockId":"b1","blockProgress":0.3,"overallProgress":0.6}"#; + let block: ReadingPosition = serde_json::from_str(block_json).unwrap(); assert_eq!( - pos, - ReadingPosition::Text { line_number: 5, scroll_progress: 0.3 } + block, + ReadingPosition::Block { + block_id: "b1".into(), + block_progress: 0.3, + overall_progress: 0.6, + } ); - - let json = r#"{"type":"Pdf","pageNumber":3,"pageProgress":0.8,"overallProgress":0.35}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); - assert_eq!( - pos, - ReadingPosition::Pdf { page_number: 3, page_progress: 0.8, overall_progress: 0.35 } - ); - - let json = r#"{"type":"Image","zoomScale":1.5,"offsetX":10.0,"offsetY":20.0}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); - assert_eq!( - pos, - ReadingPosition::Image { zoom_scale: 1.5, offset_x: 10.0, offset_y: 20.0 } - ); - - let json = r#"{"type":"Epub","chapterId":"ch3","chapterProgress":0.6,"overallProgress":0.3}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); - assert_eq!( - pos, - ReadingPosition::Epub { chapter_id: "ch3".into(), chapter_progress: 0.6, overall_progress: 0.3 } - ); - - let json = r#"{"type":"Unknown"}"#; - let pos: ReadingPosition = serde_json::from_str(json).unwrap(); - assert_eq!(pos, ReadingPosition::Unknown); - } - - #[test] - fn test_deserialize_serialize_roundtrip_all_variants() { - // Deserialize from camelCase → re-serialize → deserialize again → verify identical - let positions = vec![ - r#"{"type":"Markdown","blockId":"intro","scrollProgress":0.25}"#, - r#"{"type":"Text","lineNumber":42,"scrollProgress":0.5}"#, - r#"{"type":"Pdf","pageNumber":7,"pageProgress":0.8,"overallProgress":0.35}"#, - r#"{"type":"Image","zoomScale":2.0,"offsetX":0.0,"offsetY":100.0}"#, - r#"{"type":"Epub","chapterId":"ch1","chapterProgress":0.6,"overallProgress":0.3}"#, - r#"{"type":"Unknown"}"#, - ]; - for json_str in &positions { - let pos1: ReadingPosition = serde_json::from_str(json_str).unwrap(); - let re_serialized = serde_json::to_string(&pos1).unwrap(); - let pos2: ReadingPosition = serde_json::from_str(&re_serialized).unwrap(); - assert_eq!(pos1, pos2, "roundtrip failed for: {json_str}"); - } - } - - #[test] - fn test_rename_attrs_not_overridden_by_uniffi() { - // Verify #[serde(rename)] works correctly alongside #[derive(uniffi::Enum)]: - // serde uses renamed camelCase names, not Rust snake_case names. - // If uniffi overrode the renames, "blockId" would NOT be recognized. - - // 1. snake_case should FAIL (field is renamed) - let json_snake = r#"{"type":"Markdown","block_id":"h1","scroll_progress":0.5}"#; - let result: Result = serde_json::from_str(json_snake); - assert!(result.is_err(), "snake_case should fail: rename=blockId means block_id is unknown"); - - // 2. camelCase should SUCCEED - let json_camel = r#"{"type":"Markdown","blockId":"h1","scrollProgress":0.5}"#; - let result: Result = serde_json::from_str(json_camel); - assert!(result.is_ok(), "camelCase should succeed: rename=blockId is the expected name"); } } diff --git a/crates/zx_document_core/src/session_v2.rs b/crates/zx_document_core/src/session_v2.rs index 46d854e..43bccae 100644 --- a/crates/zx_document_core/src/session_v2.rs +++ b/crates/zx_document_core/src/session_v2.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Mutex; -use crate::progress::ReadingPosition; +use crate::progress::{ReadingCheckpoint, ReadingPosition}; use crate::reading_material::ReadingMaterialRefV2; use crate::time_tracker::ActiveTimeTracker; @@ -36,6 +36,7 @@ pub struct ReadingSessionV2 { pub next_sequence: u64, pub total_active_seconds: u32, pub last_position: Option, + pub last_checkpoint: Option, pub status: ReadingSessionStatus, } @@ -58,6 +59,7 @@ pub fn start_reading_session_v2( next_sequence: 1, total_active_seconds: 0, last_position: None, + last_checkpoint: None, status: ReadingSessionStatus::Active, }; @@ -168,13 +170,23 @@ pub fn get_active_session_v2(material_id: &str) -> Option { } } -/// Record an event on this session: bumps sequence and updates last_event_at_ms. -/// Returns the sequence number that should be assigned to the event. pub fn record_session_event_v2( session_id: &str, timestamp_ms: i64, active_seconds_delta: u32, position: Option, +) -> Result { + record_session_event_with_checkpoint_v2(session_id, timestamp_ms, active_seconds_delta, position, None) +} + +/// Record an event on this session: bumps sequence and updates last_event_at_ms. +/// Returns the sequence number that should be assigned to the event. +pub fn record_session_event_with_checkpoint_v2( + session_id: &str, + timestamp_ms: i64, + active_seconds_delta: u32, + position: Option, + checkpoint: Option, ) -> Result { let seq = with_session_mut(session_id, |s| { if s.status == ReadingSessionStatus::Closed { @@ -186,8 +198,12 @@ pub fn record_session_event_v2( let seq = s.next_sequence; s.next_sequence += 1; s.last_event_at_ms = timestamp_ms; - if position.is_some() { - s.last_position = position; + if let Some(position) = position { + s.last_position = Some(position); + } + if let Some(checkpoint) = checkpoint { + s.last_position = Some(checkpoint.legacy_position()); + s.last_checkpoint = Some(checkpoint); } Ok(seq) })?; @@ -318,11 +334,26 @@ impl std::fmt::Display for SessionError { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, MutexGuard, OnceLock}; fn test_material() -> ReadingMaterialRefV2 { ReadingMaterialRefV2::new("test_mat_001", "/tmp/test.md", "test.md", "markdown") } + fn test_guard() -> MutexGuard<'static, ()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn reset_state() { + if let Ok(mut map) = sessions().lock() { + map.clear(); + } + if let Ok(mut tmap) = trackers().lock() { + tmap.clear(); + } + } + fn teardown(id: &str) { let _ = close_reading_session_v2(id, 0); let _ = remove_session_v2(id); @@ -330,6 +361,8 @@ mod tests { #[test] fn test_start_session() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); assert!(!id.is_empty()); let s = get_session_v2(&id).unwrap(); @@ -341,6 +374,8 @@ mod tests { #[test] fn test_pause_resume_close() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); pause_reading_session_v2(&id).unwrap(); assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Paused); @@ -353,6 +388,8 @@ mod tests { #[test] fn test_remove_refuses_active() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); assert!(remove_session_v2(&id).is_err()); // Not closed teardown(&id); @@ -360,6 +397,8 @@ mod tests { #[test] fn test_sequence_increments() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap(); // tracker not used for 0 delta let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap(); @@ -370,6 +409,8 @@ mod tests { #[test] fn test_closed_rejects_events() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); close_reading_session_v2(&id, 0).unwrap(); assert!(record_session_event_v2(&id, 2000, 10, None).is_err()); @@ -379,6 +420,8 @@ mod tests { #[test] fn test_paused_rejects_active_seconds() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); pause_reading_session_v2(&id).unwrap(); // PositionChanged (delta=0) should be allowed when paused @@ -391,12 +434,16 @@ mod tests { #[test] fn test_not_found() { + let _guard = test_guard(); + reset_state(); assert!(get_session_v2("nonexistent").is_err()); assert!(close_reading_session_v2("nonexistent", 0).is_err()); } #[test] fn test_two_sessions_independent() { + let _guard = test_guard(); + reset_state(); let a = start_reading_session_v2(test_material(), 1000).unwrap(); let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 2000).unwrap(); assert_ne!(a, b); @@ -408,6 +455,8 @@ mod tests { #[test] fn test_total_active_seconds_accumulates() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 0).unwrap(); // Simulate iOS calling tick() and passing deltas to session events record_session_event_v2(&id, 15_000, 15, None).unwrap(); @@ -420,6 +469,8 @@ mod tests { #[test] fn test_sequence_and_seconds_independent() { + let _guard = test_guard(); + reset_state(); let a = start_reading_session_v2(test_material(), 0).unwrap(); let b = start_reading_session_v2(ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"), 0).unwrap(); record_session_event_v2(&a, 10_000, 10, None).unwrap(); @@ -435,6 +486,8 @@ mod tests { #[test] fn test_last_position_updated() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 0).unwrap(); let pos1 = ReadingPosition::Markdown { block_id: "intro".into(), scroll_progress: 0.3 }; let pos2 = ReadingPosition::Markdown { block_id: "ch1".into(), scroll_progress: 0.8 }; @@ -446,6 +499,8 @@ mod tests { #[test] fn test_cleanup_stale_removes_old_sessions() { + let _guard = test_guard(); + reset_state(); // Old session: last event at t=1000 let old = start_reading_session_v2(test_material(), 0).unwrap(); record_session_event_v2(&old, 1000, 0, None).unwrap(); @@ -468,6 +523,8 @@ mod tests { #[test] fn test_cleanup_stale_removes_orphaned_active_session() { + let _guard = test_guard(); + reset_state(); // Simulate a crash: active session not closed, last event at t=5000 let orphaned = start_reading_session_v2(test_material(), 0).unwrap(); record_session_event_v2(&orphaned, 5000, 10, None).unwrap(); @@ -482,6 +539,8 @@ mod tests { #[test] fn test_cleanup_stale_zero_when_all_recent() { + let _guard = test_guard(); + reset_state(); let active = start_reading_session_v2(test_material(), 0).unwrap(); // Last event at t=9500, now at t=10000, max_age=1000 // 10000-9500=500 < 1000 → NOT stale @@ -498,6 +557,8 @@ mod tests { #[test] fn test_mark_interrupted() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); mark_session_interrupted_v2(&id).unwrap(); assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Interrupted); @@ -508,6 +569,8 @@ mod tests { #[test] fn test_mark_failed() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); mark_session_failed_v2(&id).unwrap(); assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Failed); @@ -518,6 +581,8 @@ mod tests { #[test] fn test_closed_rejects_interrupted() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); close_reading_session_v2(&id, 0).unwrap(); assert!(mark_session_interrupted_v2(&id).is_err()); @@ -527,6 +592,8 @@ mod tests { #[test] fn test_close_sets_closed_at_ms() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); assert!(get_session_v2(&id).unwrap().closed_at_ms.is_none()); close_reading_session_v2(&id, 5000).unwrap(); @@ -536,6 +603,8 @@ mod tests { #[test] fn test_set_closed_at_ms_override() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); close_reading_session_v2(&id, 5000).unwrap(); // set_session_closed_at_ms can override if needed @@ -546,6 +615,8 @@ mod tests { #[test] fn test_get_active_session_by_material() { + let _guard = test_guard(); + reset_state(); let mat_a = ReadingMaterialRefV2::new("mat_a", "/tmp/a.md", "a.md", "markdown"); let mat_b = ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"); @@ -565,6 +636,8 @@ mod tests { #[test] fn test_session_does_not_store_business_fields() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); let s = get_session_v2(&id).unwrap(); // Verify the material ref has no business fields @@ -576,6 +649,8 @@ mod tests { #[test] fn test_sequence_monotonic_in_session() { + let _guard = test_guard(); + reset_state(); let id = start_reading_session_v2(test_material(), 1000).unwrap(); let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap(); let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap(); diff --git a/crates/zx_document_core/src/stable_id.rs b/crates/zx_document_core/src/stable_id.rs new file mode 100644 index 0000000..72b8838 --- /dev/null +++ b/crates/zx_document_core/src/stable_id.rs @@ -0,0 +1,32 @@ +pub fn stable_content_id(namespace: &str, index: usize, content: &str) -> String { + let mut hash: u64 = 5381; + + for byte in namespace + .bytes() + .chain(index.to_string().bytes()) + .chain(content.bytes()) + { + hash = ((hash << 5).wrapping_add(hash)).wrapping_add(byte as u64); + } + + format!("{namespace}_{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::stable_content_id; + + #[test] + fn stable_content_id_is_deterministic() { + let a = stable_content_id("text", 3, "hello"); + let b = stable_content_id("text", 3, "hello"); + assert_eq!(a, b); + } + + #[test] + fn stable_content_id_changes_with_namespace_and_index() { + let base = stable_content_id("text", 3, "hello"); + assert_ne!(base, stable_content_id("markdown", 3, "hello")); + assert_ne!(base, stable_content_id("text", 4, "hello")); + } +} diff --git a/crates/zx_document_core/src/text.rs b/crates/zx_document_core/src/text.rs index d72892f..5f31b06 100644 --- a/crates/zx_document_core/src/text.rs +++ b/crates/zx_document_core/src/text.rs @@ -1,6 +1,6 @@ use crate::blocks::DocumentBlock; use crate::error::DocumentError; -use uuid::Uuid; +use crate::stable_id::stable_content_id; /// Read a TXT file and return its content as paragraph blocks. pub fn read_text(file_path: &str) -> Result, DocumentError> { @@ -18,11 +18,12 @@ pub fn parse_text_content(content: &str) -> Vec { continue; } blocks.push(DocumentBlock::Paragraph { - id: Uuid::new_v4().to_string(), + id: stable_content_id("text", blocks.len(), &text), text, }); } + eprintln!("[Rust] parse_text — len={} block_count={}", content.len(), blocks.len()); blocks } @@ -91,4 +92,11 @@ mod tests { let result = read_text("/nonexistent/file.txt"); assert!(result.is_err()); } + + #[test] + fn test_ids_are_stable() { + let first = parse_text_content("Hello\n\nWorld"); + let second = parse_text_content("Hello\n\nWorld"); + assert_eq!(first, second); + } } diff --git a/crates/zx_document_ffi/src/lib.rs b/crates/zx_document_ffi/src/lib.rs index 5e4c9e4..5e4018e 100644 --- a/crates/zx_document_ffi/src/lib.rs +++ b/crates/zx_document_ffi/src/lib.rs @@ -10,7 +10,7 @@ pub use zx_document_core::image_meta::ImageMeta; pub use zx_document_core::text::TextStats; pub use zx_document_core::search::SearchResult; pub use zx_document_core::anchors::NoteAnchor; -pub use zx_document_core::progress::ReadingPosition; +pub use zx_document_core::progress::{ReadingCheckpoint, ReadingPosition, RendererKind}; pub use zx_document_core::events::ReadingEvent; #[allow(deprecated)] pub use zx_document_core::reading_material::ReadingMaterialRef; @@ -26,39 +26,42 @@ use zx_document_core::blocks as core_blocks; // ── V2 Reading Session FFI ── #[uniffi::export] -fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result { - zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms) - .map_err(|e| e.to_string()) +fn start_reading_session_v2(material: ReadingMaterialRefV2, timestamp_ms: i64) -> Result { + let r = zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms) + .map_err(Into::into); + match &r { Ok(sid) => eprintln!("[Rust] start_session OK — sid={}", sid), Err(e) => eprintln!("[Rust] start_session FAILED — {:?}", e) } + r } #[uniffi::export] -fn pause_reading_session_v2(session_id: String) -> Result<(), String> { - zx_document_core::session_v2::pause_reading_session_v2(&session_id).map_err(|e| e.to_string()) +fn pause_reading_session_v2(session_id: String) -> Result<(), DocumentError> { + zx_document_core::session_v2::pause_reading_session_v2(&session_id).map_err(Into::into) } #[uniffi::export] -fn resume_reading_session_v2(session_id: String) -> Result<(), String> { - zx_document_core::session_v2::resume_reading_session_v2(&session_id).map_err(|e| e.to_string()) +fn resume_reading_session_v2(session_id: String) -> Result<(), DocumentError> { + zx_document_core::session_v2::resume_reading_session_v2(&session_id).map_err(Into::into) } #[uniffi::export] -fn close_reading_session_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> { - zx_document_core::session_v2::close_reading_session_v2(&session_id, timestamp_ms).map_err(|e| e.to_string()) +fn close_reading_session_v2(session_id: String, timestamp_ms: i64) -> Result<(), DocumentError> { + eprintln!("[Rust] close_session — sid={}", session_id); + zx_document_core::session_v2::close_reading_session_v2(&session_id, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> { - zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(|e| e.to_string()) +fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), DocumentError> { + zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn mark_session_interrupted_v2(session_id: String) -> Result<(), String> { - zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(|e| e.to_string()) +fn mark_session_interrupted_v2(session_id: String) -> Result<(), DocumentError> { + zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(Into::into) } #[uniffi::export] -fn mark_session_failed_v2(session_id: String) -> Result<(), String> { - zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(|e| e.to_string()) +fn mark_session_failed_v2(session_id: String) -> Result<(), DocumentError> { + zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(Into::into) } #[uniffi::export] @@ -74,45 +77,59 @@ fn get_session_v2_ffi(session_id: String) -> Option { // ── V2 Reading Event FFI ── #[uniffi::export] -fn push_material_opened_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { - zx_document_core::events_v2::push_material_opened_v2(&session_id, &material_id, timestamp_ms) - .map_err(|e| e.to_string()) +fn push_material_opened_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { + eprintln!("[Rust] push_material_opened — mid={}", material_id); + zx_document_core::events_v2::push_material_opened_v2(&session_id, &material_id, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn push_material_closed_v2(session_id: String, material_id: String, active_seconds_delta: u32, timestamp_ms: i64) -> Result { - zx_document_core::events_v2::push_material_closed_v2(&session_id, &material_id, active_seconds_delta, timestamp_ms) - .map_err(|e| e.to_string()) +fn push_material_closed_v2(session_id: String, material_id: String, active_seconds_delta: u32, timestamp_ms: i64) -> Result { + eprintln!("[Rust] push_material_closed — mid={} active_s={}", material_id, active_seconds_delta); + zx_document_core::events_v2::push_material_closed_v2(&session_id, &material_id, active_seconds_delta, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn push_position_changed_v2(session_id: String, material_id: String, position: ReadingPosition, timestamp_ms: i64) -> Result { - zx_document_core::events_v2::push_position_changed_v2(&session_id, &material_id, position, timestamp_ms) - .map_err(|e| e.to_string()) +fn push_position_changed_v2(session_id: String, material_id: String, position: ReadingPosition, timestamp_ms: i64) -> Result { + eprintln!("[Rust] push_position_changed — mid={}", material_id); + zx_document_core::events_v2::push_position_changed_v2(&session_id, &material_id, position, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn push_heartbeat_v2(session_id: String, material_id: String, active_seconds_delta: u32, position: Option, timestamp_ms: i64) -> Result { - zx_document_core::events_v2::push_heartbeat_v2(&session_id, &material_id, active_seconds_delta, position, timestamp_ms) - .map_err(|e| e.to_string()) +fn push_heartbeat_v2(session_id: String, material_id: String, active_seconds_delta: u32, position: Option, timestamp_ms: i64) -> Result { + zx_document_core::events_v2::push_heartbeat_v2(&session_id, &material_id, active_seconds_delta, position, timestamp_ms).map_err(Into::into) } #[uniffi::export] -fn push_marked_as_read_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { - zx_document_core::events_v2::push_marked_as_read_v2(&session_id, &material_id, timestamp_ms) - .map_err(|e| e.to_string()) +fn push_checkpoint_changed_v2(session_id: String, material_id: String, checkpoint: ReadingCheckpoint, timestamp_ms: i64) -> Result { + eprintln!("[Rust] push_checkpoint_changed — mid={}", material_id); + zx_document_core::events_v2::push_checkpoint_changed_v2(&session_id, &material_id, checkpoint, timestamp_ms).map_err(Into::into) +} + +#[uniffi::export] +fn push_heartbeat_checkpoint_v2(session_id: String, material_id: String, active_seconds_delta: u32, checkpoint: Option, timestamp_ms: i64) -> Result { + zx_document_core::events_v2::push_heartbeat_checkpoint_v2(&session_id, &material_id, active_seconds_delta, checkpoint, timestamp_ms).map_err(Into::into) +} + +#[uniffi::export] +fn push_marked_as_read_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { + eprintln!("[Rust] push_marked_as_read — mid={}", material_id); + zx_document_core::events_v2::push_marked_as_read_v2(&session_id, &material_id, timestamp_ms).map_err(Into::into) } // ── V2 Buffer Management FFI ── #[uniffi::export] fn export_pending_events_v2(limit: u32, timestamp_ms: i64) -> Vec { - zx_document_core::events_v2::export_pending_events_v2(limit, timestamp_ms) + let events = zx_document_core::events_v2::export_pending_events_v2(limit, timestamp_ms); + if !events.is_empty() { eprintln!("[Rust] export_events — count={}", events.len()); } + events } #[uniffi::export] fn reload_stale_events_v2() -> u32 { - zx_document_core::events_v2::reload_stale_events_v2() + let n = zx_document_core::events_v2::reload_stale_events_v2(); + if n > 0 { eprintln!("[Rust] reload_stale — count={}", n); } + n } #[uniffi::export] @@ -122,14 +139,26 @@ fn get_event_buffer_state_v2() -> EventBufferStateV2 { #[uniffi::export] fn ack_events_v2(event_ids: Vec) -> u32 { + eprintln!("[Rust] ack_events — count={}", event_ids.len()); zx_document_core::events_v2::ack_events_v2(&event_ids) } #[uniffi::export] fn mark_events_failed_v2(event_ids: Vec) -> u32 { + eprintln!("[Rust] mark_failed — count={}", event_ids.len()); zx_document_core::events_v2::mark_events_failed_v2(&event_ids) } +#[uniffi::export] +fn compute_file_fingerprint(file_path: String) -> Result { + zx_document_core::fingerprint::compute_file_fingerprint(&file_path).map_err(Into::into) +} + +#[uniffi::export] +fn build_representation_fingerprint(source_fingerprint: String, renderer_kind: RendererKind, parser_version: String, schema_version: u32) -> String { + zx_document_core::fingerprint::build_representation_fingerprint(&source_fingerprint, &renderer_kind, &parser_version, schema_version) +} + // FFI-compatible DocumentBlock (tuple variants, UniFFI proc-macro) #[derive(Debug, uniffi::Enum)] pub enum DocumentBlock { @@ -181,6 +210,11 @@ pub enum DocumentError { ParseError, InvalidEncoding, IoError, + SessionNotFound, + SessionAlreadyActive, + SessionNotActive, + BufferOverflow, + Unsupported, } impl std::fmt::Display for DocumentError { @@ -191,6 +225,11 @@ impl std::fmt::Display for DocumentError { Self::ParseError => write!(f, "Parse error"), Self::InvalidEncoding => write!(f, "Invalid encoding"), Self::IoError => write!(f, "IO error"), + Self::SessionNotFound => write!(f, "Session not found"), + Self::SessionAlreadyActive => write!(f, "Session already active"), + Self::SessionNotActive => write!(f, "Session not active"), + Self::BufferOverflow => write!(f, "Buffer overflow"), + Self::Unsupported => write!(f, "Unsupported"), } } } @@ -209,6 +248,20 @@ impl From for DocumentError { } } +impl From for DocumentError { + fn from(e: zx_document_core::session_v2::SessionError) -> Self { + match e { + zx_document_core::session_v2::SessionError::NotFound => Self::SessionNotFound, + zx_document_core::session_v2::SessionError::AlreadyClosed + | zx_document_core::session_v2::SessionError::AlreadyInterrupted + | zx_document_core::session_v2::SessionError::AlreadyFailed => Self::SessionAlreadyActive, + zx_document_core::session_v2::SessionError::NotActive + | zx_document_core::session_v2::SessionError::NotClosed => Self::SessionNotActive, + zx_document_core::session_v2::SessionError::LockPoisoned => Self::Unsupported, + } + } +} + #[uniffi::export] fn detect_material_type(file_path: String) -> Result { zx_document_core::material_type::detect_material_type(&file_path).map_err(Into::into) diff --git a/crates/zx_document_ffi/src/zx_document.udl b/crates/zx_document_ffi/src/zx_document.udl index a979911..2e3408b 100644 --- a/crates/zx_document_ffi/src/zx_document.udl +++ b/crates/zx_document_ffi/src/zx_document.udl @@ -49,7 +49,7 @@ namespace zx_document { // ── V2 Reading Session ── [Throws=DocumentError] - string start_reading_session_v2(ReadingMaterialRef material, i64 timestamp_ms); + string start_reading_session_v2(ReadingMaterialRefV2 material, i64 timestamp_ms); [Throws=DocumentError] void pause_reading_session_v2([ByRef] string session_id); @@ -58,7 +58,20 @@ namespace zx_document { void resume_reading_session_v2([ByRef] string session_id); [Throws=DocumentError] - void close_reading_session_v2([ByRef] string session_id); + void close_reading_session_v2([ByRef] string session_id, i64 timestamp_ms); + + ReadingSessionV2? get_active_session_v2([ByRef] string material_id); + + ReadingSessionV2? get_session_v2_ffi([ByRef] string session_id); + + [Throws=DocumentError] + void mark_session_interrupted_v2([ByRef] string session_id); + + [Throws=DocumentError] + void mark_session_failed_v2([ByRef] string session_id); + + [Throws=DocumentError] + void set_session_closed_at_ms_v2([ByRef] string session_id, i64 timestamp_ms); // ── V2 Reading Events ── [Throws=DocumentError] @@ -73,6 +86,12 @@ namespace zx_document { [Throws=DocumentError] ReadingEventV2 push_heartbeat_v2([ByRef] string session_id, [ByRef] string material_id, u32 active_seconds_delta, ReadingPosition? position, i64 timestamp_ms); + [Throws=DocumentError] + ReadingEventV2 push_checkpoint_changed_v2([ByRef] string session_id, [ByRef] string material_id, ReadingCheckpoint checkpoint, i64 timestamp_ms); + + [Throws=DocumentError] + ReadingEventV2 push_heartbeat_checkpoint_v2([ByRef] string session_id, [ByRef] string material_id, u32 active_seconds_delta, ReadingCheckpoint? checkpoint, i64 timestamp_ms); + [Throws=DocumentError] ReadingEventV2 push_marked_as_read_v2([ByRef] string session_id, [ByRef] string material_id, i64 timestamp_ms); @@ -83,8 +102,15 @@ namespace zx_document { u32 mark_events_failed_v2(sequence event_ids); + [Throws=DocumentError] + string compute_file_fingerprint([ByRef] string file_path); + + string build_representation_fingerprint([ByRef] string source_fingerprint, RendererKind renderer_kind, [ByRef] string parser_version, u32 schema_version); + u32 reload_stale_events_v2(); + EventBufferStateV2 get_event_buffer_state_v2(); + // ── V1 (deprecated) ── void push_reading_event(ReadingEvent event); void update_reading_position([ByRef] string material_id, ReadingPosition position); @@ -99,6 +125,11 @@ enum DocumentError { "ParseError", "InvalidEncoding", "IoError", + "SessionNotFound", + "SessionAlreadyActive", + "SessionNotActive", + "BufferOverflow", + "Unsupported", }; // ── V2 Types ── @@ -107,24 +138,46 @@ dictionary ReadingMaterialRef { string material_id; }; +dictionary ReadingMaterialRefV2 { + string material_id; + string file_path; + string file_name; + string file_type; + string? mime_type; + u64? file_size; + string? content_hash; + i64? created_at_ms; +}; + [Enum] interface ReadingSessionStatus { Active(); Paused(); Closed(); + Interrupted(); + Failed(); }; dictionary ReadingSessionV2 { string client_session_id; - ReadingMaterialRef material; + ReadingMaterialRefV2 material; i64 started_at_ms; + i64? closed_at_ms; i64 last_event_at_ms; u64 next_sequence; u32 total_active_seconds; ReadingPosition? last_position; + ReadingCheckpoint? last_checkpoint; ReadingSessionStatus status; }; +dictionary EventBufferStateV2 { + u32 total; + u32 pending; + u32 exported; + u32 failed; +}; + [Enum] interface ReadingEventTypeV2 { MaterialOpened(); @@ -140,11 +193,34 @@ dictionary ReadingEventV2 { string material_id; ReadingEventTypeV2 event_type; ReadingPosition? position; + ReadingCheckpoint? checkpoint; u32 active_seconds_delta; i64 timestamp_ms; u64 sequence; }; +[Enum] +interface RendererKind { + Block(); + Pdf(); + Image(); + Workbook(); + Slide(); + Epub(); + Unknown(); +}; + +dictionary ReadingCheckpoint { + u32 schema_version; + string material_id; + string representation_fingerprint; + RendererKind renderer_kind; + u64 captured_at_ms; + string device_id; + u64 device_sequence; + ReadingPosition position; +}; + [Enum] interface MaterialType { Markdown(); @@ -188,10 +264,15 @@ dictionary DocumentInfo { [Enum] interface ReadingPosition { + Block(string block_id, f32 block_progress, f32 overall_progress); Markdown(string block_id, f32 scroll_progress); Text(u32 line_number, f32 scroll_progress); Pdf(u32 page_number, f32 page_progress, f32 overall_progress); + PdfViewport(u32 page_number, f32 anchor_x, f32 anchor_y, f32 zoom_scale, f32 overall_progress); Image(f32 zoom_scale, f32 offset_x, f32 offset_y); + ImageViewport(f32 zoom_scale, f32 anchor_x, f32 anchor_y); + Workbook(string sheet_id, u32 top_row, u32 left_column, string? selected_cell, f32 zoom_scale, f32? overall_progress); + Slide(u32 slide_index, f32 zoom_scale, f32 anchor_x, f32 anchor_y, f32 overall_progress); Epub(string chapter_id, f32 chapter_progress, f32 overall_progress); Unknown(); }; diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 49c127a..104ff70 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -7,7 +7,7 @@ RUST_DIR="$(cd "$(dirname "$0")/.." && pwd)" OUT_DIR="$RUST_DIR/bindings/ios" LIB_NAME="libzx_document_ffi.a" -rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios echo "==> Building for iOS device (arm64)..." cargo build --release --target aarch64-apple-ios -p zx_document_ffi @@ -15,11 +15,21 @@ cargo build --release --target aarch64-apple-ios -p zx_document_ffi echo "==> Building for iOS simulator (arm64)..." cargo build --release --target aarch64-apple-ios-sim -p zx_document_ffi +echo "==> Building for iOS simulator (x86_64)..." +cargo build --release --target x86_64-apple-ios -p zx_document_ffi + rm -rf "$OUT_DIR/ZxDocumentRuntime.xcframework" mkdir -p "$OUT_DIR/device" "$OUT_DIR/simulator" "$OUT_DIR/generated" cp "target/aarch64-apple-ios/release/$LIB_NAME" "$OUT_DIR/device/" -cp "target/aarch64-apple-ios-sim/release/$LIB_NAME" "$OUT_DIR/simulator/" +cp "target/aarch64-apple-ios-sim/release/$LIB_NAME" "$OUT_DIR/simulator/libzx_document_ffi_arm64.a" +cp "target/x86_64-apple-ios/release/$LIB_NAME" "$OUT_DIR/simulator/libzx_document_ffi_x86_64.a" + +echo "==> Creating universal iOS simulator static library..." +lipo -create \ + "$OUT_DIR/simulator/libzx_document_ffi_arm64.a" \ + "$OUT_DIR/simulator/libzx_document_ffi_x86_64.a" \ + -output "$OUT_DIR/simulator/$LIB_NAME" echo "==> Generating Swift bindings (UDL bindgen)..." rm -f "$OUT_DIR/generated/zx_document.swift" diff --git a/scripts/check-symbols.sh b/scripts/check-symbols.sh index 4dfc7c7..7ccdc81 100755 --- a/scripts/check-symbols.sh +++ b/scripts/check-symbols.sh @@ -38,6 +38,27 @@ check uniffi_zx_document_ffi_fn_func_update_reading_position check uniffi_zx_document_ffi_fn_func_export_pending_events check uniffi_zx_document_ffi_fn_func_clear_exported_events +# V2 session lifecycle +check uniffi_zx_document_ffi_fn_func_start_reading_session_v2 +check uniffi_zx_document_ffi_fn_func_pause_reading_session_v2 +check uniffi_zx_document_ffi_fn_func_resume_reading_session_v2 +check uniffi_zx_document_ffi_fn_func_close_reading_session_v2 + +# V2 event push +check uniffi_zx_document_ffi_fn_func_push_material_opened_v2 +check uniffi_zx_document_ffi_fn_func_push_material_closed_v2 +check uniffi_zx_document_ffi_fn_func_push_position_changed_v2 +check uniffi_zx_document_ffi_fn_func_push_heartbeat_v2 +check uniffi_zx_document_ffi_fn_func_push_marked_as_read_v2 + +# V2 buffer management +check uniffi_zx_document_ffi_fn_func_export_pending_events_v2 +check uniffi_zx_document_ffi_fn_func_ack_events_v2 +check uniffi_zx_document_ffi_fn_func_mark_events_failed_v2 +check uniffi_zx_document_ffi_fn_func_reload_stale_events_v2 +check uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2 +check uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi + # Buffer management check ffi_zx_document_ffi_rustbuffer_alloc check ffi_zx_document_ffi_rustbuffer_from_bytes diff --git a/scripts/patch-swift.py b/scripts/patch-swift.py index 990aa82..b956915 100755 --- a/scripts/patch-swift.py +++ b/scripts/patch-swift.py @@ -12,11 +12,13 @@ c = c.replace( "fileprivate extension Data {\n init(rustBuffer: RustBuffer) {\n let buf = UnsafeRawBufferPointer(start: rustBuffer.data, count: Int(rustBuffer.len))\n self.init(buf)\n }\n}" ) -# Patch 2: Fix UnsafeMutableRawPointer cast for iOS 26 SDK -c = c.replace( - "let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len))", - "let bytes = UnsafeBufferPointer(start: value.data!.assumingMemoryBound(to: UInt8.self), count: Int(value.len))" -) +# Patch 2: removed — `RustBuffer.data` is now `UnsafeMutablePointer?` on current SDKs, +# so `.assumingMemoryBound(to:)` is not needed (and would cause a compile error). +# Kept as comment in case an older SDK needs the raw-pointer cast. +# c = c.replace( +# "let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len))", +# "let bytes = UnsafeBufferPointer(start: value.data!.assumingMemoryBound(to: UInt8.self), count: Int(value.len))" +# ) # Patch 3: Remove checksum initialization # UniFFI 0.28 UDL scaffolding does not export checksum functions as #[no_mangle],