// // 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 } }