Cleanup
This commit is contained in:
39
README.md
39
README.md
@@ -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+
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 data object Problems : Screen()
|
||||||
@Serializable
|
|
||||||
data object Problems : Screen()
|
@Serializable data object Analytics : Screen()
|
||||||
|
|
||||||
@Serializable
|
@Serializable data object Gyms : Screen()
|
||||||
data object Analytics : Screen()
|
|
||||||
|
@Serializable data object Settings : Screen()
|
||||||
@Serializable
|
|
||||||
data object Gyms : Screen()
|
@Serializable data class SessionDetail(val sessionId: String) : Screen()
|
||||||
|
|
||||||
@Serializable
|
@Serializable data class ProblemDetail(val problemId: String) : Screen()
|
||||||
data object Settings : Screen()
|
|
||||||
|
@Serializable data class GymDetail(val gymId: String) : Screen()
|
||||||
// Detail screens
|
|
||||||
@Serializable
|
@Serializable data class AddEditGym(val gymId: String? = null) : Screen()
|
||||||
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
|
@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()
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user