177 lines
6.7 KiB
Swift
177 lines
6.7 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|