1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
This commit is contained in:
85
ios/OpenClimb/Utils/DataStateManager.swift
Normal file
85
ios/OpenClimb/Utils/DataStateManager.swift
Normal 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()))"
|
||||
}
|
||||
}
|
||||
176
ios/OpenClimb/Utils/ImageNamingUtils.swift
Normal file
176
ios/OpenClimb/Utils/ImageNamingUtils.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 """
|
||||
|
||||
Reference in New Issue
Block a user