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.
## 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
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)!
## 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
- Android 12+ or iOS 17+

View File

@@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable
/** Root structure for OpenClimb backup data */
// Root structure for OpenClimb backup data
@Serializable
data class ClimbDataBackup(
val exportedAt: String,
@@ -15,7 +15,7 @@ data class ClimbDataBackup(
val attempts: List<BackupAttempt>
)
/** Platform-neutral gym representation for backup/restore */
// Platform-neutral gym representation for backup/restore
@Serializable
data class BackupGym(
val id: String,
@@ -26,8 +26,8 @@ data class BackupGym(
@kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** 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
data class BackupProblem(
val id: String,
@@ -75,10 +75,10 @@ data class BackupProblem(
val location: String? = null,
val imagePaths: List<String>? = null,
val isActive: Boolean = true,
val dateSet: String? = null, // ISO 8601 format
val dateSet: String? = null,
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupProblem from native Android Problem model */
@@ -94,11 +94,7 @@ data class BackupProblem(
location = problem.location,
imagePaths =
if (problem.imagePaths.isEmpty()) null
else
problem.imagePaths.map { path ->
// Store just the filename to match iOS format
path.substringAfterLast('/')
},
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
isActive = problem.isActive,
dateSet = problem.dateSet,
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
data class BackupClimbSession(
val id: String,
val gymId: String,
val date: String, // ISO 8601 format
val startTime: String? = null, // ISO 8601 format
val endTime: String? = null, // ISO 8601 format
val duration: Long? = null, // Duration in seconds
val date: String,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val status: SessionStatus,
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** 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
data class BackupAttempt(
val id: String,
@@ -192,10 +188,11 @@ data class BackupAttempt(
val result: AttemptResult,
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null, // Duration in seconds
val restTime: Long? = null, // Rest time in seconds
val timestamp: String, // ISO 8601 format
val createdAt: String // ISO 8601 format
val duration: Long? = null,
val restTime: Long? = null,
val timestamp: String,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** 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)
migratedCount += problemMigrations.size
// Update problem with new image paths
// Update image paths
val newImagePaths =
problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath
@@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository
continue
}
// Check if filename follows our convention
// Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath)
} else {

View File

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

View File

@@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Bouldering
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 8C+)
V_SCALE,
FONT,
// Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d)
// Custom difficulty systems
YDS,
CUSTOM;
/** Get the display name for the UI */
@@ -28,7 +26,7 @@ enum class DifficultySystem {
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true // Custom is available for all
CUSTOM -> true
}
/** 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
}
DifficultySystem.YDS -> {
// Simplified numeric mapping for YDS grades
when {
grade.startsWith("5.10") ->
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 -> {
// Simplified Font grade mapping
when {
grade.startsWith("6A") -> 6
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 {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
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 dataStateManager = DataStateManager(context)
// Callback interface for auto-sync functionality
private var autoSyncCallback: (() -> Unit)? = null
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) {
try {
// Collect all data
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
@@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
@@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun importDataFromZip(file: File) {
try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
// Parse and validate the data structure
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
@@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity
validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
// state updates
importData.gyms.forEach { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
@@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
// Import problems with updated image paths
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
// Import problems (depends on gyms) - use DAO directly
updatedBackupProblems.forEach { backupProblem ->
try {
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 ->
try {
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 ->
try {
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()
} catch (e: Exception) {
throw Exception("Import failed: ${e.message}")
@@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
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 }
if (invalidSessions.isNotEmpty()) {
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 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")
}
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
@@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun resetAllData() {
try {
// Temporarily disable auto-sync during reset
val originalCallback = autoSyncCallback
autoSyncCallback = null
// Clear all data from database
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages()
// Restore auto-sync callback
autoSyncCallback = originalCallback
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
suspend fun insertGymWithoutSync(gym: Gym) {
gymDao.insertGym(gym)
dataStateManager.updateDataState()
@@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private fun clearAllImages() {
try {
// Get the images directory
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,15 +54,15 @@ fun BarChart(
val chartWidth = size.width - 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 }
// Calculate max value for scaling
// Calculate max value
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions
// Bar dimensions
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 barWidth = (chartWidth - totalSpacing) / barCount
@@ -106,25 +106,25 @@ fun BarChart(
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) {
val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough
// Position text
val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar
barY + 8.dp.toPx()
} else {
barY - 4.dp.toPx() // Above bar
barY - 4.dp.toPx()
}
val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar
Color.White
} else {
style.textColor // Regular color above bar
style.textColor
}
drawText(
@@ -166,7 +166,7 @@ private fun DrawScope.drawGrid(
) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis)
// Horizontal grid lines
val gridLines =
when {
maxValue <= 5 -> (0..maxValue).toList()

View File

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

View File

@@ -12,15 +12,7 @@ object ImageNamingUtils {
private const val IMAGE_EXTENSION = ".jpg"
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/**
* 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
*/
/** Generates a deterministic filename for a problem image */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}"
@@ -29,25 +21,13 @@ object ImageNamingUtils {
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
}
/**
* 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
*/
/** Generates a deterministic filename using current timestamp */
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
}
/**
* 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
*/
/** Extracts problem ID from an image filename */
fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null
@@ -66,12 +46,7 @@ object ImageNamingUtils {
return parts[1] // Return the hash as identifier
}
/**
* Validates if a filename follows our naming convention.
*
* @param filename The filename to validate
* @return true if it matches our convention, false otherwise
*/
/** Validates if a filename follows our naming convention */
fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false
@@ -86,15 +61,7 @@ object ImageNamingUtils {
parts[2].toIntOrNull() != null
}
/**
* 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
*/
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) {
@@ -107,13 +74,7 @@ object ImageNamingUtils {
return generateImageFilename(problemId, timestamp, imageIndex)
}
/**
* 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
*/
/** Creates a deterministic hash from input string */
private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
@@ -121,14 +82,7 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH)
}
/**
* 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
*/
/** Batch renames images for a problem to use our naming convention */
fun batchRenameForProblem(
problemId: String,
existingFilenames: List<String>

View File

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

View File

@@ -20,14 +20,7 @@ object ZipExportImportUtils {
private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/**
* 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
*/
/** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip(
context: Context,
exportData: ClimbDataBackup,
@@ -120,13 +113,7 @@ object ZipExportImportUtils {
}
}
/**
* 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
*/
/** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri(
context: Context,
uri: android.net.Uri,
@@ -214,12 +201,7 @@ object ZipExportImportUtils {
val importedImagePaths: Map<String, String> // original filename -> new relative path
)
/**
* 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
*/
/** Extracts a ZIP file and returns the JSON content and imported image paths */
fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = ""
val importedImagePaths = mutableMapOf<String, String>()

View File

@@ -4,9 +4,6 @@
import Foundation
// 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
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 {
let id: String
let name: String
@@ -46,8 +43,8 @@ struct BackupGym: Codable {
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Gym model
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 {
let id: String
let gymId: String
@@ -128,8 +125,8 @@ struct BackupProblem: Codable {
let isActive: Bool
let dateSet: String? // ISO 8601 format
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Problem model
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 {
let id: String
let gymId: String
@@ -249,8 +246,8 @@ struct BackupClimbSession: Codable {
let duration: Int64? // Duration in seconds
let status: SessionStatus
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS ClimbSession model
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 {
let id: String
let sessionId: String
@@ -337,8 +334,8 @@ struct BackupAttempt: Codable {
let notes: String?
let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds
let timestamp: String // ISO 8601 format
let createdAt: String // ISO 8601 format
let timestamp: String
let createdAt: String
/// Initialize from native iOS Attempt model
init(from attempt: Attempt) {

View File

@@ -3,8 +3,7 @@
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.
/// Manages the overall data state timestamp for sync purposes
class DataStateManager {
private let userDefaults = UserDefaults.standard
@@ -14,7 +13,6 @@ class DataStateManager {
static let initialized = "openclimb_data_state_initialized"
}
/// Shared instance for app-wide use
static let shared = DataStateManager()
private init() {

View File

@@ -4,54 +4,36 @@
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.
/// Utility for creating consistent image filenames across platforms
class ImageNamingUtils {
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.
/// 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
/// Generates a deterministic filename for a problem image
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
/// Generates a deterministic filename using current timestamp
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
/// Extracts problem ID from an image 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: "_")
@@ -59,14 +41,10 @@ class ImageNamingUtils {
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
/// Validates if a filename follows our naming convention
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
@@ -79,32 +57,19 @@ class ImageNamingUtils {
&& 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
/// Migrates an existing filename to our naming 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
/// Creates a deterministic hash from input string
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
@@ -112,13 +77,7 @@ class ImageNamingUtils {
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
/// Batch renames images for a problem to use our naming convention
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
@@ -135,10 +94,7 @@ class ImageNamingUtils {
return renameMap
}
/// Validates that a collection of filenames follow our naming convention.
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
/// Validates that a collection of filenames follow our naming convention
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
@@ -159,7 +115,7 @@ class ImageNamingUtils {
}
}
/// Result of image filename validation
// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]