diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e6b1e5..c928a22 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 46 - versionName = "2.2.1" + versionCode = 4 + versionName = "2.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -38,7 +38,10 @@ android { java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } - buildFeatures { compose = true } + buildFeatures { + compose = true + buildConfig = true + } } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt index 305b1dd..c6e54b5 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt @@ -3,7 +3,7 @@ package com.atridad.ascently.data.health import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences -import android.util.Log +import com.atridad.ascently.utils.AppLogger import androidx.activity.result.contract.ActivityResultContract import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.PermissionController @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flow class HealthConnectManager(private val context: Context) { private val preferences: SharedPreferences = - context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE) + context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE) private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false)) private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false)) @@ -46,21 +46,21 @@ class HealthConnectManager(private val context: Context) { private const val TAG = "HealthConnectManager" val REQUIRED_PERMISSIONS = - setOf( - HealthPermission.getReadPermission(ExerciseSessionRecord::class), - HealthPermission.getWritePermission(ExerciseSessionRecord::class), - HealthPermission.getReadPermission(HeartRateRecord::class), - HealthPermission.getWritePermission(HeartRateRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) - ) + setOf( + HealthPermission.getReadPermission(ExerciseSessionRecord::class), + HealthPermission.getWritePermission(ExerciseSessionRecord::class), + HealthPermission.getReadPermission(HeartRateRecord::class), + HealthPermission.getWritePermission(HeartRateRecord::class), + HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), + HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) + ) } private val healthConnectClient by lazy { try { HealthConnectClient.getOrCreate(context) } catch (e: Exception) { - Log.e(TAG, "Failed to create Health Connect client", e) + AppLogger.e(TAG, e) { "Failed to create Health Connect client" } _isCompatible.value = false null } @@ -75,7 +75,7 @@ class HealthConnectManager(private val context: Context) { val status = HealthConnectClient.getSdkStatus(context) emit(status == HealthConnectClient.SDK_AVAILABLE) } catch (e: Exception) { - Log.e(TAG, "Error checking Health Connect availability", e) + AppLogger.e(TAG, e) { "Error checking Health Connect availability" } _isCompatible.value = false emit(false) } @@ -90,10 +90,10 @@ class HealthConnectManager(private val context: Context) { try { val alreadyHasPermissions = hasAllPermissions() if (!alreadyHasPermissions) { - Log.d(TAG, "Health Connect enabled - permissions will be requested by UI") + AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" } } } catch (e: Exception) { - Log.w(TAG, "Error checking permissions when enabling Health Connect", e) + AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" } } } else if (!enabled) { setPermissionsGranted(false) @@ -111,15 +111,15 @@ class HealthConnectManager(private val context: Context) { return false } val grantedPermissions = - healthConnectClient!!.permissionController.getGrantedPermissions() + healthConnectClient!!.permissionController.getGrantedPermissions() val hasAll = - REQUIRED_PERMISSIONS.all { permission -> - grantedPermissions.contains(permission) - } + REQUIRED_PERMISSIONS.all { permission -> + grantedPermissions.contains(permission) + } setPermissionsGranted(hasAll) hasAll } catch (e: Exception) { - Log.e(TAG, "Error checking permissions", e) + AppLogger.e(TAG, e) { "Error checking permissions" } setPermissionsGranted(false) false } @@ -128,14 +128,14 @@ class HealthConnectManager(private val context: Context) { suspend fun isReady(): Boolean { return try { if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null) - return false + return false val isAvailable = - HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE + HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE val hasPerms = if (isAvailable) hasAllPermissions() else false isAvailable && hasPerms } catch (e: Exception) { - Log.e(TAG, "Error checking Health Connect readiness", e) + AppLogger.e(TAG, e) { "Error checking Health Connect readiness" } false } } @@ -148,27 +148,27 @@ class HealthConnectManager(private val context: Context) { return try { REQUIRED_PERMISSIONS.map { it }.toSet() } catch (e: Exception) { - Log.e(TAG, "Error getting required permissions", e) + AppLogger.e(TAG, e) { "Error getting required permissions" } emptySet() } } @SuppressLint("RestrictedApi") suspend fun syncCompletedSession( - session: ClimbSession, - gymName: String, - attemptCount: Int = 0 + session: ClimbSession, + gymName: String, + attemptCount: Int = 0 ): Result { return try { if (!isReady() || !_autoSync.value) { return Result.failure( - IllegalStateException("Health Connect not ready or auto-sync disabled") + IllegalStateException("Health Connect not ready or auto-sync disabled") ) } if (session.status != SessionStatus.COMPLETED) { return Result.failure( - IllegalArgumentException("Only completed sessions can be synced") + IllegalArgumentException("Only completed sessions can be synced") ) } @@ -177,29 +177,29 @@ class HealthConnectManager(private val context: Context) { if (startTime == null || endTime == null) { return Result.failure( - IllegalArgumentException("Session must have valid start and end times") + IllegalArgumentException("Session must have valid start and end times") ) } - Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...") + AppLogger.d(TAG) { "Attempting to sync session '${session.id}' to Health Connect..." } val records = mutableListOf() try { val exerciseSession = - ExerciseSessionRecord( - startTime = startTime, - startZoneOffset = - ZoneOffset.systemDefault().rules.getOffset(startTime), - endTime = endTime, - endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), - exerciseType = - ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, - title = "Rock Climbing at $gymName" - ) + ExerciseSessionRecord( + startTime = startTime, + startZoneOffset = + ZoneOffset.systemDefault().rules.getOffset(startTime), + endTime = endTime, + endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), + exerciseType = + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + title = "Rock Climbing at $gymName" + ) records.add(exerciseSession) } catch (e: Exception) { - Log.w(TAG, "Failed to create exercise session record", e) + AppLogger.w(TAG, e) { "Failed to create exercise session record" } } try { @@ -208,75 +208,74 @@ class HealthConnectManager(private val context: Context) { if (estimatedCalories > 0) { val caloriesRecord = - TotalCaloriesBurnedRecord( - startTime = startTime, - startZoneOffset = - ZoneOffset.systemDefault().rules.getOffset(startTime), - endTime = endTime, - endZoneOffset = - ZoneOffset.systemDefault().rules.getOffset(endTime), - energy = Energy.calories(estimatedCalories) - ) + TotalCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = + ZoneOffset.systemDefault().rules.getOffset(startTime), + endTime = endTime, + endZoneOffset = + ZoneOffset.systemDefault().rules.getOffset(endTime), + energy = Energy.calories(estimatedCalories) + ) records.add(caloriesRecord) } } catch (e: Exception) { - Log.w(TAG, "Failed to create calories record", e) + AppLogger.w(TAG, e) { "Failed to create calories record" } } try { val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount) heartRateRecord?.let { records.add(it) } } catch (e: Exception) { - Log.w(TAG, "Failed to create heart rate record", e) + AppLogger.w(TAG, e) { "Failed to create heart rate record" } } if (records.isNotEmpty() && healthConnectClient != null) { - Log.d(TAG, "Writing ${records.size} records to Health Connect...") + AppLogger.d(TAG) { "Writing ${records.size} records to Health Connect..." } healthConnectClient!!.insertRecords(records) - Log.i( - TAG, - "Successfully synced ${records.size} records for session '${session.id}' to Health Connect" - ) + AppLogger.i(TAG) { + "Successfully synced ${records.size} records for session '${session.id}' to Health Connect" + } preferences - .edit() - .putString("last_sync_success", DateFormatUtils.nowISO8601()) - .apply() + .edit() + .putString("last_sync_success", DateFormatUtils.nowISO8601()) + .apply() } else { val reason = - when { - records.isEmpty() -> "No records created" - healthConnectClient == null -> "Health Connect client unavailable" - else -> "Unknown reason" - } - Log.w(TAG, "Sync failed for session '${session.id}': $reason") + when { + records.isEmpty() -> "No records created" + healthConnectClient == null -> "Health Connect client unavailable" + else -> "Unknown reason" + } + AppLogger.w(TAG) { "Sync failed for session '${session.id}': $reason" } return Result.failure(Exception("Sync failed: $reason")) } Result.success(Unit) } catch (e: Exception) { - Log.e(TAG, "Error syncing climbing session to Health Connect", e) + AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" } Result.failure(e) } } suspend fun autoSyncCompletedSession( - session: ClimbSession, - gymName: String, - attemptCount: Int = 0 + session: ClimbSession, + gymName: String, + attemptCount: Int = 0 ): Result { return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) { - Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...") + AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." } syncCompletedSession(session, gymName, attemptCount) } else { val reason = - when { - session.status != SessionStatus.COMPLETED -> "session not completed" - !_autoSync.value -> "auto-sync disabled" - !isReady() -> "Health Connect not ready" - else -> "unknown reason" - } - Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason") + when { + session.status != SessionStatus.COMPLETED -> "session not completed" + !_autoSync.value -> "auto-sync disabled" + !isReady() -> "Health Connect not ready" + else -> "unknown reason" + } + AppLogger.d(TAG) { "Auto-sync skipped for session '${session.id}': $reason" } Result.success(Unit) } } @@ -284,30 +283,30 @@ class HealthConnectManager(private val context: Context) { private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double { val baseCaloriesPerMinute = 8.0 val intensityMultiplier = - when { - attemptCount >= 20 -> 1.3 - attemptCount >= 10 -> 1.1 - else -> 0.9 - } + when { + attemptCount >= 20 -> 1.3 + attemptCount >= 10 -> 1.1 + else -> 0.9 + } return durationMinutes * baseCaloriesPerMinute * intensityMultiplier } @SuppressLint("RestrictedApi") private fun createHeartRateRecord( - startTime: Instant, - endTime: Instant, - attemptCount: Int + startTime: Instant, + endTime: Instant, + attemptCount: Int ): HeartRateRecord? { return try { val samples = mutableListOf() val intervalMinutes = 5L val baseHeartRate = - when { - attemptCount >= 20 -> 155L - attemptCount >= 10 -> 145L - else -> 135L - } + when { + attemptCount >= 20 -> 155L + attemptCount >= 10 -> 145L + else -> 135L + } var currentTime = startTime while (currentTime.isBefore(endTime)) { @@ -321,14 +320,14 @@ class HealthConnectManager(private val context: Context) { if (samples.isEmpty()) return null HeartRateRecord( - startTime = startTime, - startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime), - endTime = endTime, - endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), - samples = samples + startTime = startTime, + startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime), + endTime = endTime, + endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), + samples = samples ) } catch (e: Exception) { - Log.e(TAG, "Error creating heart rate record", e) + AppLogger.e(TAG, e) { "Error creating heart rate record" } null } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt index 7668c36..5d40f92 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt @@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem import com.atridad.ascently.data.model.* import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.utils.DateFormatUtils +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.ZipExportImportUtils import java.io.File import kotlinx.coroutines.flow.Flow @@ -26,7 +27,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) private val attemptDao = database.attemptDao() private val dataStateManager = DataStateManager(context) private val deletionPreferences: SharedPreferences = - context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) + context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) private var autoSyncCallback: (() -> Unit)? = null @@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) dataStateManager.updateDataState() triggerAutoSync() } + suspend fun updateGym(gym: Gym) { gymDao.updateGym(gym) dataStateManager.updateDataState() triggerAutoSync() } + suspend fun deleteGym(gym: Gym) { gymDao.deleteGym(gym) trackDeletion(gym.id, "gym") @@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) problemDao.insertProblem(problem) dataStateManager.updateDataState() } + suspend fun updateProblem(problem: Problem) { problemDao.updateProblem(problem) dataStateManager.updateDataState() } + suspend fun deleteProblem(problem: Problem) { problemDao.deleteProblem(problem) trackDeletion(problem.id, "problem") @@ -77,7 +82,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) fun getAllSessions(): Flow> = sessionDao.getAllSessions() suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) fun getSessionsByGym(gymId: String): Flow> = - sessionDao.getSessionsByGym(gymId) + sessionDao.getSessionsByGym(gymId) + suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() suspend fun insertSession(session: ClimbSession) { @@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) triggerAutoSync() } } + suspend fun updateSession(session: ClimbSession) { sessionDao.updateSession(session) dataStateManager.updateDataState() @@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) triggerAutoSync() } } + suspend fun deleteSession(session: ClimbSession) { sessionDao.deleteSession(session) trackDeletion(session.id, "session") dataStateManager.updateDataState() triggerAutoSync() } + suspend fun getLastUsedGym(): Gym? { val recentSessions = sessionDao.getRecentSessions(1).first() return if (recentSessions.isNotEmpty()) { @@ -114,17 +123,21 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) // Attempt operations fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() fun getAttemptsBySession(sessionId: String): Flow> = - attemptDao.getAttemptsBySession(sessionId) + attemptDao.getAttemptsBySession(sessionId) + fun getAttemptsByProblem(problemId: String): Flow> = - attemptDao.getAttemptsByProblem(problemId) + attemptDao.getAttemptsByProblem(problemId) + suspend fun insertAttempt(attempt: Attempt) { attemptDao.insertAttempt(attempt) dataStateManager.updateDataState() } + suspend fun updateAttempt(attempt: Attempt) { attemptDao.updateAttempt(attempt) dataStateManager.updateDataState() } + suspend fun deleteAttempt(attempt: Attempt) { attemptDao.deleteAttempt(attempt) trackDeletion(attempt.id, "attempt") @@ -141,38 +154,38 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) val backupData = - ClimbDataBackup( - exportedAt = DateFormatUtils.nowISO8601(), - version = "2.0", - formatVersion = "2.0", - gyms = allGyms.map { BackupGym.fromGym(it) }, - problems = allProblems.map { BackupProblem.fromProblem(it) }, - sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, - attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } - ) + ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + version = "2.0", + formatVersion = "2.0", + gyms = allGyms.map { BackupGym.fromGym(it) }, + problems = allProblems.map { BackupProblem.fromProblem(it) }, + sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, + attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } + ) val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val validImagePaths = - referencedImagePaths - .filter { imagePath -> - try { - val imageFile = - com.atridad.ascently.utils.ImageUtils.getImageFile( - context, - imagePath - ) - imageFile.exists() && imageFile.length() > 0 - } catch (_: Exception) { - false - } - } - .toSet() + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.ascently.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (_: Exception) { + false + } + } + .toSet() ZipExportImportUtils.createExportZipToUri( - context = context, - uri = uri, - exportData = backupData, - referencedImagePaths = validImagePaths + context = context, + uri = uri, + exportData = backupData, + referencedImagePaths = validImagePaths ) } catch (e: Exception) { throw Exception("Export failed: ${e.message}") @@ -192,11 +205,11 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) } val importData = - try { - json.decodeFromString(importResult.jsonContent) - } catch (e: Exception) { - throw Exception("Invalid data format: ${e.message}") - } + try { + json.decodeFromString(importResult.jsonContent) + } catch (e: Exception) { + throw Exception("Invalid data format: ${e.message}") + } validateImportData(importData) @@ -214,17 +227,17 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) } val updatedBackupProblems = - ZipExportImportUtils.updateProblemImagePaths( - importData.problems, - importResult.importedImagePaths - ) + ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + importResult.importedImagePaths + ) updatedBackupProblems.forEach { backupProblem -> try { problemDao.insertProblem(backupProblem.toProblem()) } catch (e: Exception) { throw Exception( - "Failed to import problem '${backupProblem.name}': ${e.message}" + "Failed to import problem '${backupProblem.name}': ${e.message}" ) } } @@ -262,7 +275,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) fun trackDeletion(itemId: String, itemType: String) { val currentDeletions = getDeletedItems().toMutableList() val newDeletion = - DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) + DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) currentDeletions.add(newDeletion) val json = json.encodeToString(newDeletion) @@ -292,23 +305,23 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) } private fun validateDataIntegrity( - gyms: List, - problems: List, - sessions: List, - attempts: List + gyms: List, + problems: List, + sessions: List, + attempts: List ) { val gymIds = gyms.map { it.id }.toSet() val invalidProblems = problems.filter { it.gymId !in gymIds } if (invalidProblems.isNotEmpty()) { throw Exception( - "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" + "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" ) } val invalidSessions = sessions.filter { it.gymId !in gymIds } if (invalidSessions.isNotEmpty()) { throw Exception( - "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" + "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" ) } @@ -316,10 +329,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) val sessionIds = sessions.map { it.id }.toSet() val invalidAttempts = - attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } + attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } if (invalidAttempts.isNotEmpty()) { throw Exception( - "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" + "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" ) } } @@ -334,9 +347,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) } if (importData.gyms.size > 1000 || - importData.problems.size > 10000 || - importData.sessions.size > 10000 || - importData.attempts.size > 100000 + importData.problems.size > 10000 || + importData.sessions.size > 10000 || + importData.attempts.size > 100000 ) { throw Exception("Import data is too large: possible corruption or malicious file") } @@ -386,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) if (imagesDir.exists() && imagesDir.isDirectory) { val deletedCount = imagesDir.listFiles()?.size ?: 0 imagesDir.deleteRecursively() - android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") + AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" } } } catch (e: Exception) { - android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") + AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" } } } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/state/DataStateManager.kt b/android/app/src/main/java/com/atridad/ascently/data/state/DataStateManager.kt index 32ae8fb..bb573a1 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/state/DataStateManager.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/state/DataStateManager.kt @@ -2,8 +2,8 @@ package com.atridad.ascently.data.state import android.content.Context import android.content.SharedPreferences -import android.util.Log import androidx.core.content.edit +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.DateFormatUtils /** @@ -20,13 +20,13 @@ class DataStateManager(context: Context) { } private val prefs: SharedPreferences = - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) init { if (!isInitialized()) { updateDataState() markAsInitialized() - Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}") + AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" } } } @@ -37,7 +37,7 @@ class DataStateManager(context: Context) { fun updateDataState() { val now = DateFormatUtils.nowISO8601() prefs.edit { putString(KEY_LAST_MODIFIED, now) } - Log.d(TAG, "Data state updated to: $now") + AppLogger.d(TAG) { "Data state updated to: $now" } } /** @@ -46,7 +46,7 @@ class DataStateManager(context: Context) { */ fun getLastModified(): String { return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601()) - ?: DateFormatUtils.nowISO8601() + ?: DateFormatUtils.nowISO8601() } /** Checks if the data state has been initialized. */ diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt index 21121af..c4be4f0 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt @@ -4,10 +4,10 @@ import android.content.Context import android.content.SharedPreferences import android.net.ConnectivityManager import android.net.NetworkCapabilities -import android.util.Log import androidx.annotation.RequiresPermission import androidx.core.content.edit import com.atridad.ascently.data.format.BackupAttempt +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupGym import com.atridad.ascently.data.format.BackupProblem @@ -53,14 +53,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep } private val sharedPreferences: SharedPreferences = - context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) + context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) private val httpClient = - OkHttpClient.Builder() - .connectTimeout(45, TimeUnit.SECONDS) - .readTimeout(90, TimeUnit.SECONDS) - .writeTimeout(90, TimeUnit.SECONDS) - .build() + OkHttpClient.Builder() + .connectTimeout(45, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .writeTimeout(90, TimeUnit.SECONDS) + .build() private val json = Json { prettyPrint = true @@ -151,7 +151,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) private fun isNetworkAvailable(): Boolean { val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = connectivityManager.activeNetwork ?: return false val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false return when { @@ -164,12 +164,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) suspend fun syncWithServer() { if (isOfflineMode) { - Log.d(TAG, "Sync skipped: Offline mode is enabled.") + AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." } return } if (!isNetworkAvailable()) { _syncError.value = "No internet connection." - Log.d(TAG, "Sync skipped: No internet connection.") + AppLogger.d(TAG) { "Sync skipped: No internet connection." } return } if (!_isConfigured.value) { @@ -188,43 +188,46 @@ class SyncService(private val context: Context, private val repository: ClimbRep val serverBackup = downloadData() val hasLocalData = - localBackup.gyms.isNotEmpty() || - localBackup.problems.isNotEmpty() || - localBackup.sessions.isNotEmpty() || - localBackup.attempts.isNotEmpty() + localBackup.gyms.isNotEmpty() || + localBackup.problems.isNotEmpty() || + localBackup.sessions.isNotEmpty() || + localBackup.attempts.isNotEmpty() val hasServerData = - serverBackup.gyms.isNotEmpty() || - serverBackup.problems.isNotEmpty() || - serverBackup.sessions.isNotEmpty() || - serverBackup.attempts.isNotEmpty() + serverBackup.gyms.isNotEmpty() || + serverBackup.problems.isNotEmpty() || + serverBackup.sessions.isNotEmpty() || + serverBackup.attempts.isNotEmpty() // If both client and server have been synced before, use delta sync val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) if (hasLocalData && hasServerData && lastSyncTimeStr != null) { - Log.d(TAG, "Using delta sync for incremental updates") + AppLogger.d(TAG) { "Using delta sync for incremental updates" } performDeltaSync(lastSyncTimeStr) } else { when { !hasLocalData && hasServerData -> { - Log.d(TAG, "No local data found, performing full restore from server") + AppLogger.d(TAG) { "No local data found, performing full restore from server" } val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) - Log.d(TAG, "Full restore completed") + AppLogger.d(TAG) { "Full restore completed" } } + hasLocalData && !hasServerData -> { - Log.d(TAG, "No server data found, uploading local data to server") + AppLogger.d(TAG) { "No server data found, uploading local data to server" } uploadData(localBackup) syncImagesForBackup(localBackup) - Log.d(TAG, "Initial upload completed") + AppLogger.d(TAG) { "Initial upload completed" } } + hasLocalData && hasServerData -> { - Log.d(TAG, "Both local and server data exist, merging (server wins)") + AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" } mergeDataSafely(serverBackup) - Log.d(TAG, "Merge completed") + AppLogger.d(TAG) { "Merge completed" } } + else -> { - Log.d(TAG, "No data to sync") + AppLogger.d(TAG) { "No data to sync" } } } } @@ -242,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } private suspend fun performDeltaSync(lastSyncTimeStr: String) { - Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr") + AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" } // Parse last sync time to filter modified items val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0) @@ -250,102 +253,100 @@ class SyncService(private val context: Context, private val repository: ClimbRep // Collect items modified since last sync val allGyms = repository.getAllGyms().first() val modifiedGyms = - allGyms - .filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true } - .map { BackupGym.fromGym(it) } + allGyms + .filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true } + .map { BackupGym.fromGym(it) } val allProblems = repository.getAllProblems().first() val modifiedProblems = - allProblems - .filter { problem -> - parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true - } - .map { problem -> - val backupProblem = BackupProblem.fromProblem(problem) - val normalizedImagePaths = - problem.imagePaths.mapIndexed { index, _ -> - ImageNamingUtils.generateImageFilename(problem.id, index) - } - if (normalizedImagePaths.isNotEmpty()) { - backupProblem.copy(imagePaths = normalizedImagePaths) - } else { - backupProblem - } - } - - val allSessions = repository.getAllSessions().first() - val modifiedSessions = - allSessions - .filter { session -> - parseISO8601(session.updatedAt)?.after(lastSyncDate) == true - } - .map { BackupClimbSession.fromClimbSession(it) } - - val allAttempts = repository.getAllAttempts().first() - val modifiedAttempts = - allAttempts - .filter { attempt -> - parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true - } - .map { BackupAttempt.fromAttempt(it) } - - val allDeletions = repository.getDeletedItems() - val modifiedDeletions = - allDeletions.filter { item -> - parseISO8601(item.deletedAt)?.after(lastSyncDate) == true + allProblems + .filter { problem -> + parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true } - - Log.d( - TAG, - "Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}" - ) - - // Create delta request - val deltaRequest = - DeltaSyncRequest( - lastSyncTime = lastSyncTimeStr, - gyms = modifiedGyms, - problems = modifiedProblems, - sessions = modifiedSessions, - attempts = modifiedAttempts, - deletedItems = modifiedDeletions - ) - - val requestBody = - json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest) - .toRequestBody("application/json".toMediaType()) - - val request = - Request.Builder() - .url("$serverUrl/sync/delta") - .header("Authorization", "Bearer $authToken") - .post(requestBody) - .build() - - val deltaResponse = - withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body?.string() - if (!body.isNullOrEmpty()) { - json.decodeFromString(DeltaSyncResponse.serializer(), body) - } else { - throw SyncException.InvalidResponse("Empty response body") - } - } else { - handleHttpError(response.code) - } + .map { problem -> + val backupProblem = BackupProblem.fromProblem(problem) + val normalizedImagePaths = + problem.imagePaths.mapIndexed { index, _ -> + ImageNamingUtils.generateImageFilename(problem.id, index) } - } catch (e: IOException) { - throw SyncException.NetworkError(e.message ?: "Network error") + if (normalizedImagePaths.isNotEmpty()) { + backupProblem.copy(imagePaths = normalizedImagePaths) + } else { + backupProblem } } - Log.d( - TAG, - "Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}" - ) + val allSessions = repository.getAllSessions().first() + val modifiedSessions = + allSessions + .filter { session -> + parseISO8601(session.updatedAt)?.after(lastSyncDate) == true + } + .map { BackupClimbSession.fromClimbSession(it) } + + val allAttempts = repository.getAllAttempts().first() + val modifiedAttempts = + allAttempts + .filter { attempt -> + parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true + } + .map { BackupAttempt.fromAttempt(it) } + + val allDeletions = repository.getDeletedItems() + val modifiedDeletions = + allDeletions.filter { item -> + parseISO8601(item.deletedAt)?.after(lastSyncDate) == true + } + + AppLogger.d(TAG) { + "Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}" + } + + // Create delta request + val deltaRequest = + DeltaSyncRequest( + lastSyncTime = lastSyncTimeStr, + gyms = modifiedGyms, + problems = modifiedProblems, + sessions = modifiedSessions, + attempts = modifiedAttempts, + deletedItems = modifiedDeletions + ) + + val requestBody = + json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest) + .toRequestBody("application/json".toMediaType()) + + val request = + Request.Builder() + .url("$serverUrl/sync/delta") + .header("Authorization", "Bearer $authToken") + .post(requestBody) + .build() + + val deltaResponse = + withContext(Dispatchers.IO) { + try { + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val body = response.body?.string() + if (!body.isNullOrEmpty()) { + json.decodeFromString(DeltaSyncResponse.serializer(), body) + } else { + throw SyncException.InvalidResponse("Empty response body") + } + } else { + handleHttpError(response.code) + } + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + AppLogger.d(TAG) { + "Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}" + } // Apply server changes to local data applyDeltaResponse(deltaResponse) @@ -372,7 +373,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep val allDeletions = repository.getDeletedItems() + response.deletedItems val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" } - Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data") + AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" } applyDeletions(uniqueDeletions) // Build deleted item lookup set @@ -392,7 +393,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep imagePathMapping[imagePath] = localImagePath } } catch (e: Exception) { - Log.w(TAG, "Failed to download image $imagePath: ${e.message}") + AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" } } } } @@ -421,9 +422,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep continue } val updatedImagePaths = - backupProblem.imagePaths?.map { oldPath -> - imagePathMapping[oldPath] ?: oldPath - } + backupProblem.imagePaths?.map { oldPath -> + imagePathMapping[oldPath] ?: oldPath + } val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths) val problem = problemToMerge.toProblem() @@ -484,7 +485,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } private suspend fun applyDeletions( - deletions: List + deletions: List ) { val existingGyms = repository.getAllGyms().first() val existingProblems = repository.getAllProblems().first() @@ -496,12 +497,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep "gym" -> { existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) } } + "problem" -> { existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) } } + "session" -> { existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) } } + "attempt" -> { existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) } } @@ -512,7 +516,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun syncModifiedImages(modifiedProblems: List) { if (modifiedProblems.isEmpty()) return - Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems") + AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" } for (backupProblem in modifiedProblems) { backupProblem.imagePaths?.forEach { imagePath -> @@ -524,11 +528,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun downloadData(): ClimbDataBackup { val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .get() - .build() + Request.Builder() + .url("$serverUrl/sync") + .header("Authorization", "Bearer $authToken") + .get() + .build() return withContext(Dispatchers.IO) { try { @@ -539,11 +543,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep json.decodeFromString(body) } else { ClimbDataBackup( - exportedAt = DateFormatUtils.nowISO8601(), - gyms = emptyList(), - problems = emptyList(), - sessions = emptyList(), - attempts = emptyList() + exportedAt = DateFormatUtils.nowISO8601(), + gyms = emptyList(), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() ) } } else { @@ -558,14 +562,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun uploadData(backup: ClimbDataBackup) { val requestBody = - json.encodeToString(backup).toRequestBody("application/json".toMediaType()) + json.encodeToString(backup).toRequestBody("application/json".toMediaType()) val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .put(requestBody) - .build() + Request.Builder() + .url("$serverUrl/sync") + .header("Authorization", "Bearer $authToken") + .put(requestBody) + .build() withContext(Dispatchers.IO) { try { @@ -583,7 +587,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map { val imagePathMapping = mutableMapOf() val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 } - Log.d(TAG, "Starting image download from server for $totalImages images") + AppLogger.d(TAG) { "Starting image download from server for $totalImages images" } withContext(Dispatchers.IO) { backup.problems.forEach { problem -> @@ -595,9 +599,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep imagePathMapping[imagePath] = localImagePath } } catch (_: SyncException.ImageNotFound) { - Log.w(TAG, "Image not found on server: $imagePath") + AppLogger.w(TAG) { "Image not found on server: $imagePath" } } catch (e: Exception) { - Log.w(TAG, "Failed to download image $imagePath: ${e.message}") + AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" } } } } @@ -607,10 +611,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun downloadImage(serverFilename: String): String? { val request = - Request.Builder() - .url("$serverUrl/images/download?filename=$serverFilename") - .header("Authorization", "Bearer $authToken") - .build() + Request.Builder() + .url("$serverUrl/images/download?filename=$serverFilename") + .header("Authorization", "Bearer $authToken") + .build() return withContext(Dispatchers.IO) { try { @@ -625,14 +629,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } } catch (e: IOException) { - Log.e(TAG, "Network error downloading image $serverFilename", e) + AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" } null } } } private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { - Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") + AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" } withContext(Dispatchers.IO) { backup.problems.forEach { problem -> problem.imagePaths?.forEach { localPath -> @@ -646,33 +650,32 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun uploadImage(localPath: String, filename: String) { val file = ImageUtils.getImageFile(context, localPath) if (!file.exists()) { - Log.w(TAG, "Local image file not found, cannot upload: $localPath") + AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" } return } val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType()) val request = - Request.Builder() - .url("$serverUrl/images/upload?filename=$filename") - .header("Authorization", "Bearer $authToken") - .post(requestBody) - .build() + Request.Builder() + .url("$serverUrl/images/upload?filename=$filename") + .header("Authorization", "Bearer $authToken") + .post(requestBody) + .build() withContext(Dispatchers.IO) { try { httpClient.newCall(request).execute().use { response -> if (response.isSuccessful) { - Log.d(TAG, "Successfully uploaded image: $filename") + AppLogger.d(TAG) { "Successfully uploaded image: $filename" } } else { - Log.w( - TAG, - "Failed to upload image $filename. Server responded with ${response.code}" - ) + AppLogger.w(TAG) { + "Failed to upload image $filename. Server responded with ${response.code}" + } } } } catch (e: IOException) { - Log.e(TAG, "Network error uploading image $filename", e) + AppLogger.e(TAG, e) { "Network error uploading image $filename" } } } } @@ -680,49 +683,49 @@ class SyncService(private val context: Context, private val repository: ClimbRep private suspend fun createBackupFromRepository(): ClimbDataBackup { return withContext(Dispatchers.Default) { ClimbDataBackup( - exportedAt = dataStateManager.getLastModified(), - gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) }, - problems = - repository.getAllProblems().first().map { problem -> - val backupProblem = BackupProblem.fromProblem(problem) - val normalizedImagePaths = - problem.imagePaths.mapIndexed { index, _ -> - ImageNamingUtils.generateImageFilename( - problem.id, - index - ) - } - if (normalizedImagePaths.isNotEmpty()) { - backupProblem.copy(imagePaths = normalizedImagePaths) - } else { - backupProblem - } - }, - sessions = - repository.getAllSessions().first().map { - BackupClimbSession.fromClimbSession(it) - }, - attempts = - repository.getAllAttempts().first().map { - BackupAttempt.fromAttempt(it) - }, - deletedItems = repository.getDeletedItems() + exportedAt = dataStateManager.getLastModified(), + gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) }, + problems = + repository.getAllProblems().first().map { problem -> + val backupProblem = BackupProblem.fromProblem(problem) + val normalizedImagePaths = + problem.imagePaths.mapIndexed { index, _ -> + ImageNamingUtils.generateImageFilename( + problem.id, + index + ) + } + if (normalizedImagePaths.isNotEmpty()) { + backupProblem.copy(imagePaths = normalizedImagePaths) + } else { + backupProblem + } + }, + sessions = + repository.getAllSessions().first().map { + BackupClimbSession.fromClimbSession(it) + }, + attempts = + repository.getAllAttempts().first().map { + BackupAttempt.fromAttempt(it) + }, + deletedItems = repository.getDeletedItems() ) } } private suspend fun importBackupToRepository( - backup: ClimbDataBackup, - imagePathMapping: Map + backup: ClimbDataBackup, + imagePathMapping: Map ) { val gyms = backup.gyms.map { it.toGym() } val problems = - backup.problems.map { backupProblem -> - val imagePaths = backupProblem.imagePaths - val updatedImagePaths = - imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath } - backupProblem.copy(imagePaths = updatedImagePaths).toProblem() - } + backup.problems.map { backupProblem -> + val imagePaths = backupProblem.imagePaths + val updatedImagePaths = + imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath } + backupProblem.copy(imagePaths = updatedImagePaths).toProblem() + } val sessions = backup.sessions.map { it.toClimbSession() } val attempts = backup.attempts.map { it.toAttempt() } @@ -737,7 +740,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) { - Log.d(TAG, "Server data will overwrite local data. Performing full restore.") + AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." } val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) } @@ -760,11 +763,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep _syncError.value = null val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .head() - .build() + Request.Builder() + .url("$serverUrl/sync") + .header("Authorization", "Bearer $authToken") + .head() + .build() try { withContext(Dispatchers.IO) { httpClient.newCall(request).execute().use { response -> @@ -793,18 +796,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep } syncJob?.cancel() syncJob = - serviceScope.launch { - delay(syncDebounceDelay) - try { - syncWithServer() - } catch (e: Exception) { - Log.e(TAG, "Auto-sync failed", e) - } - if (pendingChanges) { - pendingChanges = false - triggerAutoSync() - } + serviceScope.launch { + delay(syncDebounceDelay) + try { + syncWithServer() + } catch (e: Exception) { + AppLogger.e(TAG, e) { "Auto-sync failed" } } + if (pendingChanges) { + pendingChanges = false + triggerAutoSync() + } + } } fun clearConfiguration() { @@ -822,7 +825,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep sealed class SyncException(message: String) : IOException(message), Serializable { object NotConfigured : - SyncException("Sync is not configured. Please set server URL and auth token.") + SyncException("Sync is not configured. Please set server URL and auth token.") object NotConnected : SyncException("Not connected to server. Please test connection first.") @@ -832,6 +835,7 @@ sealed class SyncException(message: String) : IOException(message), Serializable data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") data class InvalidResponse(val details: String) : - SyncException("Invalid server response: $details") + SyncException("Invalid server response: $details") + data class NetworkError(val details: String) : SyncException("Network error: $details") } diff --git a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt index 6af2e5e..77e27e3 100644 --- a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt +++ b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt @@ -12,6 +12,7 @@ import com.atridad.ascently.MainActivity import com.atridad.ascently.R import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.repository.ClimbRepository +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.widget.ClimbStatsWidgetProvider import java.time.LocalDateTime import java.time.temporal.ChronoUnit @@ -29,6 +30,7 @@ class SessionTrackingService : Service() { private lateinit var notificationManager: NotificationManager companion object { + private const val LOG_TAG = "SessionTrackingService" const val NOTIFICATION_ID = 1001 const val CHANNEL_ID = "session_tracking_channel" const val ACTION_START_SESSION = "start_session" @@ -68,23 +70,24 @@ class SessionTrackingService : Service() { startSessionTracking(sessionId) } } + ACTION_STOP_SESSION -> { val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) serviceScope.launch { try { val targetSession = - when { - sessionId != null -> repository.getSessionById(sessionId) - else -> repository.getActiveSession() - } + when { + sessionId != null -> repository.getSessionById(sessionId) + else -> repository.getActiveSession() + } if (targetSession != null && - targetSession.status == - com.atridad.ascently.data.model.SessionStatus.ACTIVE + targetSession.status == + com.atridad.ascently.data.model.SessionStatus.ACTIVE ) { val completed = - with(com.atridad.ascently.data.model.ClimbSession) { - targetSession.complete() - } + with(com.atridad.ascently.data.model.ClimbSession) { + targetSession.complete() + } repository.updateSession(completed) } } finally { @@ -108,50 +111,50 @@ class SessionTrackingService : Service() { // Update widget when session tracking starts ClimbStatsWidgetProvider.updateAllWidgets(this) } catch (e: Exception) { - e.printStackTrace() + AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" } } notificationJob = - serviceScope.launch { - try { - if (!isNotificationActive()) { - delay(1000L) - createAndShowNotification(sessionId) - } - - while (isActive) { - delay(5000L) - updateNotification(sessionId) - } - } catch (e: Exception) { - e.printStackTrace() + serviceScope.launch { + try { + if (!isNotificationActive()) { + delay(1000L) + createAndShowNotification(sessionId) } + + while (isActive) { + delay(5000L) + updateNotification(sessionId) + } + } catch (e: Exception) { + AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" } } + } monitoringJob = - serviceScope.launch { - try { - while (isActive) { - delay(10000L) + serviceScope.launch { + try { + while (isActive) { + delay(10000L) - if (!isNotificationActive()) { - updateNotification(sessionId) - } - - val session = repository.getSessionById(sessionId) - if (session == null || - session.status != - com.atridad.ascently.data.model.SessionStatus - .ACTIVE - ) { - stopSessionTracking() - break - } + if (!isNotificationActive()) { + updateNotification(sessionId) + } + + val session = repository.getSessionById(sessionId) + if (session == null || + session.status != + com.atridad.ascently.data.model.SessionStatus + .ACTIVE + ) { + stopSessionTracking() + break } - } catch (e: Exception) { - e.printStackTrace() } + } catch (e: Exception) { + AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" } } + } } private fun stopSessionTracking() { @@ -178,13 +181,13 @@ class SessionTrackingService : Service() { // Update widget when notification updates ClimbStatsWidgetProvider.updateAllWidgets(this) } catch (e: Exception) { - e.printStackTrace() + AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" } try { delay(10000L) createAndShowNotification(sessionId) } catch (retryException: Exception) { - retryException.printStackTrace() + AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" } stopSessionTracking() } } @@ -194,7 +197,7 @@ class SessionTrackingService : Service() { try { val session = runBlocking { repository.getSessionById(sessionId) } if (session == null || - session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE + session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE ) { stopSessionTracking() return @@ -207,99 +210,99 @@ class SessionTrackingService : Service() { } val duration = - session.startTime?.let { startTime -> - try { - val start = LocalDateTime.parse(startTime) - val now = LocalDateTime.now() - val totalSeconds = ChronoUnit.SECONDS.between(start, now) - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 + session.startTime?.let { startTime -> + try { + val start = LocalDateTime.parse(startTime) + val now = LocalDateTime.now() + val totalSeconds = ChronoUnit.SECONDS.between(start, now) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 - when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${totalSeconds}s" - } - } catch (_: Exception) { - "Active" + when { + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${totalSeconds}s" } + } catch (_: Exception) { + "Active" } - ?: "Active" + } + ?: "Active" val notification = - NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Climbing Session Active") - .setContentText( - "${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts" - ) - .setSmallIcon(R.drawable.ic_mountains) - .setOngoing(true) - .setAutoCancel(false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(createOpenAppIntent()) - .addAction( - R.drawable.ic_mountains, - "Open Session", - createOpenAppIntent() - ) - .addAction( - android.R.drawable.ic_menu_close_clear_cancel, - "End Session", - createStopPendingIntent(sessionId) - ) - .build() + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Climbing Session Active") + .setContentText( + "${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts" + ) + .setSmallIcon(R.drawable.ic_mountains) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(createOpenAppIntent()) + .addAction( + R.drawable.ic_mountains, + "Open Session", + createOpenAppIntent() + ) + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + "End Session", + createStopPendingIntent(sessionId) + ) + .build() startForeground(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification) } catch (e: Exception) { - e.printStackTrace() + AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" } throw e } } private fun createOpenAppIntent(): PendingIntent { val intent = - Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - action = "OPEN_SESSION" - } + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + action = "OPEN_SESSION" + } return PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun createStopPendingIntent(sessionId: String): PendingIntent { val intent = createStopIntent(this, sessionId) return PendingIntent.getService( - this, - 1, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + this, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun createNotificationChannel() { val channel = - NotificationChannel( - CHANNEL_ID, - "Session Tracking", - NotificationManager.IMPORTANCE_DEFAULT - ) - .apply { - description = "Shows active climbing session information" - setShowBadge(false) - lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - enableLights(false) - enableVibration(false) - setSound(null, null) - } + NotificationChannel( + CHANNEL_ID, + "Session Tracking", + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = "Shows active climbing session information" + setShowBadge(false) + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + enableLights(false) + enableVibration(false) + setSound(null, null) + } notificationManager.createNotificationChannel(channel) } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/AscentlyApp.kt b/android/app/src/main/java/com/atridad/ascently/ui/AscentlyApp.kt index a26a88e..4b05778 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/AscentlyApp.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/AscentlyApp.kt @@ -26,15 +26,16 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog import com.atridad.ascently.ui.screens.* import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.AppShortcutManager import com.atridad.ascently.utils.NotificationPermissionUtils @OptIn(ExperimentalMaterial3Api::class) @Composable fun AscentlyApp( - shortcutAction: String? = null, - lastUsedGymId: String? = null, - onShortcutActionProcessed: () -> Unit = {} + shortcutAction: String? = null, + lastUsedGymId: String? = null, + onShortcutActionProcessed: () -> Unit = {} ) { val navController = rememberNavController() val context = LocalContext.current @@ -45,26 +46,26 @@ fun AscentlyApp( val repository = remember { ClimbRepository(database, context) } val syncService = remember { SyncService(context, repository) } val viewModel: ClimbViewModel = - viewModel(factory = ClimbViewModelFactory(repository, syncService, context)) + viewModel(factory = ClimbViewModelFactory(repository, syncService, context)) var showNotificationPermissionDialog by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) } val permissionLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (!isGranted) { - showNotificationPermissionDialog = false - } + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (!isGranted) { + showNotificationPermissionDialog = false } + } LaunchedEffect(Unit) { if (!hasCheckedNotificationPermission) { hasCheckedNotificationPermission = true if (NotificationPermissionUtils.shouldRequestNotificationPermission() && - !NotificationPermissionUtils.isNotificationPermissionGranted(context) + !NotificationPermissionUtils.isNotificationPermissionGranted(context) ) { showNotificationPermissionDialog = true } @@ -86,10 +87,10 @@ fun AscentlyApp( LaunchedEffect(activeSession, gyms, lastUsedGym) { AppShortcutManager.updateShortcuts( - context = context, - hasActiveSession = activeSession != null, - hasGyms = gyms.isNotEmpty(), - lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null + context = context, + hasActiveSession = activeSession != null, + hasGyms = gyms.isNotEmpty(), + lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null ) } @@ -101,6 +102,7 @@ fun AscentlyApp( launchSingleTop = true } } + AppShortcutManager.ACTION_END_SESSION -> { navController.navigate(Screen.Sessions) { popUpTo(0) { inclusive = true } @@ -114,51 +116,36 @@ fun AscentlyApp( LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { - android.util.Log.d( - "AscentlyApp", - "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" - ) + AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" } if (activeSession == null) { if (NotificationPermissionUtils.shouldRequestNotificationPermission() && - !NotificationPermissionUtils.isNotificationPermissionGranted( - context - ) + !NotificationPermissionUtils.isNotificationPermissionGranted( + context + ) ) { - android.util.Log.d("AscentlyApp", "Showing notification permission dialog") + AppLogger.d("AscentlyApp") { "Showing notification permission dialog" } showNotificationPermissionDialog = true } else { if (gyms.size == 1) { - android.util.Log.d( - "AscentlyApp", - "Starting session with single gym: ${gyms.first().name}" - ) + AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" } viewModel.startSession(context, gyms.first().id) } else { val targetGym = - lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } - ?: lastUsedGym + lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } + ?: lastUsedGym if (targetGym != null) { - android.util.Log.d( - "AscentlyApp", - "Starting session with target gym: ${targetGym.name}" - ) + AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" } viewModel.startSession(context, targetGym.id) } else { - android.util.Log.d( - "AscentlyApp", - "No target gym found, navigating to selection" - ) + AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" } navController.navigate(Screen.AddEditSession()) } } } } else { - android.util.Log.d( - "AscentlyApp", - "Active session already exists: ${activeSession?.id}" - ) + AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" } } onShortcutActionProcessed() @@ -168,79 +155,79 @@ fun AscentlyApp( var fabConfig by remember { mutableStateOf(null) } Scaffold( - bottomBar = { AscentlyBottomNavigation(navController = navController) }, - floatingActionButton = { - fabConfig?.let { config -> - FloatingActionButton( - onClick = config.onClick, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon( - imageVector = config.icon, - contentDescription = config.contentDescription - ) - } + bottomBar = { AscentlyBottomNavigation(navController = navController) }, + floatingActionButton = { + fabConfig?.let { config -> + FloatingActionButton( + onClick = config.onClick, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + imageVector = config.icon, + contentDescription = config.contentDescription + ) } } + } ) { innerPadding -> NavHost( - navController = navController, - startDestination = Screen.Sessions, - modifier = Modifier.padding(innerPadding) + navController = navController, + startDestination = Screen.Sessions, + modifier = Modifier.padding(innerPadding) ) { composable { LaunchedEffect(gyms, activeSession) { fabConfig = - if (gyms.isNotEmpty() && activeSession == null) { - FabConfig( - icon = Icons.Default.PlayArrow, - contentDescription = "Start Session", - onClick = { - if (NotificationPermissionUtils - .shouldRequestNotificationPermission() && - !NotificationPermissionUtils - .isNotificationPermissionGranted( - context - ) - ) { - showNotificationPermissionDialog = true - } else { - navController.navigate(Screen.AddEditSession()) - } - } - ) - } else { - null - } + if (gyms.isNotEmpty() && activeSession == null) { + FabConfig( + icon = Icons.Default.PlayArrow, + contentDescription = "Start Session", + onClick = { + if (NotificationPermissionUtils + .shouldRequestNotificationPermission() && + !NotificationPermissionUtils + .isNotificationPermissionGranted( + context + ) + ) { + showNotificationPermissionDialog = true + } else { + navController.navigate(Screen.AddEditSession()) + } + } + ) + } else { + null + } } SessionsScreen( - viewModel = viewModel, - onNavigateToSessionDetail = { sessionId -> - navController.navigate(Screen.SessionDetail(sessionId)) - } + viewModel = viewModel, + onNavigateToSessionDetail = { sessionId -> + navController.navigate(Screen.SessionDetail(sessionId)) + } ) } composable { LaunchedEffect(gyms) { fabConfig = - if (gyms.isNotEmpty()) { - FabConfig( - icon = Icons.Default.Add, - contentDescription = "Add Problem", - onClick = { - navController.navigate(Screen.AddEditProblem()) - } - ) - } else { - null - } + if (gyms.isNotEmpty()) { + FabConfig( + icon = Icons.Default.Add, + contentDescription = "Add Problem", + onClick = { + navController.navigate(Screen.AddEditProblem()) + } + ) + } else { + null + } } ProblemsScreen( - viewModel = viewModel, - onNavigateToProblemDetail = { problemId -> - navController.navigate(Screen.ProblemDetail(problemId)) - } + viewModel = viewModel, + onNavigateToProblemDetail = { problemId -> + navController.navigate(Screen.ProblemDetail(problemId)) + } ) } @@ -252,17 +239,17 @@ fun AscentlyApp( composable { LaunchedEffect(Unit) { fabConfig = - FabConfig( - icon = Icons.Default.Add, - contentDescription = "Add Gym", - onClick = { navController.navigate(Screen.AddEditGym()) } - ) + FabConfig( + icon = Icons.Default.Add, + contentDescription = "Add Gym", + onClick = { navController.navigate(Screen.AddEditGym()) } + ) } GymsScreen( - viewModel = viewModel, - onNavigateToGymDetail = { gymId -> - navController.navigate(Screen.GymDetail(gymId)) - } + viewModel = viewModel, + onNavigateToGymDetail = { gymId -> + navController.navigate(Screen.GymDetail(gymId)) + } ) } @@ -275,12 +262,12 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } SessionDetailScreen( - sessionId = args.sessionId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() }, - onNavigateToProblemDetail = { problemId -> - navController.navigate(Screen.ProblemDetail(problemId)) - } + sessionId = args.sessionId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToProblemDetail = { problemId -> + navController.navigate(Screen.ProblemDetail(problemId)) + } ) } @@ -288,12 +275,12 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } ProblemDetailScreen( - problemId = args.problemId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() }, - onNavigateToEdit = { problemId -> - navController.navigate(Screen.AddEditProblem(problemId = problemId)) - } + problemId = args.problemId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { problemId -> + navController.navigate(Screen.AddEditProblem(problemId = problemId)) + } ) } @@ -301,18 +288,18 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } GymDetailScreen( - gymId = args.gymId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() }, - onNavigateToEdit = { gymId -> - navController.navigate(Screen.AddEditGym(gymId = gymId)) - }, - onNavigateToSessionDetail = { sessionId -> - navController.navigate(Screen.SessionDetail(sessionId)) - }, - onNavigateToProblemDetail = { problemId -> - navController.navigate(Screen.ProblemDetail(problemId)) - } + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { gymId -> + navController.navigate(Screen.AddEditGym(gymId = gymId)) + }, + onNavigateToSessionDetail = { sessionId -> + navController.navigate(Screen.SessionDetail(sessionId)) + }, + onNavigateToProblemDetail = { problemId -> + navController.navigate(Screen.ProblemDetail(problemId)) + } ) } @@ -320,9 +307,9 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } AddEditGymScreen( - gymId = args.gymId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() } + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } ) } @@ -330,10 +317,10 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } AddEditProblemScreen( - problemId = args.problemId, - gymId = args.gymId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() } + problemId = args.problemId, + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } ) } @@ -341,22 +328,22 @@ fun AscentlyApp( val args = backStackEntry.toRoute() LaunchedEffect(Unit) { fabConfig = null } AddEditSessionScreen( - sessionId = args.sessionId, - gymId = args.gymId, - viewModel = viewModel, - onNavigateBack = { navController.popBackStack() } + sessionId = args.sessionId, + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } ) } } if (showNotificationPermissionDialog) { NotificationPermissionDialog( - onDismiss = { showNotificationPermissionDialog = false }, - onRequestPermission = { - permissionLauncher.launch( - NotificationPermissionUtils.getNotificationPermissionString() - ) - } + onDismiss = { showNotificationPermissionDialog = false }, + onRequestPermission = { + permissionLauncher.launch( + NotificationPermissionUtils.getNotificationPermissionString() + ) + } ) } } @@ -370,34 +357,34 @@ fun AscentlyBottomNavigation(navController: NavHostController) { NavigationBar { bottomNavigationItems.forEach { item -> val isSelected = - when (item.screen) { - is Screen.Sessions -> currentRoute?.contains("Session") == true - is Screen.Problems -> currentRoute?.contains("Problem") == true - is Screen.Gyms -> currentRoute?.contains("Gym") == true - is Screen.Analytics -> currentRoute?.contains("Analytics") == true - is Screen.Settings -> currentRoute?.contains("Settings") == true - else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true - } + when (item.screen) { + is Screen.Sessions -> currentRoute?.contains("Session") == true + is Screen.Problems -> currentRoute?.contains("Problem") == true + is Screen.Gyms -> currentRoute?.contains("Gym") == true + is Screen.Analytics -> currentRoute?.contains("Analytics") == true + is Screen.Settings -> currentRoute?.contains("Settings") == true + else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true + } NavigationBarItem( - icon = { Icon(item.icon, contentDescription = item.label) }, - label = { Text(item.label) }, - selected = isSelected, - onClick = { - navController.navigate(item.screen) { - popUpTo(0) { inclusive = true } - launchSingleTop = true - // Don't restore state - always start fresh when switching tabs - restoreState = false - } + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }, + selected = isSelected, + onClick = { + navController.navigate(item.screen) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + // Don't restore state - always start fresh when switching tabs + restoreState = false } + } ) } } } data class FabConfig( - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val onClick: () -> Unit + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val contentDescription: String, + val onClick: () -> Unit ) diff --git a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt index ea18c13..c88b01f 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt @@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.* import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.sync.SyncService import com.atridad.ascently.service.SessionTrackingService +import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.widget.ClimbStatsWidgetProvider import java.io.File @@ -15,9 +16,9 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class ClimbViewModel( - private val repository: ClimbRepository, - val syncService: SyncService, - private val context: Context + private val repository: ClimbRepository, + val syncService: SyncService, + private val context: Context ) : ViewModel() { // Health Connect manager @@ -29,49 +30,49 @@ class ClimbViewModel( // Data flows val gyms = - repository - .getAllGyms() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = emptyList() - ) + repository + .getAllGyms() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) val problems = - repository - .getAllProblems() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = emptyList() - ) + repository + .getAllProblems() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) val sessions = - repository - .getAllSessions() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = emptyList() - ) + repository + .getAllSessions() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) val activeSession = - repository - .getActiveSessionFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = null - ) + repository + .getActiveSessionFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = null + ) val attempts = - repository - .getAllAttempts() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = emptyList() - ) + repository + .getAllAttempts() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) // Gym operations fun addGym(gym: Gym, updateWidgets: Boolean = true) { @@ -124,7 +125,7 @@ class ClimbViewModel( problem.imagePaths.forEachIndexed { index, tempPath -> if (tempPath.startsWith("temp_")) { val finalPath = - ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index) + ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index) finalImagePaths.add(finalPath ?: tempPath) } else { finalImagePaths.add(tempPath) @@ -176,23 +177,23 @@ class ClimbViewModel( val allProblems = repository.getAllProblems().first() val updatedProblems = - allProblems.map { problem -> - if (problem.imagePaths.isNotEmpty()) { - problem.copy(imagePaths = emptyList()) - } else { - problem - } + allProblems.map { problem -> + if (problem.imagePaths.isNotEmpty()) { + problem.copy(imagePaths = emptyList()) + } else { + problem } + } for (updatedProblem in updatedProblems) { if (updatedProblem.imagePaths != - allProblems.find { it.id == updatedProblem.id }?.imagePaths + allProblems.find { it.id == updatedProblem.id }?.imagePaths ) { repository.insertProblemWithoutSync(updatedProblem) } } - println("Deleted $deletedCount image files and cleared image references") + AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" } } } @@ -225,7 +226,7 @@ class ClimbViewModel( } fun getSessionsByGym(gymId: String): Flow> = - repository.getSessionsByGym(gymId) + repository.getSessionsByGym(gymId) // Get last used gym for shortcut functionality suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym() @@ -233,41 +234,35 @@ class ClimbViewModel( // Active session management fun startSession(context: Context, gymId: String, notes: String? = null) { viewModelScope.launch { - android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId") + AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" } if (!com.atridad.ascently.utils.NotificationPermissionUtils - .isNotificationPermissionGranted(context) + .isNotificationPermissionGranted(context) ) { - android.util.Log.d("ClimbViewModel", "Notification permission not granted") + AppLogger.d("ClimbViewModel") { "Notification permission not granted" } _uiState.value = - _uiState.value.copy( - error = - "Notification permission is required to track your climbing session. Please enable notifications in settings." - ) + _uiState.value.copy( + error = + "Notification permission is required to track your climbing session. Please enable notifications in settings." + ) return@launch } val existingActive = repository.getActiveSession() if (existingActive != null) { - android.util.Log.d( - "ClimbViewModel", - "Active session already exists: ${existingActive.id}" - ) + AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" } _uiState.value = - _uiState.value.copy( - error = "There's already an active session. Please end it first." - ) + _uiState.value.copy( + error = "There's already an active session. Please end it first." + ) return@launch } - android.util.Log.d("ClimbViewModel", "Creating new session") + AppLogger.d("ClimbViewModel") { "Creating new session" } val newSession = ClimbSession.create(gymId = gymId, notes = notes) repository.insertSession(newSession) - android.util.Log.d( - "ClimbViewModel", - "Starting tracking service for session: ${newSession.id}" - ) + AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" } // Start the tracking service val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) context.startForegroundService(serviceIntent) @@ -281,13 +276,13 @@ class ClimbViewModel( fun endSession(context: Context, sessionId: String) { viewModelScope.launch { if (!com.atridad.ascently.utils.NotificationPermissionUtils - .isNotificationPermissionGranted(context) + .isNotificationPermissionGranted(context) ) { _uiState.value = - _uiState.value.copy( - error = - "Notification permission is required to manage your climbing session. Please enable notifications in settings." - ) + _uiState.value.copy( + error = + "Notification permission is required to manage your climbing session. Please enable notifications in settings." + ) return@launch } @@ -313,7 +308,7 @@ class ClimbViewModel( val activeSession = repository.getActiveSession() if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) { val serviceIntent = - SessionTrackingService.createStartIntent(context, activeSession.id) + SessionTrackingService.createStartIntent(context, activeSession.id) context.startForegroundService(serviceIntent) } } @@ -348,32 +343,32 @@ class ClimbViewModel( } fun getAttemptsBySession(sessionId: String): Flow> = - repository.getAttemptsBySession(sessionId) + repository.getAttemptsBySession(sessionId) fun getAttemptsByProblem(problemId: String): Flow> = - repository.getAttemptsByProblem(problemId) + repository.getAttemptsByProblem(problemId) fun exportDataToZipUri(context: Context, uri: android.net.Uri) { viewModelScope.launch { try { _uiState.value = - _uiState.value.copy( - isLoading = true, - message = "Creating ZIP file with images..." - ) + _uiState.value.copy( + isLoading = true, + message = "Creating ZIP file with images..." + ) repository.exportAllDataToZipUri(context, uri) _uiState.value = - _uiState.value.copy( - isLoading = false, - message = - "Export complete! Your climbing data and images have been saved." - ) + _uiState.value.copy( + isLoading = false, + message = + "Export complete! Your climbing data and images have been saved." + ) } catch (e: Exception) { _uiState.value = - _uiState.value.copy( - isLoading = false, - error = "Export failed: ${e.message}" - ) + _uiState.value.copy( + isLoading = false, + error = "Export failed: ${e.message}" + ) } } } @@ -385,23 +380,23 @@ class ClimbViewModel( if (!file.name.lowercase().endsWith(".zip")) { throw Exception( - "Only ZIP files are supported for import. Please use a ZIP file exported from Ascently." + "Only ZIP files are supported for import. Please use a ZIP file exported from Ascently." ) } repository.importDataFromZip(file) _uiState.value = - _uiState.value.copy( - isLoading = false, - message = "Data imported successfully from ${file.name}" - ) + _uiState.value.copy( + isLoading = false, + message = "Data imported successfully from ${file.name}" + ) } catch (e: Exception) { _uiState.value = - _uiState.value.copy( - isLoading = false, - error = "Import failed: ${e.message}" - ) + _uiState.value.copy( + isLoading = false, + error = "Import failed: ${e.message}" + ) } } } @@ -448,13 +443,13 @@ class ClimbViewModel( repository.resetAllData() _uiState.value = - _uiState.value.copy( - isLoading = false, - message = "All data has been reset successfully" - ) + _uiState.value.copy( + isLoading = false, + message = "All data has been reset successfully" + ) } catch (e: Exception) { _uiState.value = - _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}") + _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}") } } } @@ -469,23 +464,20 @@ class ClimbViewModel( val attemptCount = attempts.size val result = - healthConnectManager.autoSyncCompletedSession( - session, - gymName, - attemptCount - ) + healthConnectManager.autoSyncCompletedSession( + session, + gymName, + attemptCount + ) result.onFailure { error -> if (healthConnectManager.isReadySync()) { - android.util.Log.w( - "ClimbViewModel", - "Health Connect sync failed: ${error.message}" - ) + AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" } } } } catch (e: Exception) { if (healthConnectManager.isReadySync()) { - android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}") + AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" } } } } @@ -493,7 +485,7 @@ class ClimbViewModel( } data class ClimbUiState( - val isLoading: Boolean = false, - val message: String? = null, - val error: String? = null + val isLoading: Boolean = false, + val message: String? = null, + val error: String? = null ) diff --git a/android/app/src/main/java/com/atridad/ascently/utils/AppLogger.kt b/android/app/src/main/java/com/atridad/ascently/utils/AppLogger.kt new file mode 100644 index 0000000..ebcec39 --- /dev/null +++ b/android/app/src/main/java/com/atridad/ascently/utils/AppLogger.kt @@ -0,0 +1,48 @@ +package com.atridad.ascently.utils + +import android.util.Log +import com.atridad.ascently.BuildConfig + +object AppLogger { + + private const val DEFAULT_TAG = "Ascently" + + enum class Level(val androidLevel: Int) { + DEBUG(Log.DEBUG), + INFO(Log.INFO), + WARN(Log.WARN), + ERROR(Log.ERROR) + } + + fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) { + log(Level.DEBUG, tag, messageProvider) + } + + fun i(tag: String = DEFAULT_TAG, messageProvider: () -> String) { + log(Level.INFO, tag, messageProvider) + } + + fun w(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) { + log(Level.WARN, tag, messageProvider, throwable) + } + + fun e(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) { + log(Level.ERROR, tag, messageProvider, throwable) + } + + private fun log( + level: Level, + tag: String, + messageProvider: () -> String, + throwable: Throwable? = null + ) { + if (!BuildConfig.DEBUG) return + + val message = messageProvider() + if (throwable != null) { + Log.println(level.androidLevel, tag, "$message\n${Log.getStackTraceString(throwable)}") + } else { + Log.println(level.androidLevel, tag, message) + } + } +} diff --git a/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt index 57ec226..10c3b91 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/ImageUtils.kt @@ -6,7 +6,6 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder import android.net.Uri -import android.util.Log import androidx.core.graphics.scale import androidx.exifinterface.media.ExifInterface import java.io.File @@ -30,17 +29,17 @@ object ImageUtils { /** Saves an image from a URI while preserving EXIF orientation data */ private fun saveImageWithExif( - context: Context, - imageUri: Uri, - originalBitmap: Bitmap, - outputFile: File + context: Context, + imageUri: Uri, + originalBitmap: Bitmap, + outputFile: File ): Boolean { return try { // Get EXIF data from original image val originalExif = - context.contentResolver.openInputStream(imageUri)?.use { input -> - ExifInterface(input) - } + context.contentResolver.openInputStream(imageUri)?.use { input -> + ExifInterface(input) + } // Compress and save the bitmap val compressedBitmap = compressImage(originalBitmap) @@ -73,7 +72,7 @@ object ImageUtils { compressedBitmap.recycle() true } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" } false } } @@ -86,11 +85,11 @@ object ImageUtils { // Calculate the scaling factor val scaleFactor = - if (width > height) { - if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f - } else { - if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f - } + if (width > height) { + if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f + } else { + if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f + } return if (scaleFactor < 1f) { val newWidth = (width * scaleFactor).toInt() @@ -119,7 +118,7 @@ object ImageUtils { val file = getImageFile(context, relativePath) file.delete() } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" } false } } @@ -137,7 +136,7 @@ object ImageUtils { sourceFile.copyTo(destFile, overwrite = true) "$IMAGES_DIR/$filename" } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" } null } } @@ -148,16 +147,16 @@ object ImageUtils { val imagesDir = getImagesDirectory(context) imagesDir.listFiles()?.mapNotNull { file -> if (file.isFile && - (file.extension == "jpg" || - file.extension == "jpeg" || - file.extension == "png") + (file.extension == "jpg" || + file.extension == "jpeg" || + file.extension == "png") ) { "$IMAGES_DIR/${file.name}" } else null } - ?: emptyList() + ?: emptyList() } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" } emptyList() } } @@ -178,50 +177,47 @@ object ImageUtils { tempFilename } catch (e: Exception) { - Log.e("ImageUtils", "Error saving temporary image from URI", e) + AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" } null } } /** Renames a temporary image */ fun renameTemporaryImage( - context: Context, - tempFilename: String, - problemId: String, - imageIndex: Int + context: Context, + tempFilename: String, + problemId: String, + imageIndex: Int ): String? { return try { val tempFile = File(getImagesDirectory(context), tempFilename) if (!tempFile.exists()) { - Log.e("ImageUtils", "Temporary file does not exist: $tempFilename") + AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" } return null } val deterministicFilename = - ImageNamingUtils.generateImageFilename(problemId, imageIndex) + ImageNamingUtils.generateImageFilename(problemId, imageIndex) val finalFile = File(getImagesDirectory(context), deterministicFilename) if (tempFile.renameTo(finalFile)) { - Log.d( - "ImageUtils", - "Renamed temporary image: $tempFilename -> $deterministicFilename" - ) + AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" } deterministicFilename } else { - Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename") + AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" } null } } catch (e: Exception) { - Log.e("ImageUtils", "Error renaming temporary image", e) + AppLogger.e("ImageUtils", e) { "Error renaming temporary image" } null } } /** Saves image data with a specific filename */ fun saveImageFromBytesWithFilename( - context: Context, - imageData: ByteArray, - filename: String + context: Context, + imageData: ByteArray, + filename: String ): String? { return try { val imageFile = File(getImagesDirectory(context), filename) @@ -230,7 +226,7 @@ object ImageUtils { if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold // For large images, decode, compress, and try to preserve EXIF val bitmap = - BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null + BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null val compressedBitmap = compressImage(bitmap) // Save compressed image @@ -249,7 +245,7 @@ object ImageUtils { destExif.saveAttributes() } catch (e: Exception) { // If EXIF preservation fails, continue without it - Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}") + AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" } } bitmap.recycle() @@ -262,7 +258,7 @@ object ImageUtils { // Return relative path "$IMAGES_DIR/$filename" } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" } null } } @@ -275,7 +271,7 @@ object ImageUtils { orphanedImages.forEach { path -> deleteImage(context, path) } } catch (e: Exception) { - e.printStackTrace() + AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" } } } } diff --git a/android/app/src/main/java/com/atridad/ascently/utils/MigrationManager.kt b/android/app/src/main/java/com/atridad/ascently/utils/MigrationManager.kt index 6532668..5bc3496 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/MigrationManager.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/MigrationManager.kt @@ -2,7 +2,6 @@ package com.atridad.ascently.utils import android.content.Context import android.content.SharedPreferences -import android.util.Log import androidx.core.content.edit class MigrationManager(private val context: Context) { @@ -14,7 +13,7 @@ class MigrationManager(private val context: Context) { } private val migrationPrefs: SharedPreferences = - context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE) + context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE) /** * Perform migration from OpenClimb to Ascently if needed This should be called early in app @@ -22,11 +21,11 @@ class MigrationManager(private val context: Context) { */ fun migrateIfNeeded() { if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) { - Log.d(TAG, "Migration already completed, skipping") + AppLogger.d(TAG) { "Migration already completed, skipping" } return } - Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...") + AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." } var migrationCount = 0 // Migrate SharedPreferences @@ -36,12 +35,9 @@ class MigrationManager(private val context: Context) { migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) } if (migrationCount > 0) { - Log.i( - TAG, - "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" - ) + AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" } } else { - Log.i(TAG, "ℹ️ No OpenClimb data found to migrate") + AppLogger.i(TAG) { "ℹ️ No OpenClimb data found to migrate" } } } @@ -51,12 +47,12 @@ class MigrationManager(private val context: Context) { // Define preference file migrations val preferenceFileMigrations = - listOf( - "openclimb_data_state" to "ascently_data_state", - "health_connect_prefs" to "health_connect_prefs", // Keep same name - "deleted_items" to "deleted_items", // Keep same name - "sync_preferences" to "sync_preferences" // Keep same name - ) + listOf( + "openclimb_data_state" to "ascently_data_state", + "health_connect_prefs" to "health_connect_prefs", // Keep same name + "deleted_items" to "deleted_items", // Keep same name + "sync_preferences" to "sync_preferences" // Keep same name + ) for ((oldFileName, newFileName) in preferenceFileMigrations) { if (oldFileName != newFileName) { @@ -95,10 +91,7 @@ class MigrationManager(private val context: Context) { // Clear old preferences oldPrefs.edit { clear() } - Log.d( - TAG, - "✅ Migrated preference file: $oldFileName → $newFileName (${oldPrefs.all.size} keys)" - ) + AppLogger.d(TAG) { "Migrated preference file: $oldFileName → $newFileName (${oldPrefs.all.size} keys)" } return oldPrefs.all.size } @@ -111,12 +104,12 @@ class MigrationManager(private val context: Context) { // Check for any openclimb-prefixed keys across all preference files val preferencesToCheck = - listOf( - "ascently_data_state", - "health_connect_prefs", - "deleted_items", - "sync_preferences" - ) + listOf( + "ascently_data_state", + "health_connect_prefs", + "deleted_items", + "sync_preferences" + ) for (prefFileName in preferencesToCheck) { val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) @@ -150,7 +143,7 @@ class MigrationManager(private val context: Context) { } } - Log.d(TAG, "✅ Migrated ${keysToMigrate.size} keys in $prefFileName") + AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" } count += keysToMigrate.size } } @@ -166,6 +159,6 @@ class MigrationManager(private val context: Context) { /** Reset migration state (for testing purposes) */ fun resetMigrationState() { migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) } - Log.d(TAG, "Migration state reset") + AppLogger.d(TAG) { "Migration state reset" } } } diff --git a/android/app/src/main/java/com/atridad/ascently/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/ZipExportImportUtils.kt index 9db6c8b..3a4d7a6 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/ZipExportImportUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/ZipExportImportUtils.kt @@ -22,19 +22,19 @@ object ZipExportImportUtils { /** Creates a ZIP file containing the JSON data and all referenced images */ fun createExportZip( - context: Context, - exportData: ClimbDataBackup, - referencedImagePaths: Set, - directory: File? = null + context: Context, + exportData: ClimbDataBackup, + referencedImagePaths: Set, + directory: File? = null ): File { val exportDir = - directory - ?: File( - context.getExternalFilesDir( - android.os.Environment.DIRECTORY_DOCUMENTS - ), - "Ascently" - ) + directory + ?: File( + context.getExternalFilesDir( + android.os.Environment.DIRECTORY_DOCUMENTS + ), + "Ascently" + ) if (!exportDir.exists()) { exportDir.mkdirs() } @@ -52,10 +52,11 @@ object ZipExportImportUtils { zipOut.closeEntry() // Add JSON data file - val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - } + val json = + Json { + prettyPrint = true + ignoreUnknownKeys = true + } val jsonString = json.encodeToString(exportData) val jsonEntry = ZipEntry(DATA_JSON_FILENAME) @@ -78,24 +79,21 @@ object ZipExportImportUtils { zipOut.closeEntry() successfulImages++ } else { - android.util.Log.w( - "ZipExportImportUtils", - "Image file not found or empty: $imagePath" - ) + AppLogger.w("ZipExportImportUtils") { + "Image file not found or empty: $imagePath" + } } } catch (e: Exception) { - android.util.Log.e( - "ZipExportImportUtils", - "Failed to add image $imagePath: ${e.message}" - ) + AppLogger.e("ZipExportImportUtils", e) { + "Failed to add image $imagePath: ${e.message}" + } } } // Log export summary - android.util.Log.i( - "ZipExportImportUtils", - "Export completed: ${successfulImages}/${referencedImagePaths.size} images included" - ) + AppLogger.i("ZipExportImportUtils") { + "Export completed: ${successfulImages}/${referencedImagePaths.size} images included" + } } // Validate the created ZIP file @@ -115,10 +113,10 @@ object ZipExportImportUtils { /** Creates a ZIP file and writes it to a provided URI */ fun createExportZipToUri( - context: Context, - uri: android.net.Uri, - exportData: ClimbDataBackup, - referencedImagePaths: Set + context: Context, + uri: android.net.Uri, + exportData: ClimbDataBackup, + referencedImagePaths: Set ) { try { context.contentResolver.openOutputStream(uri)?.use { outputStream -> @@ -131,10 +129,11 @@ object ZipExportImportUtils { zipOut.closeEntry() // Add JSON data file - val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - } + val json = + Json { + prettyPrint = true + ignoreUnknownKeys = true + } val jsonString = json.encodeToString(exportData) val jsonEntry = ZipEntry(DATA_JSON_FILENAME) @@ -158,28 +157,26 @@ object ZipExportImportUtils { successfulImages++ } } catch (e: Exception) { - android.util.Log.e( - "ZipExportImportUtils", - "Failed to add image $imagePath: ${e.message}" - ) + AppLogger.e("ZipExportImportUtils", e) { + "Failed to add image $imagePath: ${e.message}" + } } } - android.util.Log.i( - "ZipExportImportUtils", - "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" - ) + AppLogger.i("ZipExportImportUtils") { + "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" + } } } - ?: throw IOException("Could not open output stream") + ?: throw IOException("Could not open output stream") } catch (e: Exception) { throw IOException("Failed to create export ZIP to URI: ${e.message}") } } private fun createMetadata( - exportData: ClimbDataBackup, - referencedImagePaths: Set + exportData: ClimbDataBackup, + referencedImagePaths: Set ): String { return buildString { appendLine("Ascently Export Metadata") @@ -197,8 +194,8 @@ object ZipExportImportUtils { /** Data class to hold extraction results */ data class ImportResult( - val jsonContent: String, - val importedImagePaths: Map // original filename -> new relative path + val jsonContent: String, + val importedImagePaths: Map // original filename -> new relative path ) /** Extracts a ZIP file and returns the JSON content and imported image paths */ @@ -217,16 +214,17 @@ object ZipExportImportUtils { // Read metadata for validation val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("metadata") - android.util.Log.i( - "ZipExportImportUtils", - "Found metadata: ${metadataContent.lines().take(3).joinToString()}" - ) + AppLogger.i("ZipExportImportUtils") { + "Found metadata: ${metadataContent.lines().take(3).joinToString()}" + } } + entry.name == DATA_JSON_FILENAME -> { // Read JSON data jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("data") } + entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { // Extract image file val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") @@ -234,11 +232,11 @@ object ZipExportImportUtils { try { // Create temporary file to hold the extracted image val tempFile = - File.createTempFile( - "import_image_", - "_$originalFilename", - context.cacheDir - ) + File.createTempFile( + "import_image_", + "_$originalFilename", + context.cacheDir + ) FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } @@ -248,37 +246,33 @@ object ZipExportImportUtils { val newPath = ImageUtils.importImageFile(context, tempFile) if (newPath != null) { importedImagePaths[originalFilename] = newPath - android.util.Log.d( - "ZipExportImportUtils", - "Successfully imported image: $originalFilename -> $newPath" - ) + AppLogger.d("ZipExportImportUtils") { + "Successfully imported image: $originalFilename -> $newPath" + } } else { - android.util.Log.w( - "ZipExportImportUtils", - "Failed to import image: $originalFilename" - ) + AppLogger.w("ZipExportImportUtils") { + "Failed to import image: $originalFilename" + } } } else { - android.util.Log.w( - "ZipExportImportUtils", - "Extracted image is empty: $originalFilename" - ) + AppLogger.w("ZipExportImportUtils") { + "Extracted image is empty: $originalFilename" + } } // Clean up temp file tempFile.delete() } catch (e: Exception) { - android.util.Log.e( - "ZipExportImportUtils", - "Failed to process image $originalFilename: ${e.message}" - ) + AppLogger.e("ZipExportImportUtils", e) { + "Failed to process image $originalFilename: ${e.message}" + } } } + else -> { - android.util.Log.d( - "ZipExportImportUtils", - "Skipping ZIP entry: ${entry.name}" - ) + AppLogger.d("ZipExportImportUtils") { + "Skipping ZIP entry: ${entry.name}" + } } } @@ -296,10 +290,9 @@ object ZipExportImportUtils { throw IOException("Invalid ZIP file: data.json is empty") } - android.util.Log.i( - "ZipExportImportUtils", - "Import extraction completed: ${importedImagePaths.size} images processed" - ) + AppLogger.i("ZipExportImportUtils") { + "Import extraction completed: ${importedImagePaths.size} images processed" + } return ImportResult(jsonContent, importedImagePaths) } catch (e: Exception) { @@ -312,16 +305,16 @@ object ZipExportImportUtils { * the new ones after import */ fun updateProblemImagePaths( - problems: List, - imagePathMapping: Map + problems: List, + imagePathMapping: Map ): List { return problems.map { problem -> val updatedImagePaths = - (problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> - // Extract filename from the old path - val filename = oldPath.substringAfterLast("/") - imagePathMapping[filename] - } + (problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> + // Extract filename from the old path + val filename = oldPath.substringAfterLast("/") + imagePathMapping[filename] + } problem.withUpdatedImagePaths(updatedImagePaths) } } diff --git a/docs/package.json b/docs/package.json index 8355da2..a948604 100644 --- a/docs/package.json +++ b/docs/package.json @@ -25,13 +25,13 @@ "astro": "astro" }, "dependencies": { - "@astrojs/node": "^9.5.0", - "@astrojs/starlight": "^0.36.1", - "astro": "^5.14.6", + "@astrojs/node": "^9.5.1", + "@astrojs/starlight": "^0.36.2", + "astro": "^5.16.0", "qrcode": "^1.5.4", - "sharp": "^0.34.4" + "sharp": "^0.34.5" }, "devDependencies": { - "@types/qrcode": "^1.5.5" + "@types/qrcode": "^1.5.6" } } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 89d41ff..9fd4330 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -9,44 +9,44 @@ importers: .: dependencies: '@astrojs/node': - specifier: ^9.5.0 - version: 9.5.0(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3)) + specifier: ^9.5.1 + version: 9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) '@astrojs/starlight': - specifier: ^0.36.1 - version: 0.36.1(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3)) + specifier: ^0.36.2 + version: 0.36.2(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) astro: - specifier: ^5.14.6 - version: 5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3) + specifier: ^5.16.0 + version: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) qrcode: specifier: ^1.5.4 version: 1.5.4 sharp: - specifier: ^0.34.4 - version: 0.34.4 + specifier: ^0.34.5 + version: 0.34.5 devDependencies: '@types/qrcode': - specifier: ^1.5.5 - version: 1.5.5 + specifier: ^1.5.6 + version: 1.5.6 packages: '@astrojs/compiler@2.13.0': resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==} - '@astrojs/internal-helpers@0.7.4': - resolution: {integrity: sha512-lDA9MqE8WGi7T/t2BMi+EAXhs4Vcvr94Gqx3q15cFEz8oFZMO4/SFBqYr/UcmNlvW+35alowkVj+w9VhLvs5Cw==} + '@astrojs/internal-helpers@0.7.5': + resolution: {integrity: sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==} - '@astrojs/markdown-remark@6.3.8': - resolution: {integrity: sha512-uFNyFWadnULWK2cOw4n0hLKeu+xaVWeuECdP10cQ3K2fkybtTlhb7J7TcScdjmS8Yps7oje9S/ehYMfZrhrgCg==} + '@astrojs/markdown-remark@6.3.9': + resolution: {integrity: sha512-hX2cLC/KW74Io1zIbn92kI482j9J7LleBLGCVU9EP3BeH5MVrnFawOnqD0t/q6D1Z+ZNeQG2gNKMslCcO36wng==} - '@astrojs/mdx@4.3.7': - resolution: {integrity: sha512-5SRmvMyT/UMWaU2eoD+htnXtE2mUZZEH2K/nEzhuEy+iCsOSuS/DUry59WuKUJRQETi1mgJFdNR4dZLJHYVuRA==} + '@astrojs/mdx@4.3.12': + resolution: {integrity: sha512-pL3CVPtuQrPnDhWjy7zqbOibNyPaxP4VpQS8T8spwKqKzauJ4yoKyNkVTD8jrP7EAJHmBhZ7PTmUGZqOpKKp8g==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} peerDependencies: astro: ^5.0.0 - '@astrojs/node@9.5.0': - resolution: {integrity: sha512-x1whLIatmCefaqJA8FjfI+P6FStF+bqmmrib0OUGM1M3cZhAXKLgPx6UF2AzQ3JgpXgCWYM24MHtraPvZhhyLQ==} + '@astrojs/node@9.5.1': + resolution: {integrity: sha512-7k+SU877OUQylPr0mFcWrGvNuC78Lp9w+GInY8Rwc+LkHyDP9xls+nZAioK0WDWd+fyeQnlHbpDGURO3ZHuDVg==} peerDependencies: astro: ^5.14.3 @@ -57,8 +57,8 @@ packages: '@astrojs/sitemap@3.6.0': resolution: {integrity: sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==} - '@astrojs/starlight@0.36.1': - resolution: {integrity: sha512-Fmt8mIsAIZN18Y4YQDI6p521GsYGe4hYxh9jWmz0pHBXnS5J7Na3TSXNya4eyIymCcKkuiKFbs7b/knsdGVYPg==} + '@astrojs/starlight@0.36.2': + resolution: {integrity: sha512-QR8NfO7+7DR13kBikhQwAj3IAoptLLNs9DkyKko2M2l3PrqpcpVUnw1JBJ0msGDIwE6tBbua2UeBND48mkh03w==} peerDependencies: astro: ^5.5.0 @@ -70,12 +70,12 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -83,173 +83,173 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@capsizecss/unpack@3.0.0': - resolution: {integrity: sha512-+ntATQe1AlL7nTOYjwjj6w3299CgRot48wL761TUGYpYgAou3AaONZazp0PKZyCyWhudWsjhq1nvRHOvbMzhTA==} + '@capsizecss/unpack@3.0.1': + resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==} engines: {node: '>=18'} '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -270,124 +270,135 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.4': - resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.4': - resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.3': - resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.3': - resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.3': - resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.3': - resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.3': - resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.3': - resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.3': - resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': - resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.3': - resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.4': - resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.4': - resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.4': - resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - '@img/sharp-linux-s390x@0.34.4': - resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.4': - resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.4': - resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.4': - resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.4': - resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.4': - resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.4': - resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.4': - resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -443,133 +454,133 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.52.5': - resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.52.5': - resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.52.5': - resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.52.5': - resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.52.5': - resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.52.5': - resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': - resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.52.5': - resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.52.5': - resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.52.5': - resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.52.5': - resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.52.5': - resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.52.5': - resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.52.5': - resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.52.5': - resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.52.5': - resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.52.5': - resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.52.5': - resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.52.5': - resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.52.5': - resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.52.5': - resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.52.5': - resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} cpu: [x64] os: [win32] - '@shikijs/core@3.13.0': - resolution: {integrity: sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==} + '@shikijs/core@3.15.0': + resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} - '@shikijs/engine-javascript@3.13.0': - resolution: {integrity: sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==} + '@shikijs/engine-javascript@3.15.0': + resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==} - '@shikijs/engine-oniguruma@3.13.0': - resolution: {integrity: sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==} + '@shikijs/engine-oniguruma@3.15.0': + resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==} - '@shikijs/langs@3.13.0': - resolution: {integrity: sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==} + '@shikijs/langs@3.15.0': + resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==} - '@shikijs/themes@3.13.0': - resolution: {integrity: sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==} + '@shikijs/themes@3.15.0': + resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==} - '@shikijs/types@3.13.0': - resolution: {integrity: sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==} + '@shikijs/types@3.15.0': + resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -610,11 +621,11 @@ packages: '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - '@types/node@24.8.1': - resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} - '@types/qrcode@1.5.5': - resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -683,8 +694,8 @@ packages: peerDependencies: astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 - astro@5.14.6: - resolution: {integrity: sha512-MSdjKt2W2a56x868DqDWgbfw4D689/8EGhHG4465h7eivTI237u1aBx4iJvgI6WfgdUE61+coAvMjUkEvOWbpA==} + astro@5.16.0: + resolution: {integrity: sha512-GaDRs2Mngpw3dr2vc085GnORh98NiXxwIjg/EoQQQl/icZt3Z7s0BRsYHDZ8swkZbOA6wZsqWJdrNirl+iKcDg==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -780,6 +791,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -793,18 +808,33 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - css-selector-parser@3.1.3: - resolution: {integrity: sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-selector-parser@3.2.0: + resolution: {integrity: sha512-L1bdkNKUP5WYxiW5dW6vA2hd3sL8BdRNLy2FCX0rLVise4eNw9nBdeBuJHxlELieSE2H1f6bYQFfwVUwWCV9rQ==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -843,8 +873,8 @@ packages: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} - devalue@5.4.1: - resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -866,6 +896,19 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -883,6 +926,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -896,8 +943,8 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -1066,8 +1113,8 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} i18next@23.16.8: @@ -1079,8 +1126,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inline-style-parser@0.2.4: - resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -1119,18 +1166,14 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - klona@2.0.6: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} @@ -1145,11 +1188,11 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} @@ -1212,6 +1255,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -1327,9 +1373,9 @@ packages: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} - engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} @@ -1363,8 +1409,8 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - ofetch@1.4.1: - resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -1426,6 +1472,9 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1573,13 +1622,13 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} - rollup@4.52.5: - resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - sax@1.4.1: - resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + sax@1.4.3: + resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} @@ -1599,23 +1648,23 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.34.4: - resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shiki@3.13.0: - resolution: {integrity: sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==} + shiki@3.15.0: + resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==} sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - sitemap@8.0.1: - resolution: {integrity: sha512-4Y8ynSMFAy/DadeAeio8Kx4zfC8/0VcKi7TH0I1SazvBcrU2fpJaGoeWsX1FMRaHoe3VGMA53DqVoLErZrtG9Q==} + sitemap@8.0.2: + resolution: {integrity: sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==} engines: {node: '>=14.0.0', npm: '>=6.0.0'} hasBin: true - smol-toml@1.4.2: - resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==} + smol-toml@1.5.2: + resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} source-map-js@1.2.1: @@ -1629,10 +1678,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1659,17 +1704,23 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - style-to-js@1.1.18: - resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - style-to-object@1.0.11: - resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + svgo@4.0.0: + resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} + engines: {node: '>=16'} + hasBin: true tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} @@ -1716,8 +1767,8 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - undici-types@7.14.0: - resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -1761,8 +1812,8 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - unstorage@1.17.1: - resolution: {integrity: sha512-KKGwRTT0iVBCErKemkJCLs7JdxNVfqTPc/85ae1XES0+bsHbc/sFBfVi5kJp156cc51BHinIH2l3k0EZ24vOBQ==} + unstorage@1.17.3: + resolution: {integrity: sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==} peerDependencies: '@azure/app-configuration': ^1.8.0 '@azure/cosmos': ^4.2.0 @@ -1835,8 +1886,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@6.4.0: - resolution: {integrity: sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==} + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -1923,8 +1974,8 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} - yocto-queue@1.2.1: - resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} yocto-spinner@0.2.3: @@ -1935,10 +1986,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.24.6: - resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} peerDependencies: - zod: ^3.24.1 + zod: ^3.25 || ^4 zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} @@ -1956,17 +2007,17 @@ snapshots: '@astrojs/compiler@2.13.0': {} - '@astrojs/internal-helpers@0.7.4': {} + '@astrojs/internal-helpers@0.7.5': {} - '@astrojs/markdown-remark@6.3.8': + '@astrojs/markdown-remark@6.3.9': dependencies: - '@astrojs/internal-helpers': 0.7.4 + '@astrojs/internal-helpers': 0.7.5 '@astrojs/prism': 3.3.0 github-slugger: 2.0.0 hast-util-from-html: 2.0.3 hast-util-to-text: 4.0.2 import-meta-resolve: 4.2.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 @@ -1974,8 +2025,8 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 - shiki: 3.13.0 - smol-toml: 1.4.2 + shiki: 3.15.0 + smol-toml: 1.5.2 unified: 11.0.5 unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 @@ -1984,16 +2035,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.7(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3))': + '@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': dependencies: - '@astrojs/markdown-remark': 6.3.8 + '@astrojs/markdown-remark': 6.3.9 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3) + astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 - kleur: 4.1.5 + piccolore: 0.1.3 rehype-raw: 7.0.0 remark-gfm: 4.0.1 remark-smartypants: 3.0.2 @@ -2003,10 +2054,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@9.5.0(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3))': + '@astrojs/node@9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': dependencies: - '@astrojs/internal-helpers': 0.7.4 - astro: 5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3) + '@astrojs/internal-helpers': 0.7.5 + astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) send: 1.2.0 server-destroy: 1.0.1 transitivePeerDependencies: @@ -2018,28 +2069,28 @@ snapshots: '@astrojs/sitemap@3.6.0': dependencies: - sitemap: 8.0.1 + sitemap: 8.0.2 stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/starlight@0.36.1(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3))': + '@astrojs/starlight@0.36.2(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': dependencies: - '@astrojs/markdown-remark': 6.3.8 - '@astrojs/mdx': 4.3.7(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3)) + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/mdx': 4.3.12(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) '@astrojs/sitemap': 3.6.0 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3) - astro-expressive-code: 0.41.3(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3)) + astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) + astro-expressive-code: 0.41.3(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 hast-util-to-string: 3.0.1 hastscript: 9.0.1 i18next: 23.16.8 - js-yaml: 4.1.0 + js-yaml: 4.1.1 klona: 2.0.6 mdast-util-directive: 3.1.0 mdast-util-to-markdown: 2.1.2 @@ -2069,106 +2120,106 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/parser@7.28.4': + '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/runtime@7.28.4': {} - '@babel/types@7.28.4': + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - '@capsizecss/unpack@3.0.0': + '@capsizecss/unpack@3.0.1': dependencies: fontkit: 2.0.4 '@ctrl/tinycolor@4.2.0': {} - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.11': + '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/android-arm64@0.25.11': + '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.25.11': + '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-x64@0.25.11': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.25.11': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-x64@0.25.11': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.25.11': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/linux-arm64@0.25.11': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm@0.25.11': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-ia32@0.25.11': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-loong64@0.25.11': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-ppc64@0.25.11': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.25.11': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-s390x@0.25.11': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-x64@0.25.11': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.25.11': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.11': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.25.11': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.25.11': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.25.11': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.25.11': + '@esbuild/win32-x64@0.25.12': optional: true '@expressive-code/core@0.41.3': @@ -2190,7 +2241,7 @@ snapshots: '@expressive-code/plugin-shiki@0.41.3': dependencies: '@expressive-code/core': 0.41.3 - shiki: 3.13.0 + shiki: 3.15.0 '@expressive-code/plugin-text-markers@0.41.3': dependencies: @@ -2198,90 +2249,98 @@ snapshots: '@img/colour@1.0.0': {} - '@img/sharp-darwin-arm64@0.34.4': + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.34.4': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.3': + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.3': + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.3': + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.3': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.3': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.3': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.3': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.3': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-linux-arm64@0.34.4': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.34.4': + '@img/sharp-linux-arm@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.34.4': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.34.4': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-x64@0.34.4': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.4': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.4': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-wasm32@0.34.4': + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 optional: true - '@img/sharp-win32-arm64@0.34.4': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-ia32@0.34.4': + '@img/sharp-win32-ia32@0.34.5': optional: true - '@img/sharp-win32-x64@0.34.4': + '@img/sharp-win32-x64@0.34.5': optional: true '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2338,107 +2397,107 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true - '@rollup/pluginutils@5.3.0(rollup@4.52.5)': + '@rollup/pluginutils@5.3.0(rollup@4.53.3)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.52.5 + rollup: 4.53.3 - '@rollup/rollup-android-arm-eabi@4.52.5': + '@rollup/rollup-android-arm-eabi@4.53.3': optional: true - '@rollup/rollup-android-arm64@4.52.5': + '@rollup/rollup-android-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-arm64@4.52.5': + '@rollup/rollup-darwin-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-x64@4.52.5': + '@rollup/rollup-darwin-x64@4.53.3': optional: true - '@rollup/rollup-freebsd-arm64@4.52.5': + '@rollup/rollup-freebsd-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-x64@4.52.5': + '@rollup/rollup-freebsd-x64@4.53.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.52.5': + '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.52.5': + '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.52.5': + '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.52.5': + '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.52.5': + '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.52.5': + '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.52.5': + '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.52.5': + '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.52.5': + '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-musl@4.52.5': + '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.52.5': + '@rollup/rollup-openharmony-arm64@4.53.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.52.5': + '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.52.5': + '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.52.5': + '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.52.5': + '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@shikijs/core@3.13.0': + '@shikijs/core@3.15.0': dependencies: - '@shikijs/types': 3.13.0 + '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.13.0': + '@shikijs/engine-javascript@3.15.0': dependencies: - '@shikijs/types': 3.13.0 + '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.3 - '@shikijs/engine-oniguruma@3.13.0': + '@shikijs/engine-oniguruma@3.15.0': dependencies: - '@shikijs/types': 3.13.0 + '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.13.0': + '@shikijs/langs@3.15.0': dependencies: - '@shikijs/types': 3.13.0 + '@shikijs/types': 3.15.0 - '@shikijs/themes@3.13.0': + '@shikijs/themes@3.15.0': dependencies: - '@shikijs/types': 3.13.0 + '@shikijs/types': 3.15.0 - '@shikijs/types@3.13.0': + '@shikijs/types@3.15.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -2461,7 +2520,7 @@ snapshots: '@types/fontkit@2.0.8': dependencies: - '@types/node': 24.8.1 + '@types/node': 24.10.1 '@types/hast@3.0.4': dependencies: @@ -2483,13 +2542,13 @@ snapshots: '@types/node@17.0.45': {} - '@types/node@24.8.1': + '@types/node@24.10.1': dependencies: - undici-types: 7.14.0 + undici-types: 7.16.0 - '@types/qrcode@1.5.5': + '@types/qrcode@1.5.6': dependencies: - '@types/node': 24.8.1 + '@types/node': 24.10.1 '@types/sax@1.2.7': dependencies: @@ -2536,20 +2595,20 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.3(astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3)): + astro-expressive-code@0.41.3(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)): dependencies: - astro: 5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3) + astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) rehype-expressive-code: 0.41.3 - astro@5.14.6(@types/node@24.8.1)(rollup@4.52.5)(typescript@5.9.3): + astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3): dependencies: '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.4 - '@astrojs/markdown-remark': 6.3.8 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.0 + '@capsizecss/unpack': 3.0.1 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 @@ -2561,12 +2620,12 @@ snapshots: cssesc: 3.0.0 debug: 4.4.3 deterministic-object-hash: 2.0.2 - devalue: 5.4.1 + devalue: 5.5.0 diff: 5.2.0 dlv: 1.1.3 dset: 3.1.4 es-module-lexer: 1.7.0 - esbuild: 0.25.11 + esbuild: 0.25.12 estree-walker: 3.0.3 flattie: 1.1.1 fontace: 0.3.1 @@ -2574,39 +2633,40 @@ snapshots: html-escaper: 3.0.3 http-cache-semantics: 4.2.0 import-meta-resolve: 4.2.0 - js-yaml: 4.1.0 - kleur: 4.1.5 - magic-string: 0.30.19 - magicast: 0.3.5 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 mrmime: 2.0.1 neotraverse: 0.6.18 p-limit: 6.2.0 p-queue: 8.1.1 package-manager-detector: 1.5.0 + piccolore: 0.1.3 picomatch: 4.0.3 prompts: 2.4.2 rehype: 13.0.2 semver: 7.7.3 - shiki: 3.13.0 - smol-toml: 1.4.2 - tinyexec: 1.0.1 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tsconfck: 3.1.6(typescript@5.9.3) ultrahtml: 1.6.0 unifont: 0.6.0 unist-util-visit: 5.0.0 - unstorage: 1.17.1 + unstorage: 1.17.3 vfile: 6.0.3 - vite: 6.4.0(@types/node@24.8.1) - vitefu: 1.1.1(vite@6.4.0(@types/node@24.8.1)) + vite: 6.4.1(@types/node@24.10.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@24.10.1)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) optionalDependencies: - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -2719,6 +2779,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@11.1.0: {} + common-ancestor-path@1.0.1: {} cookie-es@1.2.2: {} @@ -2729,15 +2791,34 @@ snapshots: dependencies: uncrypto: 0.1.3 - css-selector-parser@3.1.3: {} + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.2.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-what@6.2.2: {} + cssesc@3.0.0: {} + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2762,7 +2843,7 @@ snapshots: dependencies: base-64: 1.0.0 - devalue@5.4.1: {} + devalue@5.5.0: {} devlop@1.1.0: dependencies: @@ -2778,6 +2859,24 @@ snapshots: dlv@1.1.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dset@3.1.4: {} ee-first@1.1.1: {} @@ -2788,6 +2887,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + entities@6.0.1: {} es-module-lexer@1.7.0: {} @@ -2806,34 +2907,34 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild@0.25.11: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escape-html@1.0.3: {} @@ -3029,7 +3130,7 @@ snapshots: '@types/unist': 3.0.3 bcp-47-match: 2.0.3 comma-separated-tokens: 2.0.3 - css-selector-parser: 3.1.3 + css-selector-parser: 3.2.0 devlop: 1.1.0 direction: 2.0.1 hast-util-has-property: 3.0.0 @@ -3056,7 +3157,7 @@ snapshots: mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - style-to-js: 1.1.18 + style-to-js: 1.1.21 unist-util-position: 5.0.0 zwitch: 2.0.4 transitivePeerDependencies: @@ -3090,7 +3191,7 @@ snapshots: mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - style-to-js: 1.1.18 + style-to-js: 1.1.21 unist-util-position: 5.0.0 vfile-message: 4.0.3 transitivePeerDependencies: @@ -3137,12 +3238,12 @@ snapshots: http-cache-semantics@4.2.0: {} - http-errors@2.0.0: + http-errors@2.0.1: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 toidentifier: 1.0.1 i18next@23.16.8: @@ -3153,7 +3254,7 @@ snapshots: inherits@2.0.4: {} - inline-style-parser@0.2.4: {} + inline-style-parser@0.2.7: {} iron-webcrypto@1.2.1: {} @@ -3182,14 +3283,12 @@ snapshots: dependencies: is-inside-container: 1.0.0 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 kleur@3.0.3: {} - kleur@4.1.5: {} - klona@2.0.6: {} locate-path@5.0.0: @@ -3200,14 +3299,14 @@ snapshots: lru-cache@10.4.3: {} - magic-string@0.30.19: + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.3.5: + magicast@0.5.1: dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 source-map-js: 1.2.1 markdown-extensions@2.0.0: {} @@ -3397,6 +3496,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.0.28: {} + mdn-data@2.12.2: {} micromark-core-commonmark@2.0.3: @@ -3675,7 +3776,7 @@ snapshots: mime-db@1.54.0: {} - mime-types@3.0.1: + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -3701,7 +3802,7 @@ snapshots: dependencies: boolbase: 1.0.0 - ofetch@1.4.1: + ofetch@1.5.1: dependencies: destr: 2.0.5 node-fetch-native: 1.6.7 @@ -3727,7 +3828,7 @@ snapshots: p-limit@6.2.0: dependencies: - yocto-queue: 1.2.1 + yocto-queue: 1.2.2 p-locate@4.1.0: dependencies: @@ -3780,6 +3881,8 @@ snapshots: path-exists@4.0.0: {} + piccolore@0.1.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3996,35 +4099,35 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 - rollup@4.52.5: + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.52.5 - '@rollup/rollup-android-arm64': 4.52.5 - '@rollup/rollup-darwin-arm64': 4.52.5 - '@rollup/rollup-darwin-x64': 4.52.5 - '@rollup/rollup-freebsd-arm64': 4.52.5 - '@rollup/rollup-freebsd-x64': 4.52.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 - '@rollup/rollup-linux-arm-musleabihf': 4.52.5 - '@rollup/rollup-linux-arm64-gnu': 4.52.5 - '@rollup/rollup-linux-arm64-musl': 4.52.5 - '@rollup/rollup-linux-loong64-gnu': 4.52.5 - '@rollup/rollup-linux-ppc64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-musl': 4.52.5 - '@rollup/rollup-linux-s390x-gnu': 4.52.5 - '@rollup/rollup-linux-x64-gnu': 4.52.5 - '@rollup/rollup-linux-x64-musl': 4.52.5 - '@rollup/rollup-openharmony-arm64': 4.52.5 - '@rollup/rollup-win32-arm64-msvc': 4.52.5 - '@rollup/rollup-win32-ia32-msvc': 4.52.5 - '@rollup/rollup-win32-x64-gnu': 4.52.5 - '@rollup/rollup-win32-x64-msvc': 4.52.5 + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 - sax@1.4.1: {} + sax@1.4.3: {} semver@7.7.3: {} @@ -4035,8 +4138,8 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 - mime-types: 3.0.1 + http-errors: 2.0.1 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -4050,56 +4153,58 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.34.4: + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 semver: 7.7.3 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.4 - '@img/sharp-darwin-x64': 0.34.4 - '@img/sharp-libvips-darwin-arm64': 1.2.3 - '@img/sharp-libvips-darwin-x64': 1.2.3 - '@img/sharp-libvips-linux-arm': 1.2.3 - '@img/sharp-libvips-linux-arm64': 1.2.3 - '@img/sharp-libvips-linux-ppc64': 1.2.3 - '@img/sharp-libvips-linux-s390x': 1.2.3 - '@img/sharp-libvips-linux-x64': 1.2.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 - '@img/sharp-linux-arm': 0.34.4 - '@img/sharp-linux-arm64': 0.34.4 - '@img/sharp-linux-ppc64': 0.34.4 - '@img/sharp-linux-s390x': 0.34.4 - '@img/sharp-linux-x64': 0.34.4 - '@img/sharp-linuxmusl-arm64': 0.34.4 - '@img/sharp-linuxmusl-x64': 0.34.4 - '@img/sharp-wasm32': 0.34.4 - '@img/sharp-win32-arm64': 0.34.4 - '@img/sharp-win32-ia32': 0.34.4 - '@img/sharp-win32-x64': 0.34.4 + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 - shiki@3.13.0: + shiki@3.15.0: dependencies: - '@shikijs/core': 3.13.0 - '@shikijs/engine-javascript': 3.13.0 - '@shikijs/engine-oniguruma': 3.13.0 - '@shikijs/langs': 3.13.0 - '@shikijs/themes': 3.13.0 - '@shikijs/types': 3.13.0 + '@shikijs/core': 3.15.0 + '@shikijs/engine-javascript': 3.15.0 + '@shikijs/engine-oniguruma': 3.15.0 + '@shikijs/langs': 3.15.0 + '@shikijs/themes': 3.15.0 + '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 sisteransi@1.0.5: {} - sitemap@8.0.1: + sitemap@8.0.2: dependencies: '@types/node': 17.0.45 '@types/sax': 1.2.7 arg: 5.0.2 - sax: 1.4.1 + sax: 1.4.3 - smol-toml@1.4.2: {} + smol-toml@1.5.2: {} source-map-js@1.2.1: {} @@ -4107,8 +4212,6 @@ snapshots: space-separated-tokens@2.0.2: {} - statuses@2.0.1: {} - statuses@2.0.2: {} stream-replace-string@2.0.0: {} @@ -4138,17 +4241,27 @@ snapshots: dependencies: ansi-regex: 6.2.2 - style-to-js@1.1.18: + style-to-js@1.1.21: dependencies: - style-to-object: 1.0.11 + style-to-object: 1.0.14 - style-to-object@1.0.11: + style-to-object@1.0.14: dependencies: - inline-style-parser: 0.2.4 + inline-style-parser: 0.2.7 + + svgo@4.0.0: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.1.0 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.4.3 tiny-inflate@1.0.3: {} - tinyexec@1.0.1: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: dependencies: @@ -4177,7 +4290,7 @@ snapshots: uncrypto@0.1.3: {} - undici-types@7.14.0: {} + undici-types@7.16.0: {} unicode-properties@1.4.1: dependencies: @@ -4202,7 +4315,7 @@ snapshots: unifont@0.6.0: dependencies: css-tree: 3.1.0 - ofetch: 1.4.1 + ofetch: 1.5.1 ohash: 2.0.11 unist-util-find-after@5.0.0: @@ -4251,7 +4364,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unstorage@1.17.1: + unstorage@1.17.3: dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -4259,7 +4372,7 @@ snapshots: h3: 1.15.4 lru-cache: 10.4.3 node-fetch-native: 1.6.7 - ofetch: 1.4.1 + ofetch: 1.5.1 ufo: 1.6.1 util-deprecate@1.0.2: {} @@ -4279,21 +4392,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.0(@types/node@24.8.1): + vite@6.4.1(@types/node@24.10.1): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.52.5 + rollup: 4.53.3 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.8.1 + '@types/node': 24.10.1 fsevents: 2.3.3 - vitefu@1.1.1(vite@6.4.0(@types/node@24.8.1)): + vitefu@1.1.1(vite@6.4.1(@types/node@24.10.1)): optionalDependencies: - vite: 6.4.0(@types/node@24.8.1) + vite: 6.4.1(@types/node@24.10.1) web-namespaces@2.0.1: {} @@ -4342,7 +4455,7 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 - yocto-queue@1.2.1: {} + yocto-queue@1.2.2: {} yocto-spinner@0.2.3: dependencies: @@ -4350,7 +4463,7 @@ snapshots: yoctocolors@2.1.2: {} - zod-to-json-schema@3.24.6(zod@3.25.76): + zod-to-json-schema@3.25.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/ios/Ascently.xcodeproj/LiveActivityManager.swift b/ios/Ascently.xcodeproj/LiveActivityManager.swift index d148bc8..da23ee2 100644 --- a/ios/Ascently.xcodeproj/LiveActivityManager.swift +++ b/ios/Ascently.xcodeproj/LiveActivityManager.swift @@ -25,7 +25,7 @@ final class LiveActivityManager { pushType: nil ) } catch { - print("Failed to start live activity: \(error)") + AppLogger.error("Failed to start live activity: \(error)", tag: "LegacyLiveActivityManager") } } diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index b0bb1cd..658b5f3 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -487,7 +487,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -535,7 +535,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -613,7 +613,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -643,7 +643,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 9d0efa4..af9392d 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/AppIntents/AscentlyShortcuts.swift b/ios/Ascently/AppIntents/AscentlyShortcuts.swift new file mode 100644 index 0000000..7dd86a6 --- /dev/null +++ b/ios/Ascently/AppIntents/AscentlyShortcuts.swift @@ -0,0 +1,33 @@ +import AppIntents + +/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app. +/// Surfaces intents that users can trigger hands-free to manage their climbing sessions. +struct AscentlyShortcuts: AppShortcutsProvider { + + static var shortcutTileColor: ShortcutTileColor { + .teal + } + + static var appShortcuts: [AppShortcut] { + return [ + AppShortcut( + intent: StartLastGymSessionIntent(), + phrases: [ + "Start my climb in \(.applicationName)", + "Begin my last gym session in \(.applicationName)", + ], + shortTitle: "Start Climb", + systemImageName: "figure.climbing" + ), + AppShortcut( + intent: EndActiveSessionIntent(), + phrases: [ + "Finish my climb in \(.applicationName)", + "End my session in \(.applicationName)", + ], + shortTitle: "End Climb", + systemImageName: "flag.checkered" + ), + ] + } +} diff --git a/ios/Ascently/AppIntents/EndActiveSessionIntent.swift b/ios/Ascently/AppIntents/EndActiveSessionIntent.swift new file mode 100644 index 0000000..f71530d --- /dev/null +++ b/ios/Ascently/AppIntents/EndActiveSessionIntent.swift @@ -0,0 +1,40 @@ +import AppIntents +import Foundation + +/// Ends the currently active climbing session so logging stays in sync across devices. +/// Exposed to Shortcuts so users can wrap up a session without opening the app. +struct EndActiveSessionIntent: AppIntent { + + static var title: LocalizedStringResource { + "End Active Session" + } + + static var description: IntentDescription { + IntentDescription( + "Stop the active climbing session and save its progress in Ascently." + ) + } + + static var openAppWhenRun: Bool { + false + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + do { + let summary = try await SessionIntentController().endActiveSession() + let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!") + return .result(dialog: dialog) + } catch SessionIntentError.noActiveSession { + // No active session is fine - just return a friendly message + let dialog = IntentDialog("No active session to end.") + return .result(dialog: dialog) + } catch { + // Re-throw other errors + throw error + } + } + + static var parameterSummary: some ParameterSummary { + Summary("End my current climbing session") + } +} diff --git a/ios/Ascently/AppIntents/SessionIntentSupport.swift b/ios/Ascently/AppIntents/SessionIntentSupport.swift new file mode 100644 index 0000000..4f21dca --- /dev/null +++ b/ios/Ascently/AppIntents/SessionIntentSupport.swift @@ -0,0 +1,95 @@ +import Foundation + +/// User-visible errors that can arise while handling session-related intents. +enum SessionIntentError: LocalizedError { + case noRecentGym + case noActiveSession + case failedToStartSession + case failedToEndSession + + var errorDescription: String? { + switch self { + case .noRecentGym: + return "There's no recent gym to start a session with." + case .noActiveSession: + return "There isn't an active session to end right now." + case .failedToStartSession: + return "Ascently couldn't start a new session." + case .failedToEndSession: + return "Ascently couldn't finish the active session." + } + } +} + +struct SessionIntentSummary: Sendable { + let sessionId: UUID + let gymName: String + let status: SessionStatus +} + +/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts. +@MainActor +final class SessionIntentController { + + private let dataManager: ClimbingDataManager + + init(dataManager: ClimbingDataManager = .shared) { + self.dataManager = dataManager + } + + /// Starts a new session using the most recently visited gym. + func startSessionWithLastUsedGym() async throws -> SessionIntentSummary { + // Give a moment for data to be ready if app just launched + if dataManager.gyms.isEmpty { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } + + guard let lastGym = dataManager.getLastUsedGym() else { + logFailure(.noRecentGym, context: "No recorded sessions available") + throw SessionIntentError.noRecentGym + } + + guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else { + logFailure(.failedToStartSession, context: "Data manager failed to create new session") + throw SessionIntentError.failedToStartSession + } + + return SessionIntentSummary( + sessionId: startedSession.id, + gymName: lastGym.name, + status: startedSession.status + ) + } + + /// Ends the currently active climbing session, if one exists. + func endActiveSession() async throws -> SessionIntentSummary { + guard let activeSession = dataManager.activeSession else { + logFailure(.noActiveSession, context: "No active session stored in data manager") + throw SessionIntentError.noActiveSession + } + + guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else { + logFailure( + .failedToEndSession, context: "Data manager failed to complete active session") + throw SessionIntentError.failedToEndSession + } + + guard let gym = dataManager.gym(withId: completedSession.gymId) else { + logFailure( + .failedToEndSession, + context: "Gym missing for completed session \(completedSession.id)") + throw SessionIntentError.failedToEndSession + } + + return SessionIntentSummary( + sessionId: completedSession.id, + gymName: gym.name, + status: completedSession.status + ) + } + + private func logFailure(_ error: SessionIntentError, context: String) { + // Logging from intent context - errors are visible to user via dialog + print("SessionIntentError: \(error). Context: \(context)") + } +} diff --git a/ios/Ascently/AppIntents/StartLastGymSessionIntent.swift b/ios/Ascently/AppIntents/StartLastGymSessionIntent.swift new file mode 100644 index 0000000..4af91d8 --- /dev/null +++ b/ios/Ascently/AppIntents/StartLastGymSessionIntent.swift @@ -0,0 +1,43 @@ +import AppIntents +import Foundation + +/// Starts a climbing session at the most recently visited gym. +/// Exposed to Shortcuts so users can begin logging without opening the app. +struct StartLastGymSessionIntent: AppIntent { + + static var title: LocalizedStringResource { + "Start Last Gym Session" + } + + static var description: IntentDescription { + IntentDescription( + "Begin a new climbing session using the most recent gym you visited in Ascently." + ) + } + + static var openAppWhenRun: Bool { + true + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + // Delay to ensure app has time to fully initialize if just launched + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + + let summary = try await SessionIntentController().startSessionWithLastUsedGym() + + // Give Live Activity extra time to start + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + return .result( + dialog: Self.successDialog(for: summary.gymName) + ) + } + + private static func successDialog(for gymName: String) -> IntentDialog { + IntentDialog("Session started at \(gymName). Have an awesome climb!") + } + + static var parameterSummary: some ParameterSummary { + Summary("Start a session at my last gym") + } +} diff --git a/ios/Ascently/AscentlyApp.swift b/ios/Ascently/AscentlyApp.swift index 1d10d71..f476c2d 100644 --- a/ios/Ascently/AscentlyApp.swift +++ b/ios/Ascently/AscentlyApp.swift @@ -1,7 +1,19 @@ import SwiftUI +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + return true + } +} + @main struct AscentlyApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @Environment(\.scenePhase) private var scenePhase + var body: some Scene { WindowGroup { ContentView() diff --git a/ios/Ascently/ContentView.swift b/ios/Ascently/ContentView.swift index 906cb68..52dd10a 100644 --- a/ios/Ascently/ContentView.swift +++ b/ios/Ascently/ContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @StateObject private var dataManager = ClimbingDataManager() + @StateObject private var dataManager = ClimbingDataManager.shared @State private var selectedTab = 0 @Environment(\.scenePhase) private var scenePhase @State private var notificationObservers: [NSObjectProtocol] = [] @@ -91,11 +91,12 @@ struct ContentView: View { object: nil, queue: .main ) { _ in - print("App will enter foreground - preparing Live Activity check") - Task { + Task { @MainActor in + AppLogger.info( + "App will enter foreground - preparing Live Activity check", tag: "Lifecycle") // Small delay to ensure app is fully active try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds - await dataManager.onAppBecomeActive() + dataManager.onAppBecomeActive() // Re-verify health integration when returning from background await dataManager.healthKitService.verifyAndRestoreIntegration() } @@ -107,10 +108,11 @@ struct ContentView: View { object: nil, queue: .main ) { _ in - print("App did become active - checking Live Activity status") - Task { + Task { @MainActor in + AppLogger.info( + "App did become active - checking Live Activity status", tag: "Lifecycle") try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds - await dataManager.onAppBecomeActive() + dataManager.onAppBecomeActive() await dataManager.healthKitService.verifyAndRestoreIntegration() } } diff --git a/ios/Ascently/Info.plist b/ios/Ascently/Info.plist index b71fedf..9a5b5a9 100644 --- a/ios/Ascently/Info.plist +++ b/ios/Ascently/Info.plist @@ -6,6 +6,7 @@ NSSupportsLiveActivities + NSPhotoLibraryUsageDescription This app needs access to your photo library to add photos to climbing problems. NSCameraUsageDescription diff --git a/ios/Ascently/Services/HealthKitService.swift b/ios/Ascently/Services/HealthKitService.swift index 9cc3983..d22f03a 100644 --- a/ios/Ascently/Services/HealthKitService.swift +++ b/ios/Ascently/Services/HealthKitService.swift @@ -38,7 +38,7 @@ class HealthKitService: ObservableObject { { currentWorkoutStartDate = startDate currentWorkoutSessionId = sessionId - print("HealthKit: Restored active workout from \(startDate)") + AppLogger.info("HealthKit: Restored active workout from \(startDate)", tag: "HealthKit") } } @@ -56,31 +56,34 @@ class HealthKitService: ObservableObject { guard isEnabled else { return } guard HKHealthStore.isHealthDataAvailable() else { - print("HealthKit: Device does not support HealthKit") + AppLogger.warning("HealthKit: Device does not support HealthKit", tag: "HealthKit") return } checkAuthorization() if !isAuthorized { - print( - "HealthKit: Integration was enabled but authorization lost, attempting to restore..." - ) + AppLogger.warning( + "HealthKit: Integration was enabled but authorization lost, attempting to restore...", + tag: "HealthKit") do { try await requestAuthorization() - print("HealthKit: Authorization restored successfully") + AppLogger.info("HealthKit: Authorization restored successfully", tag: "HealthKit") } catch { - print("HealthKit: Failed to restore authorization: \(error.localizedDescription)") + AppLogger.error( + "HealthKit: Failed to restore authorization: \(error.localizedDescription)", + tag: "HealthKit") } } else { - print("HealthKit: Integration verified - authorization is valid") + AppLogger.info( + "HealthKit: Integration verified - authorization is valid", tag: "HealthKit") } if hasActiveWorkout() { - print( - "HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)" - ) + AppLogger.info( + "HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)", + tag: "HealthKit") } } @@ -130,7 +133,7 @@ class HealthKitService: ObservableObject { currentWorkoutStartDate = startDate currentWorkoutSessionId = sessionId persistActiveWorkout() - print("HealthKit: Started workout for session \(sessionId)") + AppLogger.info("HealthKit: Started workout for session \(sessionId)", tag: "HealthKit") } func endWorkout(endDate: Date) async throws { @@ -178,15 +181,17 @@ class HealthKitService: ObservableObject { try await builder.endCollection(at: endDate) let workout = try await builder.finishWorkout() - print( - "HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")" - ) + AppLogger.info( + "HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")", + tag: "HealthKit") currentWorkoutStartDate = nil currentWorkoutSessionId = nil persistActiveWorkout() } catch { - print("HealthKit: Failed to save workout: \(error.localizedDescription)") + AppLogger.error( + "HealthKit: Failed to save workout: \(error.localizedDescription)", tag: "HealthKit" + ) currentWorkoutStartDate = nil currentWorkoutSessionId = nil persistActiveWorkout() @@ -199,7 +204,7 @@ class HealthKitService: ObservableObject { currentWorkoutStartDate = nil currentWorkoutSessionId = nil persistActiveWorkout() - print("HealthKit: Workout cancelled") + AppLogger.info("HealthKit: Workout cancelled", tag: "HealthKit") } func hasActiveWorkout() -> Bool { diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift index 07e416c..1f71a01 100644 --- a/ios/Ascently/Services/SyncService.swift +++ b/ios/Ascently/Services/SyncService.swift @@ -12,10 +12,27 @@ class SyncService: ObservableObject { @Published var isOfflineMode = false private let userDefaults = UserDefaults.standard + private let logTag = "SyncService" private var syncTask: Task? private var pendingChanges = false private let syncDebounceDelay: TimeInterval = 2.0 + private func logDebug(_ message: @autoclosure () -> String) { + AppLogger.debug(message(), tag: logTag) + } + + private func logInfo(_ message: @autoclosure () -> String) { + AppLogger.info(message(), tag: logTag) + } + + private func logWarning(_ message: @autoclosure () -> String) { + AppLogger.warning(message(), tag: logTag) + } + + private func logError(_ message: @autoclosure () -> String) { + AppLogger.error(message(), tag: logTag) + } + private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" @@ -201,7 +218,7 @@ class SyncService: ObservableObject { return false } - print( + logInfo( "iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)" ) @@ -244,7 +261,7 @@ class SyncService: ObservableObject { let decoder = JSONDecoder() let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) - print( + logInfo( "iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)" ) @@ -270,7 +287,7 @@ class SyncService: ObservableObject { let allDeletions = dataManager.getDeletedItems() + response.deletedItems let uniqueDeletions = Array(Set(allDeletions)) - print( + logInfo( "iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data" ) applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) @@ -298,10 +315,10 @@ class SyncService: ObservableObject { _ = try imageManager.saveImportedImage(imageData, filename: consistentFilename) imagePathMapping[serverFilename] = consistentFilename } catch SyncError.imageNotFound { - print("Image not found on server: \(serverFilename)") + logInfo("Image not found on server: \(serverFilename)") continue } catch { - print("Failed to download image \(serverFilename): \(error)") + logInfo("Failed to download image \(serverFilename): \(error)") continue } } @@ -436,7 +453,7 @@ class SyncService: ObservableObject { ) async throws { guard !modifiedProblems.isEmpty else { return } - print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems") + logInfo("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems") for backupProblem in modifiedProblems { guard @@ -465,9 +482,9 @@ class SyncService: ObservableObject { } try await uploadImage(filename: consistentFilename, imageData: imageData) - print("Uploaded modified problem image: \(consistentFilename)") + logInfo("Uploaded modified problem image: \(consistentFilename)") } catch { - print("Failed to upload image \(consistentFilename): \(error)") + logInfo("Failed to upload image \(consistentFilename): \(error)") } } } @@ -549,7 +566,7 @@ class SyncService: ObservableObject { func syncWithServer(dataManager: ClimbingDataManager) async throws { if isOfflineMode { - print("Sync skipped: Offline mode is enabled.") + logInfo("Sync skipped: Offline mode is enabled.") return } @@ -586,7 +603,7 @@ class SyncService: ObservableObject { // If both client and server have been synced before, use delta sync if hasLocalData && hasServerData && lastSyncTime != nil { - print("iOS SYNC: Using delta sync for incremental updates") + logInfo("iOS SYNC: Using delta sync for incremental updates") try await performDeltaSync(dataManager: dataManager) // Update last sync time @@ -597,32 +614,32 @@ class SyncService: ObservableObject { if !hasLocalData && hasServerData { // Case 1: No local data - do full restore from server - print("iOS SYNC: Case 1 - No local data, performing full restore from server") - print("Syncing images from server first...") + logInfo("iOS SYNC: Case 1 - No local data, performing full restore from server") + logInfo("Syncing images from server first...") let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) - print("Importing data after images...") + logInfo("Importing data after images...") try importBackupToDataManager( serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) - print("Full restore completed") + logInfo("Full restore completed") } else if hasLocalData && !hasServerData { // Case 2: No server data - upload local data to server - print("iOS SYNC: Case 2 - No server data, uploading local data to server") + logInfo("iOS SYNC: Case 2 - No server data, uploading local data to server") let currentBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(currentBackup) - print("Uploading local images to server...") + logInfo("Uploading local images to server...") try await syncImagesToServer(dataManager: dataManager) - print("Initial upload completed") + logInfo("Initial upload completed") } else if hasLocalData && hasServerData { // Case 3: Both have data - use safe merge strategy - print("iOS SYNC: Case 3 - Merging local and server data safely") + logInfo("iOS SYNC: Case 3 - Merging local and server data safely") try await mergeDataSafely( localBackup: localBackup, serverBackup: serverBackup, dataManager: dataManager) - print("Safe merge completed") + logInfo("Safe merge completed") } else { - print("No data to sync") + logInfo("No data to sync") } // Update last sync time @@ -640,7 +657,7 @@ class SyncService: ObservableObject { if let date = formatter.date(from: timestamp) { return Int64(date.timeIntervalSince1970 * 1000) } - print("Failed to parse timestamp: \(timestamp), using 0") + logInfo("Failed to parse timestamp: \(timestamp), using 0") return 0 } @@ -666,12 +683,12 @@ class SyncService: ObservableObject { imageData, filename: consistentFilename) imagePathMapping[serverFilename] = consistentFilename - print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") + logInfo("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") } catch SyncError.imageNotFound { - print("Image not found on server: \(serverFilename)") + logInfo("Image not found on server: \(serverFilename)") continue } catch { - print("Failed to download image \(serverFilename): \(error)") + logInfo("Failed to download image \(serverFilename): \(error)") continue } } @@ -704,18 +721,18 @@ class SyncService: ObservableObject { ).path do { try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) - print("Renamed local image: \(filename) -> \(consistentFilename)") + logInfo("Renamed local image: \(filename) -> \(consistentFilename)") // Update problem's image path in memory for consistency } catch { - print("Failed to rename local image, using original: \(error)") + logInfo("Failed to rename local image, using original: \(error)") } } try await uploadImage(filename: consistentFilename, imageData: imageData) - print("Successfully uploaded image: \(consistentFilename)") + logInfo("Successfully uploaded image: \(consistentFilename)") } catch { - print("Failed to upload image \(consistentFilename): \(error)") + logInfo("Failed to upload image \(consistentFilename): \(error)") // Continue with other images even if one fails } } @@ -733,7 +750,7 @@ class SyncService: ObservableObject { !activeSessionIds.contains($0.sessionId) } - print( + logInfo( "iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync" ) @@ -808,26 +825,26 @@ class SyncService: ObservableObject { let allDeletions = localDeletions + serverBackup.deletedItems let uniqueDeletions = Array(Set(allDeletions)) - print("Merging gyms...") + logInfo("Merging gyms...") let mergedGyms = mergeGyms( local: dataManager.gyms, server: serverBackup.gyms, deletedItems: uniqueDeletions) - print("Merging problems...") + logInfo("Merging problems...") let mergedProblems = try mergeProblems( local: dataManager.problems, server: serverBackup.problems, imagePathMapping: imagePathMapping, deletedItems: uniqueDeletions) - print("Merging sessions...") + logInfo("Merging sessions...") let mergedSessions = try mergeSessions( local: dataManager.sessions, server: serverBackup.sessions, deletedItems: uniqueDeletions) - print("Merging attempts...") + logInfo("Merging attempts...") let mergedAttempts = try mergeAttempts( local: dataManager.attempts, server: serverBackup.attempts, @@ -887,7 +904,7 @@ class SyncService: ObservableObject { && !allDeletedAttemptIds.contains($0.id.uuidString) } - print( + logInfo( "iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import" ) @@ -977,7 +994,7 @@ class SyncService: ObservableObject { // Restore active sessions and their attempts after import for session in activeSessions { - print("iOS IMPORT: Restoring active session: \(session.id)") + logInfo("iOS IMPORT: Restoring active session: \(session.id)") dataManager.sessions.append(session) if session.id == dataManager.activeSession?.id { dataManager.activeSession = session @@ -997,12 +1014,12 @@ class SyncService: ObservableObject { dataManager.clearDeletedItems() if let data = try? JSONEncoder().encode(backup.deletedItems) { UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") + logInfo("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") } // Update local data state to match imported data timestamp DataStateManager.shared.setLastModified(backup.exportedAt) - print("Data state synchronized to imported timestamp: \(backup.exportedAt)") + logInfo("Data state synchronized to imported timestamp: \(backup.exportedAt)") } catch { throw SyncError.importFailed(error) diff --git a/ios/Ascently/Utils/AppLogger.swift b/ios/Ascently/Utils/AppLogger.swift new file mode 100644 index 0000000..1ac791c --- /dev/null +++ b/ios/Ascently/Utils/AppLogger.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Centralized logging utility for the iOS app. +/// +/// All log output is automatically compiled out in non-debug builds to avoid leaking +/// sensitive information. Use this instead of calling `print` directly. +enum AppLogger { + + enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case warning = "WARN" + case error = "ERROR" + } + + static func debug(_ message: @autoclosure () -> String, tag: String = #fileID) { + log(level: .debug, tag: tag, message: message()) + } + + static func info(_ message: @autoclosure () -> String, tag: String = #fileID) { + log(level: .info, tag: tag, message: message()) + } + + static func warning(_ message: @autoclosure () -> String, tag: String = #fileID) { + log(level: .warning, tag: tag, message: message()) + } + + static func error(_ message: @autoclosure () -> String, tag: String = #fileID) { + log(level: .error, tag: tag, message: message()) + } + + static func log(level: LogLevel, tag: String, message: @autoclosure () -> String) { + #if DEBUG + let lastPath = (tag as NSString).lastPathComponent + let resolvedTag = lastPath.isEmpty ? tag : lastPath + Swift.print("[\(level.rawValue)][\(resolvedTag)] \(message())") + #endif + } +} + +enum LogTag { + static let climbingData = "ClimbingData" + static let dataManagement = "DataManagementSection" + static let exportData = "ExportDataView" + static let syncSection = "SyncSection" +} diff --git a/ios/Ascently/Utils/DataStateManager.swift b/ios/Ascently/Utils/DataStateManager.swift index 307d33d..53c84be 100644 --- a/ios/Ascently/Utils/DataStateManager.swift +++ b/ios/Ascently/Utils/DataStateManager.swift @@ -18,14 +18,17 @@ class DataStateManager { private init() { // Initialize with current timestamp if this is the first time if !isInitialized() { - print("DataStateManager: First time initialization") + AppLogger.info("DataStateManager: First time initialization", tag: "DataState") // Set initial timestamp to a very old date so server data will be considered newer let epochTime = "1970-01-01T00:00:00.000Z" userDefaults.set(epochTime, forKey: Keys.lastModified) markAsInitialized() - print("DataStateManager initialized with epoch timestamp: \(epochTime)") + AppLogger.info( + "DataStateManager initialized with epoch timestamp: \(epochTime)", tag: "DataState") } else { - print("DataStateManager: Already initialized, current timestamp: \(getLastModified())") + AppLogger.info( + "DataStateManager: Already initialized, current timestamp: \(getLastModified())", + tag: "DataState") } } @@ -34,29 +37,32 @@ class DataStateManager { func updateDataState() { let now = ISO8601DateFormatter().string(from: Date()) userDefaults.set(now, forKey: Keys.lastModified) - print("iOS Data state updated to: \(now)") + AppLogger.info("iOS Data state updated to: \(now)", tag: "DataState") } func getLastModified() -> String { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { - print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)") + AppLogger.debug( + "iOS DataStateManager returning stored timestamp: \(storedTimestamp)", + tag: "DataState") return storedTimestamp } let epochTime = "1970-01-01T00:00:00.000Z" - print("No data state timestamp found - returning epoch time: \(epochTime)") + AppLogger.warning( + "No data state timestamp found - returning epoch time: \(epochTime)", tag: "DataState") return epochTime } func setLastModified(_ timestamp: String) { userDefaults.set(timestamp, forKey: Keys.lastModified) - print("Data state set to: \(timestamp)") + AppLogger.info("Data state set to: \(timestamp)", tag: "DataState") } func reset() { userDefaults.removeObject(forKey: Keys.lastModified) userDefaults.removeObject(forKey: Keys.initialized) - print("Data state reset") + AppLogger.info("Data state reset", tag: "DataState") } private func isInitialized() -> Bool { diff --git a/ios/Ascently/Utils/ImageManager.swift b/ios/Ascently/Utils/ImageManager.swift index 22fde9e..5e20ee7 100644 --- a/ios/Ascently/Utils/ImageManager.swift +++ b/ios/Ascently/Utils/ImageManager.swift @@ -5,6 +5,7 @@ import UIKit class ImageManager { static let shared = ImageManager() + private let logTag = "ImageManager" private let thumbnailCache = NSCache() private let fileManager = FileManager.default @@ -30,7 +31,7 @@ class ImageManager { // Final integrity check if !validateStorageIntegrity() { - print("CRITICAL: Storage integrity compromised - attempting emergency recovery") + logError("CRITICAL: Storage integrity compromised - attempting emergency recovery") emergencyImageRestore() } @@ -83,7 +84,7 @@ class ImageManager { return } - print("🔄 Migrating images from OpenClimb to Ascently directory...") + logInfo("🔄 Migrating images from OpenClimb to Ascently directory...") do { // Create parent directory if needed @@ -94,16 +95,16 @@ class ImageManager { // Move the entire directory try fileManager.moveItem(at: legacyDir, to: appSupportDirectory) - print("Successfully migrated image directory from OpenClimb to Ascently") + logInfo("Successfully migrated image directory from OpenClimb to Ascently") } catch { - print("❌ Failed to migrate image directory: \(error)") + logError("Failed to migrate image directory: \(error)") // If move fails, try to copy instead do { try fileManager.copyItem(at: legacyDir, to: appSupportDirectory) - print("Successfully copied image directory from OpenClimb to Ascently") + logInfo("Successfully copied image directory from OpenClimb to Ascently") // Don't remove the old directory in case of issues } catch { - print("❌ Failed to copy image directory: \(error)") + logError("Failed to copy image directory: \(error)") } } } @@ -122,9 +123,9 @@ class ImageManager { attributes: [ .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication ]) - print("Created directory: \(directory.path)") + logInfo("Created directory: \(directory.path)") } catch { - print("ERROR: Failed to create directory \(directory.path): \(error)") + logError("ERROR: Failed to create directory \(directory.path): \(error)") } } } @@ -141,9 +142,9 @@ class ImageManager { var backupURL = backupDirectory try imagesURL.setResourceValues(resourceValues) try backupURL.setResourceValues(resourceValues) - print("Excluded image directories from iCloud backup") + logInfo("Excluded image directories from iCloud backup") } catch { - print("WARNING: Failed to exclude from iCloud backup: \(error)") + logWarning("WARNING: Failed to exclude from iCloud backup: \(error)") } } @@ -167,11 +168,11 @@ class ImageManager { } private func performRobustMigration() { - print("Starting robust image migration system...") + logInfo("Starting robust image migration system...") // Check for interrupted migration if let incompleteState = loadMigrationState() { - print("Detected interrupted migration, resuming...") + logInfo("Detected interrupted migration, resuming...") resumeMigration(from: incompleteState) } else { // Start fresh migration @@ -188,7 +189,7 @@ class ImageManager { private func startNewMigration() { // First check for images in previous Application Support directories if let previousAppSupportImages = findPreviousAppSupportImages() { - print("Found images in previous Application Support directory") + logInfo("Found images in previous Application Support directory") migratePreviousAppSupportImages(from: previousAppSupportImages) return } @@ -198,7 +199,7 @@ class ImageManager { let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) guard hasLegacyImages || hasLegacyImportImages else { - print("No legacy images to migrate") + logInfo("No legacy images to migrate") return } @@ -213,7 +214,7 @@ class ImageManager { let legacyFiles = try fileManager.contentsOfDirectory( atPath: legacyImagesDirectory.path) allLegacyFiles.append(contentsOf: legacyFiles) - print("Found \(legacyFiles.count) images in OpenClimbImages") + logInfo("Found \(legacyFiles.count) images in OpenClimbImages") } // Collect files from Documents/images directory @@ -221,10 +222,10 @@ class ImageManager { let importFiles = try fileManager.contentsOfDirectory( atPath: legacyImportImagesDirectory.path) allLegacyFiles.append(contentsOf: importFiles) - print("Found \(importFiles.count) images in Documents/images") + logInfo("Found \(importFiles.count) images in Documents/images") } - print("Total legacy images to migrate: \(allLegacyFiles.count)") + logInfo("Total legacy images to migrate: \(allLegacyFiles.count)") let initialState = MigrationState( version: MigrationState.currentVersion, @@ -239,24 +240,24 @@ class ImageManager { performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) } catch { - print("ERROR: Failed to start migration: \(error)") + logError("ERROR: Failed to start migration: \(error)") } } private func resumeMigration(from state: MigrationState) { - print("Resuming migration from checkpoint...") - print("Progress: \(state.completedFiles.count)/\(state.totalFiles)") + logInfo("Resuming migration from checkpoint...") + logInfo("Progress: \(state.completedFiles.count)/\(state.totalFiles)") do { let legacyFiles = try fileManager.contentsOfDirectory( atPath: legacyImagesDirectory.path) let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } - print("Resuming with \(remainingFiles.count) remaining files") + logInfo("Resuming with \(remainingFiles.count) remaining files") performMigrationWithCheckpoints(files: remainingFiles, currentState: state) } catch { - print("ERROR: Failed to resume migration: \(error)") + logError("ERROR: Failed to resume migration: \(error)") // Fallback: start fresh removeMigrationState() startNewMigration() @@ -323,11 +324,11 @@ class ImageManager { completedFiles.append(fileName) migratedCount += 1 - print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") + logInfo("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") } catch { failedCount += 1 - print("ERROR: Failed to migrate \(fileName): \(error)") + logError("ERROR: Failed to migrate \(fileName): \(error)") } // Save checkpoint every 5 files or if interrupted @@ -341,7 +342,7 @@ class ImageManager { lastCheckpoint: Date() ) saveMigrationState(checkpointState) - print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") + logInfo("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") } } } @@ -357,7 +358,7 @@ class ImageManager { ) saveMigrationState(finalState) - print("Migration complete: \(migratedCount) migrated, \(failedCount) failed") + logInfo("Migration complete: \(migratedCount) migrated, \(failedCount) failed") // Clean up legacy directory if no failures if failedCount == 0 { @@ -366,7 +367,7 @@ class ImageManager { } private func verifyMigrationIntegrity() { - print("Verifying migration integrity...") + logInfo("Verifying migration integrity...") var allLegacyFiles = Set() @@ -384,12 +385,12 @@ class ImageManager { allLegacyFiles.formUnion(importFiles) } } catch { - print("ERROR: Failed to read legacy directories: \(error)") + logError("ERROR: Failed to read legacy directories: \(error)") return } guard !allLegacyFiles.isEmpty else { - print("No legacy directories to verify against") + logInfo("No legacy directories to verify against") return } @@ -400,10 +401,10 @@ class ImageManager { let missingFiles = allLegacyFiles.subtracting(migratedFiles) if missingFiles.isEmpty { - print("Migration integrity verified - all files present") + logInfo("Migration integrity verified - all files present") cleanupLegacyDirectory() } else { - print("WARNING: Missing \(missingFiles.count) files, re-triggering migration") + logWarning("WARNING: Missing \(missingFiles.count) files, re-triggering migration") // Re-trigger migration for missing files performMigrationWithCheckpoints( files: Array(missingFiles), @@ -417,16 +418,16 @@ class ImageManager { )) } } catch { - print("ERROR: Failed to verify migration integrity: \(error)") + logError("ERROR: Failed to verify migration integrity: \(error)") } } private func cleanupLegacyDirectory() { do { try fileManager.removeItem(at: legacyImagesDirectory) - print("Cleaned up legacy directory") + logInfo("Cleaned up legacy directory") } catch { - print("WARNING: Failed to clean up legacy directory: \(error)") + logWarning("WARNING: Failed to clean up legacy directory: \(error)") } } @@ -446,16 +447,16 @@ class ImageManager { let data = try Data(contentsOf: migrationStateURL) let state = try JSONDecoder().decode(MigrationState.self, from: data) - // Check if state is too old (more than 1 hour) + // Check if state is too old if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { - print("WARNING: Migration state is stale, starting fresh") + logWarning("WARNING: Migration state is stale, starting fresh") removeMigrationState() return nil } return state.isComplete ? nil : state } catch { - print("ERROR: Failed to load migration state: \(error)") + logError("ERROR: Failed to load migration state: \(error)") removeMigrationState() return nil } @@ -466,7 +467,7 @@ class ImageManager { let data = try JSONEncoder().encode(state) try data.write(to: migrationStateURL) } catch { - print("ERROR: Failed to save migration state: \(error)") + logError("ERROR: Failed to save migration state: \(error)") } } @@ -482,7 +483,7 @@ class ImageManager { private func cleanupMigrationState() { try? fileManager.removeItem(at: migrationStateURL) try? fileManager.removeItem(at: migrationLockURL) - print("Cleaned up migration state files") + logInfo("Cleaned up migration state files") } func saveImageData(_ data: Data, withName name: String? = nil) -> String? { @@ -497,10 +498,10 @@ class ImageManager { // Create backup copy try data.write(to: backupPath) - print("Saved image with backup: \(fileName)") + logInfo("Saved image with backup: \(fileName)") return fileName } catch { - print("ERROR: Failed to save image \(fileName): \(error)") + logError("ERROR: Failed to save image \(fileName): \(error)") return nil } } @@ -520,7 +521,7 @@ class ImageManager { if fileManager.fileExists(atPath: backupPath.path), let data = try? Data(contentsOf: backupPath) { - print("Restored image from backup: \(path)") + logInfo("Restored image from backup: \(path)") // Restore to primary location try? data.write(to: URL(fileURLWithPath: primaryPath)) @@ -595,7 +596,7 @@ class ImageManager { do { try fileManager.removeItem(atPath: primaryPath) } catch { - print("ERROR: Failed to delete primary image at \(primaryPath): \(error)") + logError("ERROR: Failed to delete primary image at \(primaryPath): \(error)") success = false } } @@ -605,7 +606,7 @@ class ImageManager { do { try fileManager.removeItem(at: backupPath) } catch { - print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)") + logError("ERROR: Failed to delete backup image at \(backupPath.path): \(error)") success = false } } @@ -642,7 +643,7 @@ class ImageManager { } func performMaintenance() { - print("Starting image maintenance...") + logInfo("Starting image maintenance...") syncBackups() validateImageIntegrity() @@ -660,11 +661,11 @@ class ImageManager { let backupPath = backupDirectory.appendingPathComponent(fileName) try? fileManager.copyItem(at: primaryPath, to: backupPath) - print("Created missing backup for: \(fileName)") + logInfo("Created missing backup for: \(fileName)") } } } catch { - print("ERROR: Failed to sync backups: \(error)") + logError("ERROR: Failed to sync backups: \(error)") } } @@ -683,14 +684,14 @@ class ImageManager { } } - print("Validated \(validFiles) of \(files.count) image files") + logInfo("Validated \(validFiles) of \(files.count) image files") } catch { - print("ERROR: Failed to validate images: \(error)") + logError("ERROR: Failed to validate images: \(error)") } } private func cleanupOrphanedFiles() { - print("Cleanup would require coordination with data manager") + logInfo("Cleanup would require coordination with data manager") } func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { @@ -718,7 +719,7 @@ class ImageManager { private func logDirectoryInfo() { let info = getStorageInfo() let previousDir = findPreviousAppSupportImages() - print( + logInfo( """ Ascently Image Storage: - App Support: \(appSupportDirectory.path) @@ -732,7 +733,7 @@ class ImageManager { } func forceRecoveryMigration() { - print("FORCE RECOVERY: Starting manual migration recovery...") + logInfo("FORCE RECOVERY: Starting manual migration recovery...") // Remove any stale state removeMigrationState() @@ -741,7 +742,7 @@ class ImageManager { // Force fresh migration startNewMigration() - print("FORCE RECOVERY: Migration recovery completed") + logInfo("FORCE RECOVERY: Migration recovery completed") } func saveImportedImage(_ imageData: Data, filename: String) throws -> String { @@ -754,12 +755,12 @@ class ImageManager { // Create backup try? imageData.write(to: backupPath) - print("Imported image: \(filename)") + logInfo("Imported image: \(filename)") return filename } func emergencyImageRestore() { - print("EMERGENCY: Attempting image restoration...") + logError("EMERGENCY: Attempting image restoration...") // Try to restore from backup directory do { @@ -777,14 +778,14 @@ class ImageManager { } } - print("EMERGENCY: Restored \(restoredCount) images from backup") + logError("EMERGENCY: Restored \(restoredCount) images from backup") } catch { - print("EMERGENCY: Failed to restore from backup: \(error)") + logError("EMERGENCY: Failed to restore from backup: \(error)") } // Try previous Application Support directories first if let previousAppSupportImages = findPreviousAppSupportImages() { - print("EMERGENCY: Found previous Application Support images, migrating...") + logError("EMERGENCY: Found previous Application Support images, migrating...") migratePreviousAppSupportImages(from: previousAppSupportImages) return } @@ -793,23 +794,21 @@ class ImageManager { if fileManager.fileExists(atPath: legacyImagesDirectory.path) || fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { - print("EMERGENCY: Attempting legacy migration as fallback...") + logError("EMERGENCY: Attempting legacy migration as fallback...") forceRecoveryMigration() } } func debugSafeInitialization() -> Bool { - print("DEBUG SAFE: Performing debug-safe initialization check...") + logDebug("DEBUG SAFE: Performing debug-safe initialization check...") // Check if we're in a debug environment #if DEBUG - print("DEBUG SAFE: Debug environment detected") + logDebug("DEBUG SAFE: Debug environment detected") - // Check for interrupted migration more aggressively if fileManager.fileExists(atPath: migrationLockURL.path) { - print("DEBUG SAFE: Found migration lock - likely debug interruption") + logDebug("DEBUG SAFE: Found migration lock - likely debug interruption") - // Give extra time for file system to stabilize Thread.sleep(forTimeInterval: 1.0) // Try emergency recovery @@ -829,14 +828,14 @@ class ImageManager { ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 if primaryEmpty && backupHasFiles { - print("DEBUG SAFE: Primary empty but backup exists - restoring") + logDebug("DEBUG SAFE: Primary empty but backup exists - restoring") emergencyImageRestore() return true } // Check if primary storage is empty but previous Application Support images exist if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { - print("DEBUG SAFE: Primary empty but found previous Application Support images") + logDebug("DEBUG SAFE: Primary empty but found previous Application Support images") migratePreviousAppSupportImages(from: previousAppSupportImages) return true } @@ -852,7 +851,7 @@ class ImageManager { // Check if we have more backups than primary files (sign of corruption) if backupFiles.count > primaryFiles.count + 5 { - print( + logInfo( "WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption" ) return false @@ -860,7 +859,7 @@ class ImageManager { // Check if primary is completely empty but we have data elsewhere if primaryFiles.isEmpty && !backupFiles.isEmpty { - print("WARNING INTEGRITY: Primary storage empty but backups exist") + logWarning("WARNING INTEGRITY: Primary storage empty but backups exist") return false } @@ -874,7 +873,7 @@ class ImageManager { for: .applicationSupportDirectory, in: .userDomainMask ).first else { - print("ERROR: Could not access Application Support directory") + logError("ERROR: Could not access Application Support directory") return nil } @@ -908,13 +907,13 @@ class ImageManager { } } } catch { - print("ERROR: Error scanning for previous Application Support directories: \(error)") + logError("ERROR: Error scanning for previous Application Support directories: \(error)") } return nil } private func migratePreviousAppSupportImages(from sourceDirectory: URL) { - print("Migrating images from previous Application Support directory") + logInfo("Migrating images from previous Application Support directory") do { let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) @@ -937,18 +936,33 @@ class ImageManager { // Create backup try? fileManager.copyItem(at: sourcePath, to: backupPath) - print("Migrated: \(fileName)") + logInfo("Migrated: \(fileName)") } catch { - print("ERROR: Failed to migrate \(fileName): \(error)") + logError("ERROR: Failed to migrate \(fileName): \(error)") } } } - print("Completed migration from previous Application Support directory") + logInfo("Completed migration from previous Application Support directory") } catch { - print("ERROR: Failed to migrate from previous Application Support: \(error)") + logError("ERROR: Failed to migrate from previous Application Support: \(error)") } } + private func logInfo(_ message: String) { + AppLogger.info(message, tag: logTag) + } + + private func logWarning(_ message: String) { + AppLogger.warning(message, tag: logTag) + } + + private func logError(_ message: String) { + AppLogger.error(message, tag: logTag) + } + + private func logDebug(_ message: String) { + AppLogger.debug(message, tag: logTag) + } } diff --git a/ios/Ascently/Utils/ZipUtils.swift b/ios/Ascently/Utils/ZipUtils.swift index d47b1bf..9ca275f 100644 --- a/ios/Ascently/Utils/ZipUtils.swift +++ b/ios/Ascently/Utils/ZipUtils.swift @@ -4,6 +4,8 @@ import zlib struct ZipUtils { + private static let logTag = "ZipUtils" + private static let DATA_JSON_FILENAME = "data.json" private static let IMAGES_DIR_NAME = "images" private static let METADATA_FILENAME = "metadata.txt" @@ -49,7 +51,7 @@ struct ZipUtils { ) // Process images in batches for better performance - print("Processing \(referencedImagePaths.count) images for export") + logInfo("Processing \(referencedImagePaths.count) images for export") var successfulImages = 0 let batchSize = 10 let sortedPaths = Array(referencedImagePaths).sorted() @@ -59,7 +61,7 @@ struct ZipUtils { for (index, imagePath) in sortedPaths.enumerated() { if index % batchSize == 0 { - print("Processing images \(index)/\(sortedPaths.count)") + logInfo("Processing images \(index)/\(sortedPaths.count)") } let imageURL = URL(fileURLWithPath: imagePath) @@ -83,11 +85,11 @@ struct ZipUtils { successfulImages += 1 } } catch { - print("Failed to read image: \(imageName)") + logWarning("Failed to read image: \(imageName)") } } - print("Export: included \(successfulImages)/\(referencedImagePaths.count) images") + logInfo("Export: included \(successfulImages)/\(referencedImagePaths.count) images") // Build central directory centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry @@ -114,7 +116,7 @@ struct ZipUtils { } static func extractImportZip(data: Data) throws -> ImportResult { - print("Starting ZIP extraction - data size: \(data.count) bytes") + logInfo("Starting ZIP extraction - data size: \(data.count) bytes") return try extractUsingCustomParser(data: data) } @@ -127,10 +129,10 @@ struct ZipUtils { let zipEntries: [ZipEntry] do { zipEntries = try parseZipFile(data: data) - print("Successfully parsed ZIP file with \(zipEntries.count) entries") + logInfo("Successfully parsed ZIP file with \(zipEntries.count) entries") } catch { - print("Failed to parse ZIP file: \(error)") - print( + logError("Failed to parse ZIP file: \(error)") + logError( "ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))" ) throw NSError( @@ -142,24 +144,24 @@ struct ZipUtils { ) } - print("Found \(zipEntries.count) entries in ZIP file:") + logInfo("Found \(zipEntries.count) entries in ZIP file:") for entry in zipEntries { - print(" - \(entry.filename) (size: \(entry.data.count) bytes)") + logInfo(" - \(entry.filename) (size: \(entry.data.count) bytes)") } for entry in zipEntries { switch entry.filename { case METADATA_FILENAME: metadataContent = String(data: entry.data, encoding: .utf8) ?? "" - print("Found metadata: \(metadataContent.prefix(100))...") + logInfo("Found metadata: \(metadataContent.prefix(100))...") case DATA_JSON_FILENAME: jsonContent = String(data: entry.data, encoding: .utf8) ?? "" - print("Found data.json with \(jsonContent.count) characters") + logInfo("Found data.json with \(jsonContent.count) characters") if jsonContent.isEmpty { - print("WARNING: data.json is empty!") + logWarning("WARNING: data.json is empty!") } else { - print("data.json preview: \(jsonContent.prefix(200))...") + logInfo("data.json preview: \(jsonContent.prefix(200))...") } default: @@ -173,17 +175,17 @@ struct ZipUtils { entry.data, filename: originalFilename) importedImagePaths[originalFilename] = filename } catch { - print("Failed to import image \(originalFilename): \(error)") + logError("Failed to import image \(originalFilename): \(error)") } } } } guard !jsonContent.isEmpty else { - print("ERROR: data.json not found or empty") - print("Available files in ZIP:") + logError("ERROR: data.json not found or empty") + logInfo("Available files in ZIP:") for entry in zipEntries { - print(" - \(entry.filename)") + logInfo(" - \(entry.filename)") } throw NSError( domain: "ImportError", code: 1, @@ -194,13 +196,25 @@ struct ZipUtils { ) } - print("Import extraction completed: \(importedImagePaths.count) images processed") + logInfo("Import extraction completed: \(importedImagePaths.count) images processed") return ImportResult( jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths ) } + private static func logInfo(_ message: String) { + AppLogger.info(message, tag: logTag) + } + + private static func logWarning(_ message: String) { + AppLogger.warning(message, tag: logTag) + } + + private static func logError(_ message: String) { + AppLogger.error(message, tag: logTag) + } + private static func createMetadata( exportData: ClimbDataBackup, referencedImagePaths: Set diff --git a/ios/Ascently/ViewModels/ClimbingDataManager.swift b/ios/Ascently/ViewModels/ClimbingDataManager.swift index 67d3f96..246d123 100644 --- a/ios/Ascently/ViewModels/ClimbingDataManager.swift +++ b/ios/Ascently/ViewModels/ClimbingDataManager.swift @@ -15,6 +15,8 @@ import UniformTypeIdentifiers @MainActor class ClimbingDataManager: ObservableObject { + static let shared = ClimbingDataManager() + @Published var gyms: [Gym] = [] @Published var problems: [Problem] = [] @Published var sessions: [ClimbSession] = [] @@ -38,7 +40,6 @@ class ClimbingDataManager: ObservableObject { let healthKitService = HealthKitService.shared @Published var isSyncing = false - private enum Keys { static let gyms = "ascently_gyms" static let problems = "ascently_problems" @@ -79,7 +80,7 @@ class ClimbingDataManager: ObservableObject { let name: String } - init() { + fileprivate init() { _ = ImageManager.shared migrateFromOpenClimbIfNeeded() loadAllData() @@ -115,7 +116,8 @@ class ClimbingDataManager: ObservableObject { return } - print("Starting migration from OpenClimb to Ascently keys...") + AppLogger.info( + "Starting migration from OpenClimb to Ascently keys...", tag: LogTag.climbingData) var migrationCount = 0 // Migrate each data type if it exists in old format but not in new format @@ -135,7 +137,7 @@ class ClimbingDataManager: ObservableObject { userDefaults.set(oldData, forKey: newKey) userDefaults.removeObject(forKey: oldKey) migrationCount += 1 - print("✅ Migrated: \(oldKey) → \(newKey)") + AppLogger.info("Migrated: \(oldKey) → \(newKey)", tag: LogTag.climbingData) } } @@ -147,7 +149,8 @@ class ClimbingDataManager: ObservableObject { { sharedDefaults.set(oldData, forKey: newKey) sharedDefaults.removeObject(forKey: oldKey) - print("✅ Migrated shared: \(oldKey) → \(newKey)") + AppLogger.info( + "Migrated shared: \(oldKey) → \(newKey)", tag: LogTag.climbingData) } } } @@ -161,18 +164,19 @@ class ClimbingDataManager: ObservableObject { userDefaults.set(lastModified, forKey: newDataStateKey) userDefaults.removeObject(forKey: legacyDataStateKey) migrationCount += 1 - print("✅ Migrated data state timestamp") + AppLogger.info("Migrated data state timestamp", tag: LogTag.climbingData) } // Mark migration as completed userDefaults.set(true, forKey: migrationKey) if migrationCount > 0 { - print( - "Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently" + AppLogger.info( + "Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently", + tag: LogTag.climbingData ) } else { - print("No OpenClimb data found to migrate") + AppLogger.info("No OpenClimb data found to migrate", tag: LogTag.climbingData) } } @@ -413,9 +417,16 @@ class ClimbingDataManager: ObservableObject { } func startSession(gymId: UUID, notes: String? = nil) { - // End any currently active session + Task { @MainActor in + await startSessionAsync(gymId: gymId, notes: notes) + } + } + + @discardableResult + func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? { + // End any currently active session before starting a new one if let currentActive = activeSession { - endSession(currentActive.id) + await endSessionAsync(currentActive.id) } let newSession = ClimbSession(gymId: gymId, notes: notes) @@ -428,60 +439,70 @@ class ClimbingDataManager: ObservableObject { // MARK: - Start Live Activity for new session if let gym = gym(withId: gymId) { - Task { - await LiveActivityManager.shared.startLiveActivity( - for: newSession, gymName: gym.name) - } + await LiveActivityManager.shared.startLiveActivity( + for: newSession, + gymName: gym.name) } if healthKitService.isEnabled { - Task { - do { - try await healthKitService.startWorkout( - startDate: newSession.startTime ?? Date(), - sessionId: newSession.id) - } catch { - print("Failed to start HealthKit workout: \(error.localizedDescription)") - } + do { + try await healthKitService.startWorkout( + startDate: newSession.startTime ?? Date(), + sessionId: newSession.id) + } catch { + AppLogger.error( + "Failed to start HealthKit workout: \(error.localizedDescription)", + tag: LogTag.climbingData) } } + + return newSession } func endSession(_ sessionId: UUID) { - if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), + Task { @MainActor in + await endSessionAsync(sessionId) + } + } + + @discardableResult + func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? { + guard + let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), let index = sessions.firstIndex(where: { $0.id == sessionId }) - { + else { + return nil + } - let completedSession = session.completed() - sessions[index] = completedSession + let completedSession = session.completed() + sessions[index] = completedSession - if activeSession?.id == sessionId { - activeSession = nil - } + if activeSession?.id == sessionId { + activeSession = nil + } - saveActiveSession() - saveSessions() - DataStateManager.shared.updateDataState() + saveActiveSession() + saveSessions() + DataStateManager.shared.updateDataState() - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) - // MARK: - End Live Activity after session ends - Task { - await LiveActivityManager.shared.endLiveActivity() - } + // MARK: - End Live Activity after session ends + await LiveActivityManager.shared.endLiveActivity() - if healthKitService.isEnabled { - Task { - do { - try await healthKitService.endWorkout( - endDate: completedSession.endTime ?? Date()) - } catch { - print("Failed to end HealthKit workout: \(error.localizedDescription)") - } - } + if healthKitService.isEnabled { + do { + try await healthKitService.endWorkout( + endDate: completedSession.endTime ?? Date()) + } catch { + AppLogger.error( + "Failed to end HealthKit workout: \(error.localizedDescription)", + tag: LogTag.climbingData) } } + + return completedSession } func updateSession(_ session: ClimbSession) { @@ -667,7 +688,9 @@ class ClimbingDataManager: ObservableObject { } if !orphanedAttempts.isEmpty { - print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts") + AppLogger.info( + "🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts", + tag: LogTag.climbingData) // Track these as deleted to prevent sync from re-introducing them for attempt in orphanedAttempts { @@ -693,14 +716,15 @@ class ClimbingDataManager: ObservableObject { if seenAttempts.contains(key) { duplicateIds.append(attempt.id) - print("🧹 Found duplicate attempt: \(attempt.id)") + AppLogger.info("🧹 Found duplicate attempt: \(attempt.id)", tag: LogTag.climbingData) } else { seenAttempts.insert(key) } } if !duplicateIds.isEmpty { - print("🧹 Removing \(duplicateIds.count) duplicate attempts") + AppLogger.info( + "🧹 Removing \(duplicateIds.count) duplicate attempts", tag: LogTag.climbingData) // Track duplicates as deleted for attemptId in duplicateIds { @@ -714,8 +738,9 @@ class ClimbingDataManager: ObservableObject { if initialAttemptCount != attempts.count { saveAttempts() let removedCount = initialAttemptCount - attempts.count - print( - "Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)" + AppLogger.info( + "Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)", + tag: LogTag.climbingData ) } @@ -725,7 +750,9 @@ class ClimbingDataManager: ObservableObject { } if !orphanedProblems.isEmpty { - print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems") + AppLogger.info( + "🧹 Cleaning up \(orphanedProblems.count) orphaned problems", + tag: LogTag.climbingData) for problem in orphanedProblems { trackDeletion(itemId: problem.id.uuidString, itemType: "problem") @@ -744,7 +771,9 @@ class ClimbingDataManager: ObservableObject { } if !orphanedSessions.isEmpty { - print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions") + AppLogger.info( + "🧹 Cleaning up \(orphanedSessions.count) orphaned sessions", + tag: LogTag.climbingData) for session in orphanedSessions { trackDeletion(itemId: session.id.uuidString, itemType: "session") @@ -844,19 +873,29 @@ class ClimbingDataManager: ObservableObject { let problemsForImages = problems // Move heavy I/O operations to background thread + let logTag = LogTag.climbingData let zipData = try await Task.detached(priority: .userInitiated) { // Collect actual image paths from disk for the ZIP - let referencedImagePaths = await Self.collectReferencedImagePathsStatic( + let imageSummary = Self.collectReferencedImagePathsStatic( problems: problemsForImages, imagesDirectory: imagesDirectory) - print("Starting export with \(referencedImagePaths.count) images") + let referencedImagePaths = imageSummary.paths + + await MainActor.run { + AppLogger.info( + "Starting export with \(referencedImagePaths.count) images (\(imageSummary.missingCount) missing)", + tag: logTag) + } let zipData = try await ZipUtils.createExportZip( exportData: exportData, referencedImagePaths: referencedImagePaths ) - print("Export completed successfully") + await MainActor.run { + AppLogger.info("Export completed successfully", tag: logTag) + } + return (zipData, referencedImagePaths.count) }.value @@ -865,7 +904,7 @@ class ClimbingDataManager: ObservableObject { return zipData.0 } catch { let errorMessage = "Export failed: \(error.localizedDescription)" - print("ERROR: \(errorMessage)") + AppLogger.error("ERROR: \(errorMessage)", tag: LogTag.climbingData) setError(errorMessage) return nil } @@ -894,16 +933,24 @@ class ClimbingDataManager: ObservableObject { return Date() } - print("Raw JSON content preview:") - print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") + AppLogger.debug("Raw JSON content preview:", tag: LogTag.climbingData) + AppLogger.debug( + String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...", + tag: LogTag.climbingData + ) let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) - print("Successfully decoded import data:") - print("- Gyms: \(importData.gyms.count)") - print("- Problems: \(importData.problems.count)") - print("- Sessions: \(importData.sessions.count)") - print("- Attempts: \(importData.attempts.count)") + AppLogger.info( + """ + Successfully decoded import data: + - Gyms: \(importData.gyms.count) + - Problems: \(importData.problems.count) + - Sessions: \(importData.sessions.count) + - Attempts: \(importData.attempts.count) + """, + tag: LogTag.climbingData + ) try validateImportData(importData) @@ -960,14 +1007,20 @@ class ClimbingDataManager: ObservableObject { extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { let imagesDirectory = ImageManager.shared.imagesDirectory.path - return Self.collectReferencedImagePathsStatic( + let result = Self.collectReferencedImagePathsStatic( problems: problems, imagesDirectory: imagesDirectory) + + AppLogger.info( + "Export: Collected \(result.paths.count) images (\(result.missingCount) missing)", + tag: LogTag.climbingData) + + return result.paths } - private static func collectReferencedImagePathsStatic( + nonisolated private static func collectReferencedImagePathsStatic( problems: [Problem], imagesDirectory: String - ) -> Set { + ) -> (paths: Set, missingCount: Int) { var imagePaths = Set() var missingCount = 0 @@ -988,8 +1041,7 @@ extension ClimbingDataManager { } } - print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)") - return imagePaths + return (imagePaths, missingCount) } private func updateProblemImagePaths( @@ -1030,11 +1082,14 @@ extension ClimbingDataManager { } deterministicImagePaths.append(deterministicName) - print("Renamed imported image: \(tempFileName) → \(deterministicName)") + AppLogger.debug( + "Renamed imported image: \(tempFileName) → \(deterministicName)", + tag: LogTag.climbingData) } } catch { - print( - "Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)" + AppLogger.error( + "Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)", + tag: LogTag.climbingData ) deterministicImagePaths.append(tempFileName) } @@ -1078,7 +1133,8 @@ extension ClimbingDataManager { if needsUpdate { problems = updatedProblems saveProblems() - print("Migrated image paths for \(problems.count) problems") + AppLogger.info( + "Migrated image paths for \(problems.count) problems", tag: LogTag.climbingData) } } @@ -1089,8 +1145,9 @@ extension ClimbingDataManager { // Log storage information for debugging let info = await ImageManager.shared.getStorageInfo() - print( - "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" + await AppLogger.debug( + "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total", + tag: LogTag.climbingData ) }.value } @@ -1128,7 +1185,9 @@ extension ClimbingDataManager { } if !orphanedFiles.isEmpty { - print("Cleaned up \(orphanedFiles.count) orphaned image files") + AppLogger.info( + "Cleaned up \(orphanedFiles.count) orphaned image files", + tag: LogTag.climbingData) } } } @@ -1145,7 +1204,7 @@ extension ClimbingDataManager { } func forceImageRecovery() { - print("User initiated force image recovery") + AppLogger.info("User initiated force image recovery", tag: LogTag.climbingData) ImageManager.shared.forceRecoveryMigration() // Refresh the UI after recovery @@ -1153,7 +1212,7 @@ extension ClimbingDataManager { } func emergencyImageRestore() { - print("User initiated emergency image restore") + AppLogger.info("User initiated emergency image restore", tag: LogTag.climbingData) ImageManager.shared.emergencyImageRestore() // Refresh the UI after restore @@ -1179,15 +1238,15 @@ extension ClimbingDataManager { } func testLiveActivity() { - print("🧪 Testing Live Activity functionality...") + AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData) // Check Live Activity availability let status = LiveActivityManager.shared.checkLiveActivityAvailability() - print(status) + AppLogger.info(status, tag: LogTag.climbingData) // Test with dummy data if we have a gym guard let testGym = gyms.first else { - print("ERROR: No gyms available for testing") + AppLogger.error("No gyms available for testing", tag: LogTag.climbingData) return } @@ -1218,15 +1277,18 @@ extension ClimbingDataManager { // Only restart if session is actually active guard activeSession.status == .active else { - print( - "WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity" + AppLogger.warning( + "Session exists but is not active (status: \(activeSession.status)), ending Live Activity", + tag: LogTag.climbingData ) await LiveActivityManager.shared.endLiveActivity() return } if let gym = gym(withId: activeSession.gymId) { - print("Checking Live Activity for active session at \(gym.name)") + AppLogger.info( + "Checking Live Activity for active session at \(gym.name)", tag: LogTag.climbingData + ) // First cleanup any dismissed activities await LiveActivityManager.shared.cleanupDismissedActivities() @@ -1241,7 +1303,9 @@ extension ClimbingDataManager { /// Call this when app becomes active to check for Live Activity restart func onAppBecomeActive() { - print("App became active - checking Live Activity status") + let logTag = "ClimbingData" + AppLogger.info( + "App became active - checking Live Activity status", tag: logTag) Task { await checkAndRestartLiveActivity() } @@ -1249,35 +1313,46 @@ extension ClimbingDataManager { /// Call this when app enters background to update Live Activity func onAppEnterBackground() { - print("App entering background - updating Live Activity if needed") + let logTag = "ClimbingData" + AppLogger.info( + "App entering background - updating Live Activity if needed", tag: logTag) Task { await updateLiveActivityData() } } /// Setup notifications for Live Activity events - private func setupLiveActivityNotifications() { + nonisolated private func setupLiveActivityNotifications() { + let notificationName = Notification.Name("liveActivityDismissed") + let logTag = "ClimbingData" + liveActivityObserver = NotificationCenter.default.addObserver( - forName: .liveActivityDismissed, + forName: notificationName, object: nil, queue: .main ) { [weak self] _ in - print("🔔 Received Live Activity dismissed notification - attempting restart") Task { @MainActor in + AppLogger.info( + "Received Live Activity dismissed notification - attempting restart", + tag: logTag) await self?.handleLiveActivityDismissed() } } } - private func setupMigrationNotifications() { + nonisolated private func setupMigrationNotifications() { + let logTag = "ClimbingData" + migrationObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name("ImageMigrationCompleted"), object: nil, queue: .main ) { [weak self] notification in if let updateCount = notification.userInfo?["updateCount"] as? Int { - print("🔔 Image migration completed with \(updateCount) updates - reloading data") Task { @MainActor in + AppLogger.info( + "Image migration completed with \(updateCount) updates - reloading data", + tag: logTag) self?.loadProblems() } } @@ -1293,7 +1368,9 @@ extension ClimbingDataManager { return } - print("Attempting to restart dismissed Live Activity for \(gym.name)") + AppLogger.info( + "Attempting to restart dismissed Live Activity for \(gym.name)", + tag: LogTag.climbingData) // Wait a bit before restarting to avoid frequency limits try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds @@ -1333,11 +1410,20 @@ extension ClimbingDataManager { activeSession.status == .active, let gym = gym(withId: activeSession.gymId) else { - print("WARNING: Live Activity update skipped - no active session or gym") + AppLogger.warning( + "Live Activity update skipped - no active session or gym", + tag: LogTag.climbingData + ) if let session = activeSession { - print(" Session ID: \(session.id)") - print(" Session Status: \(session.status)") - print(" Gym ID: \(session.gymId)") + AppLogger.debug( + """ + Skipped session details: + Session ID: \(session.id) + Session Status: \(session.status) + Gym ID: \(session.gymId) + """, + tag: LogTag.climbingData + ) } return } @@ -1357,14 +1443,17 @@ extension ClimbingDataManager { elapsedInterval = 0 } - print("Live Activity Update Debug:") - print(" Session ID: \(activeSession.id)") - print(" Gym: \(gym.name)") - print(" Total attempts in session: \(totalAttempts)") - print(" Completed problems: \(completedProblems)") - print(" Elapsed time: \(elapsedInterval) seconds") - print( - " All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })" + AppLogger.debug( + """ + Live Activity Update Debug: + Session ID: \(activeSession.id) + Gym: \(gym.name) + Total attempts in session: \(totalAttempts) + Completed problems: \(completedProblems) + Elapsed time: \(elapsedInterval) seconds + All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" }) + """, + tag: LogTag.climbingData ) Task { diff --git a/ios/Ascently/ViewModels/LiveActivityManager.swift b/ios/Ascently/ViewModels/LiveActivityManager.swift index 60ad654..3bce362 100644 --- a/ios/Ascently/ViewModels/LiveActivityManager.swift +++ b/ios/Ascently/ViewModels/LiveActivityManager.swift @@ -8,6 +8,7 @@ extension Notification.Name { @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() + private static let logTag = "LiveActivity" private init() {} nonisolated(unsafe) private var currentActivity: Activity? @@ -30,11 +31,12 @@ final class LiveActivityManager { let isStillActive = activities.contains { $0.id == currentActivity.id } if isStillActive { - print("Live Activity still running: \(currentActivity.id)") + AppLogger.debug("Live Activity still running: \(currentActivity.id)", tag: Self.logTag) return } else { - print( - "WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" + AppLogger.warning( + "Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference", + tag: Self.logTag ) self.currentActivity = nil } @@ -43,18 +45,18 @@ final class LiveActivityManager { // Check if there are ANY active Live Activities for this session let existingActivities = Activity.activities if let existingActivity = existingActivities.first { - print("Found existing Live Activity: \(existingActivity.id), using it") + AppLogger.info("Found existing Live Activity: \(existingActivity.id), using it", tag: Self.logTag) self.currentActivity = existingActivity return } - print("No Live Activity found, restarting for existing session") + AppLogger.info("No Live Activity found, restarting for existing session", tag: Self.logTag) await startLiveActivity(for: activeSession, gymName: gymName) } /// Call this when a ClimbSession starts to begin a Live Activity func startLiveActivity(for session: ClimbSession, gymName: String) async { - print("Starting Live Activity for gym: \(gymName)") + AppLogger.info("Starting Live Activity for gym: \(gymName)", tag: Self.logTag) await endLiveActivity() @@ -80,18 +82,26 @@ final class LiveActivityManager { pushType: nil ) self.currentActivity = activity - print("Live Activity started successfully: \(activity.id)") + AppLogger.info("Live Activity started successfully: \(activity.id)", tag: Self.logTag) } catch { - print("ERROR: Failed to start live activity: \(error)") - print("Error details: \(error.localizedDescription)") + AppLogger.error( + """ + Failed to start live activity: \(error) + Details: \(error.localizedDescription) + """, + tag: Self.logTag + ) // Check specific error types if error.localizedDescription.contains("authorization") { - print("Authorization error - check Live Activity permissions in Settings") + AppLogger.warning( + "Authorization error - check Live Activity permissions in Settings", + tag: Self.logTag + ) } else if error.localizedDescription.contains("content") { - print("Content error - check ActivityAttributes structure") + AppLogger.warning("Content error - check ActivityAttributes structure", tag: Self.logTag) } else if error.localizedDescription.contains("frequencyLimited") { - print("Frequency limited - too many Live Activities started recently") + AppLogger.warning("Frequency limited - too many Live Activities started recently", tag: Self.logTag) } } } @@ -100,7 +110,7 @@ final class LiveActivityManager { func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async { guard let currentActivity = currentActivity else { - print("WARNING: No current activity to update") + AppLogger.warning("No current activity to update", tag: Self.logTag) return } @@ -109,15 +119,17 @@ final class LiveActivityManager { let isStillActive = activities.contains { $0.id == currentActivity.id } if !isStillActive { - print( - "WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference" + AppLogger.warning( + "Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference", + tag: Self.logTag ) self.currentActivity = nil return } - print( - "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" + AppLogger.debug( + "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)", + tag: Self.logTag ) let updatedContentState = SessionActivityAttributes.ContentState( @@ -137,26 +149,26 @@ final class LiveActivityManager { // First end the tracked activity if it exists if let currentActivity { - print("Ending tracked Live Activity: \(currentActivity.id)") + AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag) nonisolated(unsafe) let activity = currentActivity await activity.end(nil, dismissalPolicy: .immediate) self.currentActivity = nil - print("Tracked Live Activity ended successfully") + AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag) } // Force end ALL active activities of our type to ensure cleanup - print("Checking for any remaining active activities...") + AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag) let activities = Activity.activities if activities.isEmpty { - print("No additional activities found") + AppLogger.debug("No additional activities found", tag: Self.logTag) } else { - print("Found \(activities.count) additional active activities, ending them...") + AppLogger.info("Found \(activities.count) additional active activities, ending them...", tag: Self.logTag) for activity in activities { - print("Force ending activity: \(activity.id)") + AppLogger.debug("Force ending activity: \(activity.id)", tag: Self.logTag) await activity.end(nil, dismissalPolicy: .immediate) } - print("All Live Activities ended successfully") + AppLogger.info("All Live Activities ended successfully", tag: Self.logTag) } } @@ -174,7 +186,7 @@ final class LiveActivityManager { • All Active Activities: \(allActivities.count) """ - print(message) + AppLogger.info(message, tag: Self.logTag) return message } @@ -185,7 +197,7 @@ final class LiveActivityManager { if let currentActivity = currentActivity { let isStillActive = activities.contains { $0.id == currentActivity.id } if !isStillActive { - print("Cleaning up dismissed Live Activity: \(currentActivity.id)") + AppLogger.info("Cleaning up dismissed Live Activity: \(currentActivity.id)", tag: Self.logTag) self.currentActivity = nil } } @@ -195,7 +207,7 @@ final class LiveActivityManager { func startHealthChecks() { stopHealthChecks() // Stop any existing timer - print("🩺 Starting Live Activity health checks") + AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag) healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in @@ -208,7 +220,7 @@ final class LiveActivityManager { func stopHealthChecks() { healthCheckTimer?.invalidate() healthCheckTimer = nil - print("Stopped Live Activity health checks") + AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag) } /// Perform a health check on the current Live Activity @@ -221,14 +233,14 @@ final class LiveActivityManager { // Only perform health check if it's been at least 25 seconds guard timeSinceLastCheck >= 25 else { return } - print("🩺 Performing Live Activity health check") + AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag) lastHealthCheck = now let activities = Activity.activities let isStillActive = activities.contains { $0.id == currentActivity.id } if !isStillActive { - print("Health check failed - Live Activity was dismissed") + AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag) self.currentActivity = nil // Notify that we need to restart @@ -237,7 +249,7 @@ final class LiveActivityManager { object: nil ) } else { - print("Live Activity health check passed") + AppLogger.debug("Live Activity health check passed", tag: Self.logTag) } } diff --git a/ios/Ascently/Views/LiveActivityDebugView.swift b/ios/Ascently/Views/LiveActivityDebugView.swift index cd7c8fd..86e79e1 100644 --- a/ios/Ascently/Views/LiveActivityDebugView.swift +++ b/ios/Ascently/Views/LiveActivityDebugView.swift @@ -196,7 +196,7 @@ struct LiveActivityDebugView: View { } isTestRunning = true - appendDebugOutput("🧪 Starting Live Activity test...") + appendDebugOutput("Starting Live Activity test...") Task { defer { diff --git a/ios/Ascently/Views/ProblemsView.swift b/ios/Ascently/Views/ProblemsView.swift index 5d59bde..a36e3c7 100644 --- a/ios/Ascently/Views/ProblemsView.swift +++ b/ios/Ascently/Views/ProblemsView.swift @@ -317,7 +317,6 @@ struct ProblemsList: View { } Button { - // Use a spring animation for more natural movement withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1)) { let updatedProblem = problem.updated(isActive: !problem.isActive) diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index e7ee043..6ec5e76 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -84,6 +84,8 @@ struct DataManagementSection: View { @State private var isDeletingImages = false @State private var showingDeleteImagesAlert = false + private static let logTag = "DataManagementSection" + var body: some View { Section("Data Management") { // Export Data @@ -217,13 +219,14 @@ struct DataManagementSection: View { try fileManager.removeItem(at: imageFile) deletedCount += 1 } catch { - print("Failed to delete image: \(imageFile.lastPathComponent)") + AppLogger.error( + "Failed to delete image: \(imageFile.lastPathComponent)", tag: Self.logTag) } } - print("Deleted \(deletedCount) image files") + AppLogger.info("Deleted \(deletedCount) image files", tag: Self.logTag) } catch { - print("Failed to access images directory: \(error)") + AppLogger.error("Failed to access images directory: \(error)", tag: Self.logTag) } // Delete all images from backup directory @@ -235,7 +238,7 @@ struct DataManagementSection: View { try? fileManager.removeItem(at: backupFile) } } catch { - print("Failed to access backup directory: \(error)") + AppLogger.error("Failed to access backup directory: \(error)", tag: Self.logTag) } // Clear image paths from all problems @@ -260,20 +263,6 @@ struct AppInfoSection: View { var body: some View { Section("App Information") { - HStack { - Image("AppLogo") - .resizable() - .frame(width: 24, height: 24) - VStack(alignment: .leading) { - Text("Ascently") - .font(.headline) - Text("Track your climbing progress") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - HStack { Image(systemName: "info.circle") .foregroundColor(.blue) @@ -292,11 +281,13 @@ struct ExportDataView: View { @State private var tempFileURL: URL? @State private var isCreatingFile = true + private static let logTag = "ExportDataView" + var body: some View { NavigationStack { VStack(spacing: 30) { if isCreatingFile { - // Loading state - more prominent + // Loading state VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) @@ -380,6 +371,7 @@ struct ExportDataView: View { } private func createTempFile() { + let logTag = Self.logTag // Capture before entering background queue DispatchQueue.global(qos: .userInitiated).async { do { let formatter = ISO8601DateFormatter() @@ -394,7 +386,9 @@ struct ExportDataView: View { for: .documentDirectory, in: .userDomainMask ).first else { - print("Could not access Documents directory") + Task { @MainActor in + AppLogger.error("Could not access Documents directory", tag: logTag) + } DispatchQueue.main.async { self.isCreatingFile = false } @@ -410,7 +404,9 @@ struct ExportDataView: View { self.isCreatingFile = false } } catch { - print("Failed to create export file: \(error)") + Task { @MainActor in + AppLogger.error("Failed to create export file: \(error)", tag: logTag) + } DispatchQueue.main.async { self.isCreatingFile = false } @@ -420,10 +416,12 @@ struct ExportDataView: View { private func cleanupTempFile() { if let fileURL = tempFileURL { + let logTag = Self.logTag // Capture before entering async closure // Clean up after a delay to ensure sharing is complete DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { try? FileManager.default.removeItem(at: fileURL) - print("Cleaned up export file: \(fileURL.lastPathComponent)") + AppLogger.debug( + "Cleaned up export file: \(fileURL.lastPathComponent)", tag: logTag) } } } @@ -435,6 +433,8 @@ struct SyncSection: View { @State private var showingSyncSettings = false @State private var showingDisconnectAlert = false + private static let logTag = "SyncSection" + var body: some View { Section("Sync") { // Sync Status @@ -579,11 +579,14 @@ struct SyncSection: View { } private func performSync() { + let logTag = Self.logTag // Capture before entering async context Task { do { try await syncService.syncWithServer(dataManager: dataManager) } catch { - print("Sync failed: \(error)") + await MainActor.run { + AppLogger.error("Sync failed: \(error)", tag: logTag) + } } } } diff --git a/ios/SessionStatusLive/SessionStatusLiveBundle.swift b/ios/SessionStatusLive/SessionStatusLiveBundle.swift index 34e1bef..b0b7e8a 100644 --- a/ios/SessionStatusLive/SessionStatusLiveBundle.swift +++ b/ios/SessionStatusLive/SessionStatusLiveBundle.swift @@ -8,7 +8,6 @@ import WidgetKit struct SessionStatusLiveBundle: WidgetBundle { var body: some Widget { SessionStatusLive() - SessionStatusLiveControl() SessionStatusLiveLiveActivity() } } diff --git a/ios/SessionStatusLive/SessionStatusLiveControl.swift b/ios/SessionStatusLive/SessionStatusLiveControl.swift deleted file mode 100644 index 3471c8f..0000000 --- a/ios/SessionStatusLive/SessionStatusLiveControl.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// SessionStatusLiveControl.swift - -import AppIntents -import SwiftUI -import WidgetKit - -struct SessionStatusLiveControl: ControlWidget { - static let kind: String = "com.atridad.Ascently.SessionStatusLive" - - var body: some ControlWidgetConfiguration { - AppIntentControlConfiguration( - kind: Self.kind, - provider: Provider() - ) { value in - ControlWidgetToggle( - "Start Timer", - isOn: value.isRunning, - action: StartTimerIntent(value.name) - ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") - } - } - .displayName("Timer") - .description("A an example control that runs a timer.") - } -} - -extension SessionStatusLiveControl { - struct Value { - var isRunning: Bool - var name: String - } - - struct Provider: AppIntentControlValueProvider { - func previewValue(configuration: TimerConfiguration) -> Value { - SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName) - } - - func currentValue(configuration: TimerConfiguration) async throws -> Value { - let isRunning = true // Check if the timer is running - return SessionStatusLiveControl.Value( - isRunning: isRunning, name: configuration.timerName) - } - } -} - -struct TimerConfiguration: ControlConfigurationIntent { - static let title: LocalizedStringResource = "Timer Name Configuration" - - @Parameter(title: "Timer Name", default: "Timer") - var timerName: String -} - -struct StartTimerIntent: SetValueIntent { - static let title: LocalizedStringResource = "Start a timer" - - @Parameter(title: "Timer Name") - var name: String - - @Parameter(title: "Timer is running") - var value: Bool - - init() {} - - init(_ name: String) { - self.name = name - } - - func perform() async throws -> some IntentResult { - // Start the timer… - return .result() - } -} diff --git a/sync/main.go b/sync/main.go index 40632b7..40d335b 100644 --- a/sync/main.go +++ b/sync/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const VERSION = "2.2.0" +const VERSION = "2.3.0" func min(a, b int) int { if a < b {