import Compression import Foundation import zlib struct ZipUtils { private static let DATA_JSON_FILENAME = "data.json" private static let IMAGES_DIR_NAME = "images" private static let METADATA_FILENAME = "metadata.txt" static func createExportZip( exportData: ClimbDataExport, referencedImagePaths: Set ) throws -> Data { var zipData = Data() var centralDirectory = Data() var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] var currentOffset: UInt32 = 0 let metadata = createMetadata( exportData: exportData, referencedImagePaths: referencedImagePaths) let metadataData = metadata.data(using: .utf8) ?? Data() try addFileToZip( filename: METADATA_FILENAME, fileData: metadataData, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted encoder.dateEncodingStrategy = .custom { date, encoder in let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" var container = encoder.singleValueContainer() try container.encode(formatter.string(from: date)) } let jsonData = try encoder.encode(exportData) try addFileToZip( filename: DATA_JSON_FILENAME, fileData: jsonData, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) print("Processing \(referencedImagePaths.count) referenced image paths") var successfulImages = 0 for imagePath in referencedImagePaths { print("Processing image path: \(imagePath)") let imageURL = URL(fileURLWithPath: imagePath) let imageName = imageURL.lastPathComponent print("Image name: \(imageName)") if FileManager.default.fileExists(atPath: imagePath) { print("Image file exists at: \(imagePath)") do { let imageData = try Data(contentsOf: imageURL) print("Image data size: \(imageData.count) bytes") if imageData.count > 0 { let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)" try addFileToZip( filename: imageEntryName, fileData: imageData, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) successfulImages += 1 print("Successfully added image to ZIP: \(imageEntryName)") } else { print("Image data is empty for: \(imagePath)") } } catch { print("Failed to read image data for \(imagePath): \(error)") } } else { print("Image file does not exist at: \(imagePath)") } } print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included") for entry in fileEntries { let centralDirEntry = createCentralDirectoryEntry( filename: entry.name, fileData: entry.data, localHeaderOffset: entry.offset ) centralDirectory.append(centralDirEntry) } let centralDirOffset = UInt32(zipData.count) zipData.append(centralDirectory) let endOfCentralDir = createEndOfCentralDirectory( numEntries: UInt16(fileEntries.count), centralDirSize: UInt32(centralDirectory.count), centralDirOffset: centralDirOffset ) zipData.append(endOfCentralDir) return zipData } static func extractImportZip(data: Data) throws -> ImportResult { print("Starting ZIP extraction - data size: \(data.count) bytes") return try extractUsingCustomParser(data: data) } private static func extractUsingCustomParser(data: Data) throws -> ImportResult { var jsonContent = "" var metadataContent = "" var importedImagePaths: [String: String] = [:] let zipEntries: [ZipEntry] do { zipEntries = try parseZipFile(data: data) print("Successfully parsed ZIP file with \(zipEntries.count) entries") } catch { print("Failed to parse ZIP file: \(error)") print( "ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))" ) throw NSError( domain: "ImportError", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Failed to parse ZIP file: \(error.localizedDescription). This may be due to incompatibility with the ZIP format." ] ) } print("Found \(zipEntries.count) entries in ZIP file:") for entry in zipEntries { print(" - \(entry.filename) (size: \(entry.data.count) bytes)") } for entry in zipEntries { switch entry.filename { case METADATA_FILENAME: metadataContent = String(data: entry.data, encoding: .utf8) ?? "" print("Found metadata: \(metadataContent.prefix(100))...") case DATA_JSON_FILENAME: jsonContent = String(data: entry.data, encoding: .utf8) ?? "" print("Found data.json with \(jsonContent.count) characters") if jsonContent.isEmpty { print("WARNING: data.json is empty!") } else { print("data.json preview: \(jsonContent.prefix(200))...") } default: if entry.filename.hasPrefix("\(IMAGES_DIR_NAME)/") && !entry.filename.hasSuffix("/") { let originalFilename = String( entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count)) do { let filename = try ImageManager.shared.saveImportedImage( entry.data, filename: originalFilename) importedImagePaths[originalFilename] = filename } catch { print("Failed to import image \(originalFilename): \(error)") } } } } guard !jsonContent.isEmpty else { print("ERROR: data.json not found or empty") print("Available files in ZIP:") for entry in zipEntries { print(" - \(entry.filename)") } throw NSError( domain: "ImportError", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Invalid ZIP file: data.json not found or empty. Found files: \(zipEntries.map { $0.filename }.joined(separator: ", "))" ] ) } print("Import extraction completed: \(importedImagePaths.count) images processed") return ImportResult( jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths ) } private static func createMetadata( exportData: ClimbDataExport, referencedImagePaths: Set ) -> String { return """ OpenClimb Export Metadata ======================= Export Date: \(exportData.exportedAt) Gyms: \(exportData.gyms.count) Problems: \(exportData.problems.count) Sessions: \(exportData.sessions.count) Attempts: \(exportData.attempts.count) Referenced Images: \(referencedImagePaths.count) Format: ZIP with embedded JSON data and images """ } private static func addFileToZip( filename: String, fileData: Data, zipData: inout Data, fileEntries: inout [(name: String, data: Data, offset: UInt32)], currentOffset: inout UInt32 ) throws { let localHeader = createLocalFileHeader(filename: filename, fileData: fileData) let headerOffset = currentOffset zipData.append(localHeader) zipData.append(fileData) fileEntries.append((name: filename, data: fileData, offset: headerOffset)) currentOffset += UInt32(localHeader.count + fileData.count) } private static func createLocalFileHeader(filename: String, fileData: Data) -> Data { var header = Data() header.append(Data([0x50, 0x4b, 0x03, 0x04])) header.append(Data([0x14, 0x00])) header.append(Data([0x00, 0x00])) header.append(Data([0x00, 0x00])) // Last mod file time & date (use current time) let dosTime = getDosDateTime() header.append(dosTime) let crc = calculateCRC32(data: fileData) header.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) }) // Compressed size (same as uncompressed since no compression) let compressedSize = UInt32(fileData.count) header.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) }) let uncompressedSize = UInt32(fileData.count) header.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) }) let filenameData = filename.data(using: .utf8) ?? Data() let filenameLength = UInt16(filenameData.count) header.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) }) header.append(Data([0x00, 0x00])) header.append(filenameData) return header } private static func createCentralDirectoryEntry( filename: String, fileData: Data, localHeaderOffset: UInt32 ) -> Data { var entry = Data() entry.append(Data([0x50, 0x4b, 0x01, 0x02])) entry.append(Data([0x14, 0x00])) entry.append(Data([0x14, 0x00])) entry.append(Data([0x00, 0x00])) entry.append(Data([0x00, 0x00])) // Last mod file time & date let dosTime = getDosDateTime() entry.append(dosTime) let crc = calculateCRC32(data: fileData) entry.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) }) let compressedSize = UInt32(fileData.count) entry.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) }) let uncompressedSize = UInt32(fileData.count) entry.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) }) let filenameData = filename.data(using: .utf8) ?? Data() let filenameLength = UInt16(filenameData.count) entry.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) }) entry.append(Data([0x00, 0x00])) // File comment length entry.append(Data([0x00, 0x00])) entry.append(Data([0x00, 0x00])) entry.append(Data([0x00, 0x00])) entry.append(Data([0x00, 0x00, 0x00, 0x00])) // Relative offset of local header entry.append(withUnsafeBytes(of: localHeaderOffset.littleEndian) { Data($0) }) entry.append(filenameData) return entry } private static func createEndOfCentralDirectory( numEntries: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 ) -> Data { var endRecord = Data() endRecord.append(Data([0x50, 0x4b, 0x05, 0x06])) endRecord.append(Data([0x00, 0x00])) // Number of the disk with the start of the central directory endRecord.append(Data([0x00, 0x00])) // Total number of entries in the central directory on this disk endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) }) // Total number of entries in the central directory endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) }) endRecord.append(withUnsafeBytes(of: centralDirSize.littleEndian) { Data($0) }) // Offset of start of central directory endRecord.append(withUnsafeBytes(of: centralDirOffset.littleEndian) { Data($0) }) // ZIP file comment length endRecord.append(Data([0x00, 0x00])) return endRecord } private static func getDosDateTime() -> Data { let date = Date() let calendar = Calendar.current let components = calendar.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: date) let year = UInt16(max(1980, components.year ?? 1980) - 1980) let month = UInt16(components.month ?? 1) let day = UInt16(components.day ?? 1) let hour = UInt16(components.hour ?? 0) let minute = UInt16(components.minute ?? 0) let second = UInt16((components.second ?? 0) / 2) let dosDate = (year << 9) | (month << 5) | day let dosTime = (hour << 11) | (minute << 5) | second var data = Data() data.append(withUnsafeBytes(of: dosTime.littleEndian) { Data($0) }) data.append(withUnsafeBytes(of: dosDate.littleEndian) { Data($0) }) return data } private static func calculateCRC32(data: Data) -> UInt32 { let polynomial: UInt32 = 0xEDB8_8320 var crc: UInt32 = 0xFFFF_FFFF for byte in data { crc ^= UInt32(byte) for _ in 0..<8 { if crc & 1 != 0 { crc = (crc >> 1) ^ polynomial } else { crc >>= 1 } } } return ~crc } private static func parseZipFile(data: Data) throws -> [ZipEntry] { var endOfCentralDirOffset = -1 let signature = Data([0x50, 0x4b, 0x05, 0x06]) for i in stride(from: data.count - 22, through: 0, by: -1) { if data.subdata(in: i..= 0 else { throw NSError( domain: "ZipError", code: 1, userInfo: [NSLocalizedDescriptionKey: "End of central directory not found"]) } let endRecord = data.subdata(in: endOfCentralDirOffset.. ZipEntry { guard offset + 46 <= data.count else { throw NSError( domain: "ZipError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid central directory entry"]) } let entryData = data.subdata(in: offset.. Data { let headerOffset = Int(entry.localHeaderOffset) guard headerOffset + 30 <= data.count else { throw NSError( domain: "ZipError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid local header offset"]) } let headerData = data.subdata(in: headerOffset.. Data { let buffer = UnsafeMutablePointer.allocate(capacity: 1024 * 1024) defer { buffer.deallocate() } var decompressedData = Data() try data.withUnsafeBytes { bytes in var stream = z_stream() stream.next_in = UnsafeMutablePointer( mutating: bytes.bindMemory(to: UInt8.self).baseAddress) stream.avail_in = UInt32(data.count) let initResult = inflateInit2_( &stream, -15, ZLIB_VERSION, Int32(MemoryLayout.size)) guard initResult == Z_OK else { throw NSError( domain: "ZipError", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Failed to initialize deflate decompression" ]) } defer { inflateEnd(&stream) } var result: Int32 repeat { stream.next_out = buffer stream.avail_out = 1024 * 1024 result = inflate(&stream, Z_NO_FLUSH) if result != Z_OK && result != Z_STREAM_END { throw NSError( domain: "ZipError", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Decompression failed with code: \(result)" ]) } let bytesDecompressed = 1024 * 1024 - Int(stream.avail_out) if bytesDecompressed > 0 { decompressedData.append(buffer, count: bytesDecompressed) } } while result != Z_STREAM_END } return decompressedData } } struct ZipEntry { let filename: String let data: Data let localHeaderOffset: UInt32 let compressedSize: UInt32 let uncompressedSize: UInt32 let compressionMethod: UInt16 init(filename: String, data: Data) { self.filename = filename self.data = data self.localHeaderOffset = 0 self.compressedSize = 0 self.uncompressedSize = 0 self.compressionMethod = 0 } init( filename: String, localHeaderOffset: UInt32, compressedSize: UInt32, uncompressedSize: UInt32 = 0, compressionMethod: UInt16 = 0 ) { self.filename = filename self.data = Data() self.localHeaderOffset = localHeaderOffset self.compressedSize = compressedSize self.uncompressedSize = uncompressedSize self.compressionMethod = compressionMethod } } struct ImportResult { let jsonData: Data let imagePathMapping: [String: String] }