Files
Ascently/ios/Ascently/Utils/ZipUtils.swift
Atridad Lahiji 23de8a6fc6
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m31s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m30s
[All Platforms] 2.1.0 - Sync Optimizations
2025-10-15 18:17:19 -06:00

653 lines
22 KiB
Swift

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: ClimbDataBackup,
referencedImagePaths: Set<String>
) throws -> Data {
var zipData = Data()
var centralDirectory = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
// Add metadata
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: &currentOffset
)
// Encode JSON data
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: &currentOffset
)
// Process images in batches for better performance
print("Processing \(referencedImagePaths.count) images for export")
var successfulImages = 0
let batchSize = 10
let sortedPaths = Array(referencedImagePaths).sorted()
// Pre-allocate capacity for better memory performance
zipData.reserveCapacity(zipData.count + (referencedImagePaths.count * 200_000)) // Estimate 200KB per image
for (index, imagePath) in sortedPaths.enumerated() {
if index % batchSize == 0 {
print("Processing images \(index)/\(sortedPaths.count)")
}
let imageURL = URL(fileURLWithPath: imagePath)
let imageName = imageURL.lastPathComponent
guard FileManager.default.fileExists(atPath: imagePath) else {
continue
}
do {
let imageData = try Data(contentsOf: imageURL)
if imageData.count > 0 {
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
try addFileToZip(
filename: imageEntryName,
fileData: imageData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
successfulImages += 1
}
} catch {
print("Failed to read image: \(imageName)")
}
}
print("Export: included \(successfulImages)/\(referencedImagePaths.count) images")
// Build central directory
centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry
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: ClimbDataBackup,
referencedImagePaths: Set<String>
) -> String {
return """
Ascently 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
}
// CRC32 lookup table for faster calculation
private static let crc32Table: [UInt32] = {
let polynomial: UInt32 = 0xEDB8_8320
var table = [UInt32](repeating: 0, count: 256)
for i in 0..<256 {
var crc = UInt32(i)
for _ in 0..<8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ polynomial
} else {
crc >>= 1
}
}
table[i] = crc
}
return table
}()
private static func calculateCRC32(data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFF_FFFF
data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
for byte in bytes {
let index = Int((crc ^ UInt32(byte)) & 0xFF)
crc = (crc >> 8) ^ crc32Table[index]
}
}
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..<i + 4) == signature {
endOfCentralDirOffset = i
break
}
}
guard endOfCentralDirOffset >= 0 else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "End of central directory not found"])
}
let endRecord = data.subdata(in: endOfCentralDirOffset..<endOfCentralDirOffset + 22)
let numEntries = endRecord.subdata(in: 8..<10).withUnsafeBytes { $0.load(as: UInt16.self) }
let centralDirOffset = endRecord.subdata(in: 16..<20).withUnsafeBytes {
$0.load(as: UInt32.self)
}
var entries: [ZipEntry] = []
var offset = Int(centralDirOffset)
for _ in 0..<numEntries {
let entry = try parseCentralDirectoryEntry(data: data, offset: &offset)
entries.append(entry)
}
var zipEntries: [ZipEntry] = []
for entry in entries {
let fileData = try extractFileData(data: data, entry: entry)
let zipEntry = ZipEntry(filename: entry.filename, data: fileData)
zipEntries.append(zipEntry)
}
return zipEntries
}
private static func parseCentralDirectoryEntry(
data: Data, offset: inout Int
) throws -> 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..<offset + 46)
let signature = entryData.subdata(in: 0..<4)
guard signature == Data([0x50, 0x4b, 0x01, 0x02]) else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid central directory signature"])
}
let compressionMethod = entryData.subdata(in: 10..<12).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let compressedSize = entryData.subdata(in: 20..<24).withUnsafeBytes {
$0.load(as: UInt32.self)
}
let uncompressedSize = entryData.subdata(in: 24..<28).withUnsafeBytes {
$0.load(as: UInt32.self)
}
let filenameLength = entryData.subdata(in: 28..<30).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let extraFieldLength = entryData.subdata(in: 30..<32).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let commentLength = entryData.subdata(in: 32..<34).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let localHeaderOffset = entryData.subdata(in: 42..<46).withUnsafeBytes {
$0.load(as: UInt32.self)
}
offset += 46
let filenameData = data.subdata(in: offset..<offset + Int(filenameLength))
let filename = String(data: filenameData, encoding: .utf8) ?? ""
offset += Int(filenameLength)
offset += Int(extraFieldLength) + Int(commentLength)
return ZipEntry(
filename: filename,
localHeaderOffset: localHeaderOffset,
compressedSize: compressedSize,
uncompressedSize: uncompressedSize,
compressionMethod: compressionMethod
)
}
private static func extractFileData(
data: Data, entry: ZipEntry
) throws -> 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..<headerOffset + 30)
let signature = headerData.subdata(in: 0..<4)
guard signature == Data([0x50, 0x4b, 0x03, 0x04]) else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid local header signature"])
}
let localCompressionMethod = headerData.subdata(in: 8..<10).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let filenameLength = headerData.subdata(in: 26..<28).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let extraFieldLength = headerData.subdata(in: 28..<30).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let dataOffset = headerOffset + 30 + Int(filenameLength) + Int(extraFieldLength)
let dataEndOffset = dataOffset + Int(entry.compressedSize)
guard dataEndOffset <= data.count else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "File data extends beyond ZIP file"])
}
let compressedData = data.subdata(in: dataOffset..<dataEndOffset)
switch localCompressionMethod {
case 0:
return compressedData
case 8:
return try decompressDeflate(compressedData)
default:
throw NSError(
domain: "ZipError", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Unsupported compression method: \(localCompressionMethod)"
])
}
}
private static func decompressDeflate(_ data: Data) throws -> Data {
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 1024 * 1024)
defer { buffer.deallocate() }
var decompressedData = Data()
try data.withUnsafeBytes { bytes in
var stream = z_stream()
stream.next_in = UnsafeMutablePointer<UInt8>(
mutating: bytes.bindMemory(to: UInt8.self).baseAddress)
stream.avail_in = UInt32(data.count)
let initResult = inflateInit2_(
&stream, -15, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.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]
}