This commit is contained in:
2025-10-05 12:42:02 -06:00
parent 4bbd422c09
commit b8f874a433
18 changed files with 147 additions and 432 deletions

View File

@@ -2,21 +2,6 @@
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS. This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
## Versions
- Android: 1.7.0
- iOS: 1.2.0
- Sync: 1.0.0
## Stability
- Clients: 8/10
- Server: 10/10
- Schema: 9/10 (No more breaking changes)
## Self-Hosted Sync Server
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example.
## Download ## Download
For Android do one of the following: For Android do one of the following:
@@ -28,6 +13,30 @@ For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)! Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
## Self-Hosted Sync Server
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker.
### Quick Start with Docker Compose
1. Create a `.env` file with your configuration:
```
IMAGE=git.atri.dad/atridad/openclimb-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/openclimb.json
IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-data
```
2. Use the provided `docker-compose.yml` in the `sync/` directory:
```bash
cd sync/
docker-compose up -d
```
The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing.
## Requirements ## Requirements
- Android 12+ or iOS 17+ - Android 12+ or iOS 17+

View File

@@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Root structure for OpenClimb backup data */ // Root structure for OpenClimb backup data
@Serializable @Serializable
data class ClimbDataBackup( data class ClimbDataBackup(
val exportedAt: String, val exportedAt: String,
@@ -15,7 +15,7 @@ data class ClimbDataBackup(
val attempts: List<BackupAttempt> val attempts: List<BackupAttempt>
) )
/** Platform-neutral gym representation for backup/restore */ // Platform-neutral gym representation for backup/restore
@Serializable @Serializable
data class BackupGym( data class BackupGym(
val id: String, val id: String,
@@ -26,8 +26,8 @@ data class BackupGym(
@kotlinx.serialization.SerialName("customDifficultyGrades") @kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(), val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupGym from native Android Gym model */ /** Create BackupGym from native Android Gym model */
@@ -62,7 +62,7 @@ data class BackupGym(
} }
} }
/** Platform-neutral problem representation for backup/restore */ // Platform-neutral problem representation for backup/restore
@Serializable @Serializable
data class BackupProblem( data class BackupProblem(
val id: String, val id: String,
@@ -75,10 +75,10 @@ data class BackupProblem(
val location: String? = null, val location: String? = null,
val imagePaths: List<String>? = null, val imagePaths: List<String>? = null,
val isActive: Boolean = true, val isActive: Boolean = true,
val dateSet: String? = null, // ISO 8601 format val dateSet: String? = null,
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupProblem from native Android Problem model */ /** Create BackupProblem from native Android Problem model */
@@ -94,11 +94,7 @@ data class BackupProblem(
location = problem.location, location = problem.location,
imagePaths = imagePaths =
if (problem.imagePaths.isEmpty()) null if (problem.imagePaths.isEmpty()) null
else else problem.imagePaths.map { path -> path.substringAfterLast('/') },
problem.imagePaths.map { path ->
// Store just the filename to match iOS format
path.substringAfterLast('/')
},
isActive = problem.isActive, isActive = problem.isActive,
dateSet = problem.dateSet, dateSet = problem.dateSet,
notes = problem.notes, notes = problem.notes,
@@ -134,19 +130,19 @@ data class BackupProblem(
} }
} }
/** Platform-neutral climb session representation for backup/restore */ // Platform-neutral climb session representation for backup/restore
@Serializable @Serializable
data class BackupClimbSession( data class BackupClimbSession(
val id: String, val id: String,
val gymId: String, val gymId: String,
val date: String, // ISO 8601 format val date: String,
val startTime: String? = null, // ISO 8601 format val startTime: String? = null,
val endTime: String? = null, // ISO 8601 format val endTime: String? = null,
val duration: Long? = null, // Duration in seconds val duration: Long? = null,
val status: SessionStatus, val status: SessionStatus,
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO 8601 format val createdAt: String,
val updatedAt: String // ISO 8601 format val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupClimbSession from native Android ClimbSession model */ /** Create BackupClimbSession from native Android ClimbSession model */
@@ -183,7 +179,7 @@ data class BackupClimbSession(
} }
} }
/** Platform-neutral attempt representation for backup/restore */ // Platform-neutral attempt representation for backup/restore
@Serializable @Serializable
data class BackupAttempt( data class BackupAttempt(
val id: String, val id: String,
@@ -192,10 +188,11 @@ data class BackupAttempt(
val result: AttemptResult, val result: AttemptResult,
val highestHold: String? = null, val highestHold: String? = null,
val notes: String? = null, val notes: String? = null,
val duration: Long? = null, // Duration in seconds val duration: Long? = null,
val restTime: Long? = null, // Rest time in seconds val restTime: Long? = null,
val timestamp: String, // ISO 8601 format val timestamp: String,
val createdAt: String // ISO 8601 format val createdAt: String,
val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupAttempt from native Android Attempt model */ /** Create BackupAttempt from native Android Attempt model */

View File

@@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository
migrationResults.putAll(problemMigrations) migrationResults.putAll(problemMigrations)
migratedCount += problemMigrations.size migratedCount += problemMigrations.size
// Update problem with new image paths // Update image paths
val newImagePaths = val newImagePaths =
problem.imagePaths.map { oldPath -> problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath problemMigrations[oldPath] ?: oldPath
@@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository
continue continue
} }
// Check if filename follows our convention // Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) { if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath) validImages.add(imagePath)
} else { } else {

View File

@@ -39,11 +39,11 @@ data class Attempt(
val sessionId: String, val sessionId: String,
val problemId: String, val problemId: String,
val result: AttemptResult, val result: AttemptResult,
val highestHold: String? = null, // Description of the highest hold reached val highestHold: String? = null,
val notes: String? = null, val notes: String? = null,
val duration: Long? = null, // Attempt duration in seconds val duration: Long? = null,
val restTime: Long? = null, // Rest time before this attempt in seconds val restTime: Long? = null,
val timestamp: String, // When this attempt was made val timestamp: String,
val createdAt: String val createdAt: String
) { ) {
companion object { companion object {

View File

@@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class DifficultySystem { enum class DifficultySystem {
// Bouldering // Bouldering
V_SCALE, // V-Scale (VB - V17) V_SCALE,
FONT, // Fontainebleau (3 - 8C+) FONT,
// Rope // Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d) YDS,
// Custom difficulty systems
CUSTOM; CUSTOM;
/** Get the display name for the UI */ /** Get the display name for the UI */
@@ -28,7 +26,7 @@ enum class DifficultySystem {
when (this) { when (this) {
V_SCALE, FONT -> true V_SCALE, FONT -> true
YDS -> false YDS -> false
CUSTOM -> true // Custom is available for all CUSTOM -> true
} }
/** Check if this system is for rope climbing */ /** Check if this system is for rope climbing */
@@ -157,7 +155,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
// Simplified numeric mapping for YDS grades
when { when {
grade.startsWith("5.10") -> grade.startsWith("5.10") ->
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) 10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
@@ -175,7 +172,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
} }
} }
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
// Simplified Font grade mapping
when { when {
grade.startsWith("6A") -> 6 grade.startsWith("6A") -> 6
grade.startsWith("6B") -> 7 grade.startsWith("6B") -> 7
@@ -209,24 +205,20 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
} }
private fun compareVScaleGrades(grade1: String, grade2: String): Int { private fun compareVScaleGrades(grade1: String, grade2: String): Int {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1 if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1 if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0 if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2) return num1.compareTo(num2)
} }
private fun compareFontGrades(grade1: String, grade2: String): Int { private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2) return grade1.compareTo(grade2)
} }
private fun compareYDSGrades(grade1: String, grade2: String): Int { private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
return grade1.compareTo(grade2) return grade1.compareTo(grade2)
} }
} }

View File

@@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context) private val dataStateManager = DataStateManager(context)
// Callback interface for auto-sync functionality
private var autoSyncCallback: (() -> Unit)? = null private var autoSyncCallback: (() -> Unit)? = null
private val json = Json { private val json = Json {
@@ -125,16 +124,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try { try {
// Collect all data
val allGyms = gymDao.getAllGyms().first() val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
// Create backup data using platform-neutral format
val backupData = val backupData =
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
@@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
) )
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = val validImagePaths =
referencedImagePaths referencedImagePaths
@@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) { if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist") throw Exception("Invalid ZIP file: file is empty or doesn't exist")
} }
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file) val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) { if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content") throw Exception("Invalid ZIP file: no data.json found or empty content")
} }
// Parse and validate the data structure
val importData = val importData =
try { try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent) json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
@@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Invalid data format: ${e.message}") throw Exception("Invalid data format: ${e.message}")
} }
// Validate data integrity
validateImportData(importData) validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
// state updates
importData.gyms.forEach { backupGym -> importData.gyms.forEach { backupGym ->
try { try {
gymDao.insertGym(backupGym.toGym()) gymDao.insertGym(backupGym.toGym())
@@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
// Import problems with updated image paths
val updatedBackupProblems = val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths( ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
// Import problems (depends on gyms) - use DAO directly
updatedBackupProblems.forEach { backupProblem -> updatedBackupProblems.forEach { backupProblem ->
try { try {
problemDao.insertProblem(backupProblem.toProblem()) problemDao.insertProblem(backupProblem.toProblem())
@@ -235,7 +220,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
// Import sessions - use DAO directly
importData.sessions.forEach { backupSession -> importData.sessions.forEach { backupSession ->
try { try {
sessionDao.insertSession(backupSession.toClimbSession()) sessionDao.insertSession(backupSession.toClimbSession())
@@ -244,7 +228,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
// Import attempts last (depends on problems and sessions) - use DAO directly
importData.attempts.forEach { backupAttempt -> importData.attempts.forEach { backupAttempt ->
try { try {
attemptDao.insertAttempt(backupAttempt.toAttempt()) attemptDao.insertAttempt(backupAttempt.toAttempt())
@@ -253,7 +236,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
// Update data state once at the end to current time since we just imported new data
dataStateManager.updateDataState() dataStateManager.updateDataState()
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Import failed: ${e.message}") throw Exception("Import failed: ${e.message}")
@@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { if (invalidProblems.isNotEmpty()) {
@@ -291,7 +272,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
) )
} }
// Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception( throw Exception(
@@ -299,7 +279,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
) )
} }
// Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet() val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet()
@@ -321,7 +300,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Import data is invalid: no version information") throw Exception("Import data is invalid: no version information")
} }
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
@@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun resetAllData() { suspend fun resetAllData() {
try { try {
// Temporarily disable auto-sync during reset
val originalCallback = autoSyncCallback val originalCallback = autoSyncCallback
autoSyncCallback = null autoSyncCallback = null
// Clear all data from database
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages() clearAllImages()
// Restore auto-sync callback
autoSyncCallback = originalCallback autoSyncCallback = originalCallback
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Reset failed: ${e.message}") throw Exception("Reset failed: ${e.message}")
} }
} }
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
suspend fun insertGymWithoutSync(gym: Gym) { suspend fun insertGymWithoutSync(gym: Gym) {
gymDao.insertGym(gym) gymDao.insertGym(gym)
dataStateManager.updateDataState() dataStateManager.updateDataState()
@@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private fun clearAllImages() { private fun clearAllImages() {
try { try {
// Get the images directory
val imagesDir = File(context.filesDir, "images") val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) { if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0 val deletedCount = imagesDir.listFiles()?.size ?: 0

View File

@@ -22,7 +22,6 @@ class DataStateManager(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init { init {
// Initialize with current timestamp if this is the first time
if (!isInitialized()) { if (!isInitialized()) {
updateDataState() updateDataState()
markAsInitialized() markAsInitialized()

View File

@@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupGym
@@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.edit
class SyncService(private val context: Context, private val repository: ClimbRepository) { class SyncService(private val context: Context, private val repository: ClimbRepository) {
@@ -61,7 +61,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
coerceInputValues = true coerceInputValues = true
} }
// State flows // State
private val _isSyncing = MutableStateFlow(false) private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow() val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
@@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
init { init {
// Initialize state from preferences
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
// Register auto-sync callback with repository
repository.setAutoSyncCallback { repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
triggerAutoSync()
}
} }
} }
@@ -153,7 +149,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
"Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}" "Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}"
) )
// Log problems with images
backup.problems.forEach { problem -> backup.problems.forEach { problem ->
val imageCount = problem.imagePaths?.size ?: 0 val imageCount = problem.imagePaths?.size ?: 0
if (imageCount > 0) { if (imageCount > 0) {
@@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
throw SyncException.NotConfigured throw SyncException.NotConfigured
} }
// Server expects filename as query parameter and raw image data in body
// Extract just the filename without directory path
val justFilename = filename.substringAfterLast('/') val justFilename = filename.substringAfterLast('/')
val requestBody = imageData.toRequestBody("image/*".toMediaType()) val requestBody = imageData.toRequestBody("image/*".toMediaType())
@@ -252,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val response = httpClient.newCall(request).execute() val response = httpClient.newCall(request).execute()
when (response.code) { when (response.code) {
200 -> Unit // Success 200 -> Unit
401 -> throw SyncException.Unauthorized 401 -> throw SyncException.Unauthorized
else -> { else -> {
val errorBody = response.body?.string() ?: "No error details" val errorBody = response.body?.string() ?: "No error details"
@@ -325,33 +318,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep
throw SyncException.NotConnected throw SyncException.NotConnected
} }
// Prevent concurrent sync operations
syncMutex.withLock { syncMutex.withLock {
_isSyncing.value = true _isSyncing.value = true
_syncError.value = null _syncError.value = null
try { try {
// Fix existing image paths first
Log.d(TAG, "Fixing existing image paths before sync") Log.d(TAG, "Fixing existing image paths before sync")
val pathFixSuccess = fixImagePaths() val pathFixSuccess = fixImagePaths()
if (!pathFixSuccess) { if (!pathFixSuccess) {
Log.w(TAG, "Image path fix failed, but continuing with sync") Log.w(TAG, "Image path fix failed, but continuing with sync")
} }
// Migrate images to consistent naming second
Log.d(TAG, "Performing image migration before sync") Log.d(TAG, "Performing image migration before sync")
val migrationSuccess = migrateImagesForSync() val migrationSuccess = migrateImagesForSync()
if (!migrationSuccess) { if (!migrationSuccess) {
Log.w(TAG, "Image migration failed, but continuing with sync") Log.w(TAG, "Image migration failed, but continuing with sync")
} }
// Get local backup data
val localBackup = createBackupFromRepository() val localBackup = createBackupFromRepository()
// Download server data
val serverBackup = downloadData() val serverBackup = downloadData()
// Check if we have any local data
val hasLocalData = val hasLocalData =
localBackup.gyms.isNotEmpty() || localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() || localBackup.problems.isNotEmpty() ||
@@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
when { when {
!hasLocalData && hasServerData -> { !hasLocalData && hasServerData -> {
// Case 1: No local data - do full restore from server
Log.d(TAG, "No local data found, performing full restore from server") Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed") Log.d(TAG, "Full restore completed")
} }
hasLocalData && !hasServerData -> { hasLocalData && !hasServerData -> {
// Case 2: No server data - upload local data to server
Log.d(TAG, "No server data found, uploading local data to server") Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup) uploadData(localBackup)
syncImagesForBackup(localBackup) syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed") Log.d(TAG, "Initial upload completed")
} }
hasLocalData && hasServerData -> { hasLocalData && hasServerData -> {
// Case 3: Both have data - compare timestamps (last writer wins)
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
@@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) )
if (localTimestamp > serverTimestamp) { if (localTimestamp > serverTimestamp) {
// Local is newer - replace server with local data
Log.d(TAG, "Local data is newer, replacing server content") Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup) uploadData(localBackup)
syncImagesForBackup(localBackup) syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data") Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) { } else if (serverTimestamp > localTimestamp) {
// Server is newer - replace local with server data
Log.d(TAG, "Server data is newer, replacing local content") Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data") Log.d(TAG, "Local data replaced with server data")
} else { } else {
// Timestamps are equal - no sync needed
Log.d(TAG, "Data is in sync (timestamps equal), no action needed") Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
} }
} }
@@ -411,7 +392,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Update last sync time
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now _lastSyncTime.value = now
sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply()
@@ -447,13 +427,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Attempting to download image: $imagePath") Log.d(TAG, "Attempting to download image: $imagePath")
val imageData = downloadImage(imagePath) val imageData = downloadImage(imagePath)
// Extract filename and ensure it follows our naming convention
val serverFilename = imagePath.substringAfterLast('/') val serverFilename = imagePath.substringAfterLast('/')
val consistentFilename = val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(serverFilename)) { if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
serverFilename serverFilename
} else { } else {
// Generate consistent filename using problem ID and index
ImageNamingUtils.generateImageFilename(problem.id, index) ImageNamingUtils.generateImageFilename(problem.id, index)
} }
@@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) )
if (localImagePath != null) { if (localImagePath != null) {
// Map original server filename to the full local relative path
imagePathMapping[serverFilename] = localImagePath imagePathMapping[serverFilename] = localImagePath
downloadedImages++ downloadedImages++
Log.d( Log.d(
@@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val imageData = imageFile.readBytes() val imageData = imageFile.readBytes()
val filename = imagePath.substringAfterLast('/') val filename = imagePath.substringAfterLast('/')
// Ensure filename follows our naming convention
val consistentFilename = val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(filename)) { if (ImageNamingUtils.isValidImageFilename(filename)) {
filename filename
} else { } else {
// Generate consistent filename and rename the local file
val newFilename = val newFilename =
ImageNamingUtils.generateImageFilename( ImageNamingUtils.generateImageFilename(
problem.id, problem.id,
@@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
TAG, TAG,
"Renamed local image file: $filename -> $newFilename" "Renamed local image file: $filename -> $newFilename"
) )
// Update the problem's image path in memory for next sync
newFilename newFilename
} else { } else {
Log.w( Log.w(
@@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backup: ClimbDataBackup, backup: ClimbDataBackup,
imagePathMapping: Map<String, String> = emptyMap() imagePathMapping: Map<String, String> = emptyMap()
) { ) {
// Clear existing data to avoid conflicts
repository.resetAllData() repository.resetAllData()
// Import gyms first (problems depend on gyms)
backup.gyms.forEach { backupGym -> backup.gyms.forEach { backupGym ->
try { try {
val gym = backupGym.toGym() val gym = backupGym.toGym()
@@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.insertGymWithoutSync(gym) repository.insertGymWithoutSync(gym)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
throw e // Stop import if gym fails since problems depend on it throw e
} }
} }
// Import problems with updated image paths
backup.problems.forEach { backupProblem -> backup.problems.forEach { backupProblem ->
try { try {
val updatedProblem = val updatedProblem =
if (imagePathMapping.isNotEmpty()) { if (imagePathMapping.isNotEmpty()) {
val newImagePaths = val newImagePaths =
backupProblem.imagePaths?.map { oldPath -> backupProblem.imagePaths?.map { oldPath ->
// Extract filename and check mapping
val filename = oldPath.substringAfterLast('/') val filename = oldPath.substringAfterLast('/')
// Use mapped full path or fallback to consistent naming
// with full path
imagePathMapping[filename] imagePathMapping[filename]
?: if (ImageNamingUtils.isValidImageFilename( ?: if (ImageNamingUtils.isValidImageFilename(
filename filename
@@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) { ) {
"problem_images/$filename" "problem_images/$filename"
} else { } else {
// Generate consistent filename as fallback with
// full path
val index = val index =
backupProblem.imagePaths.indexOf( backupProblem.imagePaths.indexOf(
oldPath oldPath
@@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Import sessions
backup.sessions.forEach { backupSession -> backup.sessions.forEach { backupSession ->
try { try {
repository.insertSessionWithoutSync(backupSession.toClimbSession()) repository.insertSessionWithoutSync(backupSession.toClimbSession())
@@ -656,7 +622,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Import attempts last
backup.attempts.forEach { backupAttempt -> backup.attempts.forEach { backupAttempt ->
try { try {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
@@ -665,7 +630,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// Update local data state to match imported data timestamp
dataStateManager.setLastModified(backup.exportedAt) dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
} }
@@ -697,7 +661,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val fixedPaths = val fixedPaths =
problem.imagePaths.map { path -> problem.imagePaths.map { path ->
if (!path.startsWith("problem_images/") && !path.contains("/")) { if (!path.startsWith("problem_images/") && !path.contains("/")) {
// Just a filename, add the directory prefix
val fixedPath = "problem_images/$path" val fixedPath = "problem_images/$path"
Log.d(TAG, "Fixed path: $path -> $fixedPath") Log.d(TAG, "Fixed path: $path -> $fixedPath")
fixedCount++ fixedCount++
@@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
return return
} }
// Check if sync is already running to prevent duplicate attempts
if (_isSyncing.value) { if (_isSyncing.value) {
Log.d(TAG, "Sync already in progress, skipping auto-sync") Log.d(TAG, "Sync already in progress, skipping auto-sync")
return return

View File

@@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed class Screen { sealed class Screen {
@Serializable @Serializable data object Sessions : Screen()
data object Sessions : Screen()
@Serializable @Serializable data object Problems : Screen()
data object Problems : Screen()
@Serializable @Serializable data object Analytics : Screen()
data object Analytics : Screen()
@Serializable @Serializable data object Gyms : Screen()
data object Gyms : Screen()
@Serializable @Serializable data object Settings : Screen()
data object Settings : Screen()
// Detail screens @Serializable data class SessionDetail(val sessionId: String) : Screen()
@Serializable
data class SessionDetail(val sessionId: String) : Screen()
@Serializable @Serializable data class ProblemDetail(val problemId: String) : Screen()
data class ProblemDetail(val problemId: String) : Screen()
@Serializable @Serializable data class GymDetail(val gymId: String) : Screen()
data class GymDetail(val gymId: String) : Screen()
@Serializable @Serializable data class AddEditGym(val gymId: String? = null) : Screen()
data class AddEditGym(val gymId: String? = null) : Screen()
@Serializable @Serializable
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen() data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
@Serializable @Serializable
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen() data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
} }

View File

@@ -47,11 +47,9 @@ fun OpenClimbApp(
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService)) viewModel(factory = ClimbViewModelFactory(repository, syncService))
// Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -75,13 +73,11 @@ fun OpenClimbApp(
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) } LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
// Trigger auto-sync on app launch
LaunchedEffect(Unit) { syncService.triggerAutoSync() } LaunchedEffect(Unit) { syncService.triggerAutoSync() }
val activeSession by viewModel.activeSession.collectAsState() val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
// Update last used gym when gyms change
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) { if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym() lastUsedGym = viewModel.getLastUsedGym()
@@ -116,7 +112,6 @@ fun OpenClimbApp(
} }
} }
// Process shortcut actions after data is loaded
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( android.util.Log.d(
@@ -140,7 +135,6 @@ fun OpenClimbApp(
) )
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
// Try to get the last used gym from the intent or fallback to state
val targetGym = val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym ?: lastUsedGym
@@ -167,7 +161,6 @@ fun OpenClimbApp(
) )
} }
// Clear the shortcut action after processing to prevent repeated execution
onShortcutActionProcessed() onShortcutActionProcessed()
} }
} }
@@ -215,8 +208,6 @@ fun OpenClimbApp(
if (gyms.size == 1) { if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
// Always show gym selection for FAB when
// multiple gyms
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
@@ -362,7 +353,6 @@ fun OpenClimbApp(
} }
} }
// Notification permission dialog
if (showNotificationPermissionDialog) { if (showNotificationPermissionDialog) {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
@@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
// Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true launchSingleTop = true
// Don't restore state - always start fresh when switching tabs // Don't restore state - always start fresh when switching tabs
restoreState = false restoreState = false

View File

@@ -54,15 +54,15 @@ fun BarChart(
val chartWidth = size.width - padding * 2 val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2 val chartHeight = size.height - padding * 2
// Sort data by grade numeric value for proper ordering // Sort data by grade numeric value
val sortedData = data.sortedBy { it.gradeNumeric } val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value for scaling // Calculate max value
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1 val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions // Bar dimensions
val barCount = sortedData.size val barCount = sortedData.size
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing val totalSpacing = chartWidth * 0.2f
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2 val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount val barWidth = (chartWidth - totalSpacing) / barCount
@@ -106,25 +106,25 @@ fun BarChart(
size = androidx.compose.ui.geometry.Size(barWidth, barHeight) size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
) )
// Draw value on top of bar (if there's space) // Draw value on bar
if (dataPoint.value > 0) { if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString() val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp) val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle) val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough // Position text
val textY = val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) { if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar barY + 8.dp.toPx()
} else { } else {
barY - 4.dp.toPx() // Above bar barY - 4.dp.toPx()
} }
val textColor = val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) { if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar Color.White
} else { } else {
style.textColor // Regular color above bar style.textColor
} }
drawText( drawText(
@@ -166,7 +166,7 @@ private fun DrawScope.drawGrid(
) { ) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp) val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis) // Horizontal grid lines
val gridLines = val gridLines =
when { when {
maxValue <= 5 -> (0..maxValue).toList() maxValue <= 5 -> (0..maxValue).toList()

View File

@@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter
object DateFormatUtils { object DateFormatUtils {
/** // ISO 8601 formatter matching iOS date format exactly
* ISO 8601 formatter matching iOS date format exactly Produces dates like:
* "2025-09-07T22:00:40.014Z"
*/
private val ISO_FORMATTER = private val ISO_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC) DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC)
/** /** Get current timestamp in iOS-compatible ISO 8601 format */
* Get current timestamp in iOS-compatible ISO 8601 format
* @return Current timestamp as "2025-09-07T22:00:40.014Z"
*/
fun nowISO8601(): String { fun nowISO8601(): String {
return ISO_FORMATTER.format(Instant.now()) return ISO_FORMATTER.format(Instant.now())
} }
/** /** Format an Instant to iOS-compatible ISO 8601 format */
* Format an Instant to iOS-compatible ISO 8601 format
* @param instant The instant to format
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
*/
fun formatISO8601(instant: Instant): String { fun formatISO8601(instant: Instant): String {
return ISO_FORMATTER.format(instant) return ISO_FORMATTER.format(instant)
} }
/** /** Parse an iOS-compatible ISO 8601 date string back to Instant */
* Parse an iOS-compatible ISO 8601 date string back to Instant
* @param dateString ISO 8601 formatted date string
* @return Instant object, or null if parsing fails
*/
fun parseISO8601(dateString: String): Instant? { fun parseISO8601(dateString: String): Instant? {
return try { return try {
Instant.from(ISO_FORMATTER.parse(dateString)) Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) { } catch (e: Exception) {
// Fallback - try standard Instant parsing
try { try {
Instant.parse(dateString) Instant.parse(dateString)
} catch (e2: Exception) { } catch (e2: Exception) {
@@ -48,20 +34,12 @@ object DateFormatUtils {
} }
} }
/** /** Validate that a date string matches the expected iOS format */
* Validate that a date string matches the expected iOS format
* @param dateString The date string to validate
* @return True if the format matches iOS expectations
*/
fun isValidISO8601(dateString: String): Boolean { fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null return parseISO8601(dateString) != null
} }
/** /** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
* Convert milliseconds timestamp to iOS-compatible ISO 8601 format
* @param millis Milliseconds since epoch
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
*/
fun millisToISO8601(millis: Long): String { fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
} }

View File

@@ -12,15 +12,7 @@ object ImageNamingUtils {
private const val IMAGE_EXTENSION = ".jpg" private const val IMAGE_EXTENSION = ".jpg"
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256 private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/** /** Generates a deterministic filename for a problem image */
* Generates a deterministic filename for a problem image. Format:
* "problem_{problemId}_{timestamp}_{index}.jpg"
*
* @param problemId The ID of the problem this image belongs to
* @param timestamp ISO8601 timestamp when the image was created
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
* @return A consistent filename that will be the same across platforms
*/
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index // Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}" val input = "${problemId}_${timestamp}_${imageIndex}"
@@ -29,25 +21,13 @@ object ImageNamingUtils {
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}" return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
} }
/** /** Generates a deterministic filename using current timestamp */
* Generates a deterministic filename for a problem image using current timestamp.
*
* @param problemId The ID of the problem this image belongs to
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
* @return A consistent filename
*/
fun generateImageFilename(problemId: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601() val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex) return generateImageFilename(problemId, timestamp, imageIndex)
} }
/** /** Extracts problem ID from an image filename */
* Extracts problem ID from an image filename created by this utility. Returns null if the
* filename doesn't match our naming convention.
*
* @param filename The image filename
* @return The problem ID or null if not a valid filename
*/
fun extractProblemIdFromFilename(filename: String): String? { fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null return null
@@ -66,12 +46,7 @@ object ImageNamingUtils {
return parts[1] // Return the hash as identifier return parts[1] // Return the hash as identifier
} }
/** /** Validates if a filename follows our naming convention */
* Validates if a filename follows our naming convention.
*
* @param filename The filename to validate
* @return true if it matches our convention, false otherwise
*/
fun isValidImageFilename(filename: String): Boolean { fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false return false
@@ -86,15 +61,7 @@ object ImageNamingUtils {
parts[2].toIntOrNull() != null parts[2].toIntOrNull() != null
} }
/** /** Migrates an existing filename to our naming convention */
* Migrates an existing UUID-based filename to our naming convention. This is used during sync
* to rename downloaded images.
*
* @param oldFilename The existing filename (UUID-based)
* @param problemId The problem ID this image belongs to
* @param imageIndex The index of this image
* @return The new filename following our convention
*/
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it // If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) { if (isValidImageFilename(oldFilename)) {
@@ -107,13 +74,7 @@ object ImageNamingUtils {
return generateImageFilename(problemId, timestamp, imageIndex) return generateImageFilename(problemId, timestamp, imageIndex)
} }
/** /** Creates a deterministic hash from input string */
* Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters
* for filename safety.
*
* @param input The input string to hash
* @return First 12 characters of SHA-256 hash in lowercase
*/
private fun createHash(input: String): String { private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
@@ -121,14 +82,7 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH) return hashHex.take(HASH_LENGTH)
} }
/** /** Batch renames images for a problem to use our naming convention */
* Batch renames images for a problem to use our naming convention. Returns a mapping of old
* filename -> new filename.
*
* @param problemId The problem ID
* @param existingFilenames List of current image filenames for this problem
* @return Map of old filename to new filename
*/
fun batchRenameForProblem( fun batchRenameForProblem(
problemId: String, problemId: String,
existingFilenames: List<String> existingFilenames: List<String>

View File

@@ -16,7 +16,7 @@ object ImageUtils {
private const val MAX_IMAGE_SIZE = 1024 private const val MAX_IMAGE_SIZE = 1024
private const val IMAGE_QUALITY = 85 private const val IMAGE_QUALITY = 85
/** Creates the images directory if it doesn't exist */ // Creates the images directory if it doesn't exist
private fun getImagesDirectory(context: Context): File { private fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR) val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) { if (!imagesDir.exists()) {
@@ -25,14 +25,7 @@ object ImageUtils {
return imagesDir return imagesDir
} }
/** /** Saves an image from a URI with compression and proper orientation */
* Saves an image from a URI with compression and proper orientation
* @param context Android context
* @param imageUri URI of the image to save
* @param problemId The problem ID this image belongs to (optional)
* @param imageIndex The index of this image for the problem (optional)
* @return The relative file path if successful, null otherwise
*/
fun saveImageFromUri( fun saveImageFromUri(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
@@ -40,7 +33,7 @@ object ImageUtils {
imageIndex: Int? = null imageIndex: Int? = null
): String? { ): String? {
return try { return try {
// Decode bitmap from a fresh stream to avoid mark/reset dependency
val originalBitmap = val originalBitmap =
context.contentResolver.openInputStream(imageUri)?.use { input -> context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
@@ -50,7 +43,6 @@ object ImageUtils {
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap) val compressedBitmap = compressImage(orientedBitmap)
// Generate filename using naming convention if problem info provided
val filename = val filename =
if (problemId != null && imageIndex != null) { if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex) ImageNamingUtils.generateImageFilename(problemId, imageIndex)
@@ -59,19 +51,16 @@ object ImageUtils {
} }
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output -> FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
} }
// Clean up bitmaps
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) { if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() orientedBitmap.recycle()
} }
compressedBitmap.recycle() compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -162,12 +151,7 @@ object ImageUtils {
} }
} }
/** /** Gets the full file path for an image */
* Gets the full file path for an image
* @param context Android context
* @param relativePath The relative path returned by saveImageFromUri
* @return Full file path
*/
fun getImageFile(context: Context, relativePath: String): File { fun getImageFile(context: Context, relativePath: String): File {
// If relativePath already contains the directory, use it as-is // If relativePath already contains the directory, use it as-is
// Otherwise, assume it's just a filename and add the images directory // Otherwise, assume it's just a filename and add the images directory
@@ -179,12 +163,7 @@ object ImageUtils {
} }
} }
/** /** Deletes an image file */
* Deletes an image file
* @param context Android context
* @param relativePath The relative path of the image to delete
* @return true if deleted successfully, false otherwise
*/
fun deleteImage(context: Context, relativePath: String): Boolean { fun deleteImage(context: Context, relativePath: String): Boolean {
return try { return try {
val file = getImageFile(context, relativePath) val file = getImageFile(context, relativePath)
@@ -195,12 +174,7 @@ object ImageUtils {
} }
} }
/** /** Imports an image file from the import directory */
* Imports an image file from the import directory
* @param context Android context
* @param sourceFile The source image file to import
* @return The relative path in app storage, null if failed
*/
fun importImageFile(context: Context, sourceFile: File): String? { fun importImageFile(context: Context, sourceFile: File): String? {
return try { return try {
if (!sourceFile.exists()) return null if (!sourceFile.exists()) return null
@@ -218,11 +192,7 @@ object ImageUtils {
} }
} }
/** /** Gets all image files in the images directory */
* Gets all image files in the images directory
* @param context Android context
* @return List of relative paths for all images
*/
fun getAllImages(context: Context): List<String> { fun getAllImages(context: Context): List<String> {
return try { return try {
val imagesDir = getImagesDirectory(context) val imagesDir = getImagesDirectory(context)
@@ -242,12 +212,7 @@ object ImageUtils {
} }
} }
/** /** Saves an image from byte array to app's private storage */
* Saves an image from byte array to app's private storage
* @param context Android context
* @param imageData Byte array of the image data
* @return The relative file path if successful, null otherwise
*/
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try { return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
@@ -275,13 +240,7 @@ object ImageUtils {
} }
} }
/** /** Saves image data with a specific filename */
* Saves image data with a specific filename (used for sync to preserve server filenames)
* @param context Android context
* @param imageData The image data as byte array
* @param filename The specific filename to use (including extension)
* @return The relative file path if successful, null otherwise
*/
fun saveImageFromBytesWithFilename( fun saveImageFromBytesWithFilename(
context: Context, context: Context,
imageData: ByteArray, imageData: ByteArray,
@@ -312,13 +271,7 @@ object ImageUtils {
} }
} }
/** /** Migrates existing images to use consistent naming convention */
* Migrates existing images to use consistent naming convention
* @param context Android context
* @param problemId The problem ID these images belong to
* @param currentImagePaths List of current image paths for this problem
* @return Map of old path -> new path for successfully migrated images
*/
fun migrateImageNaming( fun migrateImageNaming(
context: Context, context: Context,
problemId: String, problemId: String,
@@ -349,12 +302,7 @@ object ImageUtils {
return migrationMap return migrationMap
} }
/** /** Batch migrates all images in the system to use consistent naming */
* Batch migrates all images in the system to use consistent naming
* @param context Android context
* @param problemImageMap Map of problem ID -> list of current image paths
* @return Map of old path -> new path for all migrated images
*/
fun batchMigrateAllImages( fun batchMigrateAllImages(
context: Context, context: Context,
problemImageMap: Map<String, List<String>> problemImageMap: Map<String, List<String>>
@@ -369,11 +317,7 @@ object ImageUtils {
return allMigrations return allMigrations
} }
/** /** Cleans up orphaned images that are not referenced by any problems */
* Cleans up orphaned images that are not referenced by any problems
* @param context Android context
* @param referencedPaths Set of image paths that are still being used
*/
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) { fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try { try {
val allImages = getAllImages(context) val allImages = getAllImages(context)

View File

@@ -20,14 +20,7 @@ object ZipExportImportUtils {
private const val IMAGES_DIR_NAME = "images" private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt" private const val METADATA_FILENAME = "metadata.txt"
/** /** Creates a ZIP file containing the JSON data and all referenced images */
* Creates a ZIP file containing the JSON data and all referenced images
* @param context Android context
* @param exportData The data to export (should be serializable)
* @param referencedImagePaths Set of image paths referenced in the data
* @param directory Optional directory to save to, uses default if null
* @return The created ZIP file
*/
fun createExportZip( fun createExportZip(
context: Context, context: Context,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
@@ -120,13 +113,7 @@ object ZipExportImportUtils {
} }
} }
/** /** Creates a ZIP file and writes it to a provided URI */
* Creates a ZIP file and writes it to a provided URI
* @param context Android context
* @param uri The URI to write to
* @param exportData The data to export
* @param referencedImagePaths Set of image paths referenced in the data
*/
fun createExportZipToUri( fun createExportZipToUri(
context: Context, context: Context,
uri: android.net.Uri, uri: android.net.Uri,
@@ -214,12 +201,7 @@ object ZipExportImportUtils {
val importedImagePaths: Map<String, String> // original filename -> new relative path val importedImagePaths: Map<String, String> // original filename -> new relative path
) )
/** /** Extracts a ZIP file and returns the JSON content and imported image paths */
* Extracts a ZIP file and returns the JSON content and imported image paths
* @param context Android context
* @param zipFile The ZIP file to extract
* @return ImportResult containing the JSON and image path mappings
*/
fun extractImportZip(context: Context, zipFile: File): ImportResult { fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = "" var jsonContent = ""
val importedImagePaths = mutableMapOf<String, String>() val importedImagePaths = mutableMapOf<String, String>()

View File

@@ -4,9 +4,6 @@
import Foundation import Foundation
// MARK: - Backup Format Specification v2.0 // MARK: - Backup Format Specification v2.0
// Platform-neutral backup format for cross-platform compatibility
// This format ensures portability between iOS and Android while maintaining
// platform-specific implementations
/// Root structure for OpenClimb backup data /// Root structure for OpenClimb backup data
struct ClimbDataBackup: Codable { struct ClimbDataBackup: Codable {
@@ -37,7 +34,7 @@ struct ClimbDataBackup: Codable {
} }
} }
/// Platform-neutral gym representation for backup/restore // Platform-neutral gym representation for backup/restore
struct BackupGym: Codable { struct BackupGym: Codable {
let id: String let id: String
let name: String let name: String
@@ -46,8 +43,8 @@ struct BackupGym: Codable {
let difficultySystems: [DifficultySystem] let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String] let customDifficultyGrades: [String]
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS Gym model /// Initialize from native iOS Gym model
init(from gym: Gym) { init(from gym: Gym) {
@@ -114,7 +111,7 @@ struct BackupGym: Codable {
} }
} }
/// Platform-neutral problem representation for backup/restore // Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable { struct BackupProblem: Codable {
let id: String let id: String
let gymId: String let gymId: String
@@ -128,8 +125,8 @@ struct BackupProblem: Codable {
let isActive: Bool let isActive: Bool
let dateSet: String? // ISO 8601 format let dateSet: String? // ISO 8601 format
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS Problem model /// Initialize from native iOS Problem model
init(from problem: Problem) { init(from problem: Problem) {
@@ -239,7 +236,7 @@ struct BackupProblem: Codable {
} }
} }
/// Platform-neutral climb session representation for backup/restore // Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable { struct BackupClimbSession: Codable {
let id: String let id: String
let gymId: String let gymId: String
@@ -249,8 +246,8 @@ struct BackupClimbSession: Codable {
let duration: Int64? // Duration in seconds let duration: Int64? // Duration in seconds
let status: SessionStatus let status: SessionStatus
let notes: String? let notes: String?
let createdAt: String // ISO 8601 format let createdAt: String
let updatedAt: String // ISO 8601 format let updatedAt: String
/// Initialize from native iOS ClimbSession model /// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) { init(from session: ClimbSession) {
@@ -327,7 +324,7 @@ struct BackupClimbSession: Codable {
} }
} }
/// Platform-neutral attempt representation for backup/restore // Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable { struct BackupAttempt: Codable {
let id: String let id: String
let sessionId: String let sessionId: String
@@ -337,8 +334,8 @@ struct BackupAttempt: Codable {
let notes: String? let notes: String?
let duration: Int64? // Duration in seconds let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds let restTime: Int64? // Rest time in seconds
let timestamp: String // ISO 8601 format let timestamp: String
let createdAt: String // ISO 8601 format let createdAt: String
/// Initialize from native iOS Attempt model /// Initialize from native iOS Attempt model
init(from attempt: Attempt) { init(from attempt: Attempt) {

View File

@@ -3,8 +3,7 @@
import Foundation import Foundation
/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the /// Manages the overall data state timestamp for sync purposes
/// local database was last modified, independent of individual entity timestamps.
class DataStateManager { class DataStateManager {
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
@@ -14,7 +13,6 @@ class DataStateManager {
static let initialized = "openclimb_data_state_initialized" static let initialized = "openclimb_data_state_initialized"
} }
/// Shared instance for app-wide use
static let shared = DataStateManager() static let shared = DataStateManager()
private init() { private init() {

View File

@@ -4,54 +4,36 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
/// Utility for creating consistent image filenames across iOS and Android platforms. /// Utility for creating consistent image filenames across platforms
/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility.
class ImageNamingUtils { class ImageNamingUtils {
private static let imageExtension = ".jpg" private static let imageExtension = ".jpg"
private static let hashLength = 12 // First 12 chars of SHA-256 private static let hashLength = 12
/// Generates a deterministic filename for a problem image. /// 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) static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String -> String
{ {
// Create a deterministic hash from problemId + timestamp + index
let input = "\(problemId)_\(timestamp)_\(imageIndex)" let input = "\(problemId)_\(timestamp)_\(imageIndex)"
let hash = createHash(from: input) let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)" return "problem_\(hash)_\(imageIndex)\(imageExtension)"
} }
/// Generates a deterministic filename for a problem image using current timestamp. /// Generates a deterministic filename 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 { static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date()) let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename( return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
} }
/// Extracts problem ID from an image filename created by this utility. /// Extracts problem ID from an image filename
/// 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? { static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil return nil
} }
// Format: problem_{hash}_{index}.jpg
let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_") let parts = nameWithoutExtension.components(separatedBy: "_")
@@ -59,14 +41,10 @@ class ImageNamingUtils {
return nil return nil
} }
// Return the hash as identifier
return parts[1] return parts[1]
} }
/// Validates if a filename follows our naming convention. /// 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 { static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false return false
@@ -79,32 +57,19 @@ class ImageNamingUtils {
&& Int(parts[2]) != nil && Int(parts[2]) != nil
} }
/// Migrates an existing UUID-based filename to our naming convention. /// Migrates an existing 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 { static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
// If it's already using our convention, keep it
if isValidImageFilename(oldFilename) { if isValidImageFilename(oldFilename) {
return oldFilename return oldFilename
} }
// Generate new deterministic name
// Use current timestamp to maintain some consistency
let timestamp = ISO8601DateFormatter().string(from: Date()) let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename( return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
} }
/// Creates a deterministic hash from input string. /// 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 { private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8) let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData) let hashed = SHA256.hash(data: inputData)
@@ -112,13 +77,7 @@ class ImageNamingUtils {
return String(hashString.prefix(hashLength)) return String(hashString.prefix(hashLength))
} }
/// Batch renames images for a problem to use our naming convention. /// 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: static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String] String]
{ {
@@ -135,10 +94,7 @@ class ImageNamingUtils {
return renameMap return renameMap
} }
/// Validates that a collection of filenames follow our naming convention. /// 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 { static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = [] var validImages: [String] = []
var invalidImages: [String] = [] var invalidImages: [String] = []
@@ -159,7 +115,7 @@ class ImageNamingUtils {
} }
} }
/// Result of image filename validation // Result of image filename validation
struct ImageValidationResult { struct ImageValidationResult {
let totalImages: Int let totalImages: Int
let validImages: [String] let validImages: [String]