1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import

formats :)
This commit is contained in:
2025-09-28 02:37:03 -06:00
parent cf2e2f7c57
commit c3f847e1e6
48 changed files with 6944 additions and 1107 deletions

View File

@@ -0,0 +1,85 @@
//
// DataStateManager.swift
import Foundation
/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the
/// local database was last modified, independent of individual entity timestamps.
class DataStateManager {
private let userDefaults = UserDefaults.standard
private enum Keys {
static let lastModified = "openclimb_data_last_modified"
static let initialized = "openclimb_data_state_initialized"
}
/// Shared instance for app-wide use
static let shared = DataStateManager()
private init() {
// Initialize with current timestamp if this is the first time
if !isInitialized() {
print("DataStateManager: First time initialization")
// Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)")
} else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())")
}
}
/// Updates the data state timestamp to the current time. Call this whenever any data is modified
/// (create, update, delete).
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("📝 iOS Data state updated to: \(now)")
}
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp
}
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z"
print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}
/// Sets the data state timestamp to a specific value. Used when importing data from server to
/// sync the state.
func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)")
}
/// Resets the data state (for testing or complete data wipe).
func reset() {
userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset")
}
/// Checks if the data state has been initialized.
private func isInitialized() -> Bool {
return userDefaults.bool(forKey: Keys.initialized)
}
/// Marks the data state as initialized.
private func markAsInitialized() {
userDefaults.set(true, forKey: Keys.initialized)
}
/// Gets debug information about the current state.
func getDebugInfo() -> String {
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
}
}

View File

@@ -0,0 +1,176 @@
//
// ImageNamingUtils.swift
import CryptoKit
import Foundation
/// Utility for creating consistent image filenames across iOS and Android platforms.
/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility.
class ImageNamingUtils {
private static let imageExtension = ".jpg"
private static let hashLength = 12 // First 12 chars of SHA-256
/// Generates a deterministic filename for a problem image.
/// Format: "problem_{hash}_{index}.jpg"
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - timestamp: ISO8601 timestamp when the image was created
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename that will be the same across platforms
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
// Create a deterministic hash from problemId + timestamp + index
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
}
/// Generates a deterministic filename for a problem image using current timestamp.
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Extracts problem ID from an image filename created by this utility.
/// Returns nil if the filename doesn't match our naming convention.
///
/// - Parameter filename: The image filename
/// - Returns: The hash identifier or nil if not a valid filename
static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil
}
// Format: problem_{hash}_{index}.jpg
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
guard parts.count == 3 && parts[0] == "problem" else {
return nil
}
// Return the hash as identifier
return parts[1]
}
/// Validates if a filename follows our naming convention.
///
/// - Parameter filename: The filename to validate
/// - Returns: true if it matches our convention, false otherwise
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
}
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength
&& Int(parts[2]) != nil
}
/// Migrates an existing UUID-based filename to our naming convention.
/// This is used during sync to rename downloaded images.
///
/// - Parameters:
/// - oldFilename: The existing filename (UUID-based)
/// - problemId: The problem ID this image belongs to
/// - imageIndex: The index of this image
/// - Returns: The new filename following our convention
static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
// If it's already using our convention, keep it
if isValidImageFilename(oldFilename) {
return oldFilename
}
// Generate new deterministic name
// Use current timestamp to maintain some consistency
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Creates a deterministic hash from input string.
/// Uses SHA-256 and takes first 12 characters for filename safety.
///
/// - Parameter input: The input string to hash
/// - Returns: First 12 characters of SHA-256 hash in lowercase
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined()
return String(hashString.prefix(hashLength))
}
/// Batch renames images for a problem to use our naming convention.
/// Returns a mapping of old filename -> new filename.
///
/// - Parameters:
/// - problemId: The problem ID
/// - existingFilenames: List of current image filenames for this problem
/// - Returns: Dictionary mapping old filename to new filename
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
var renameMap: [String: String] = [:]
for (index, oldFilename) in existingFilenames.enumerated() {
let newFilename = migrateFilename(
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
if newFilename != oldFilename {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/// Validates that a collection of filenames follow our naming convention.
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
for filename in filenames {
if isValidImageFilename(filename) {
validImages.append(filename)
} else {
invalidImages.append(filename)
}
}
return ImageValidationResult(
totalImages: filenames.count,
validImages: validImages,
invalidImages: invalidImages
)
}
}
/// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]
let invalidImages: [String]
var isAllValid: Bool {
return invalidImages.isEmpty
}
var validPercentage: Double {
guard totalImages > 0 else { return 100.0 }
return (Double(validImages.count) / Double(totalImages)) * 100.0
}
}

View File

@@ -1,4 +1,3 @@
import Compression
import Foundation
import zlib
@@ -10,7 +9,7 @@ struct ZipUtils {
private static let METADATA_FILENAME = "metadata.txt"
static func createExportZip(
exportData: ClimbDataExport,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) throws -> Data {
@@ -196,7 +195,7 @@ struct ZipUtils {
}
private static func createMetadata(
exportData: ClimbDataExport,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) -> String {
return """