diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/FileManager.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/FileManager.kt index 2769de9..bb3d7bf 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/FileManager.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/FileManager.kt @@ -9,6 +9,7 @@ interface FileManager { suspend fun delete(fileName: String) suspend fun exists(fileName: String): Boolean suspend fun list(): List + suspend fun getSize(fileName: String): Long } class LocalFileManager( @@ -65,6 +66,11 @@ class LocalFileManager( emptyList() } } + + override suspend fun getSize(fileName: String): Long { + val filePath = rootPath / fileName + return fileSystem.metadataOrNull(filePath)?.size ?: 0L + } } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/InMemoryFileManager.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/InMemoryFileManager.kt index 35b7dd4..ab3f113 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/InMemoryFileManager.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/InMemoryFileManager.kt @@ -22,4 +22,8 @@ class InMemoryFileManager : FileManager { override suspend fun list(): List { return files.keys.toList() } + + override suspend fun getSize(fileName: String): Long { + return files[fileName]?.size?.toLong() ?: 0L + } } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcher.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcher.kt index 812fa99..3464491 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcher.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcher.kt @@ -53,6 +53,10 @@ class LocalFileFetcher( */ class Factory(private val fileManager: FileManager) : Fetcher.Factory { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { + return createForUri(data) + } + + internal fun createForUri(data: Uri): Fetcher? { if (data.scheme == "localfile") { // data.path might start with /, e.g. /image.jpg val fileName = data.path?.trimStart('/') ?: run { diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt index 0d5d45e..0c1f419 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt @@ -104,11 +104,35 @@ fun EntriesScreen( ) } + val performSync = { + if (!isSyncing) { + isSyncing = true + scope.launch { + try { + val result = io.github.smiling_pixel.sync.performCloudSync( + client = getCloudDriveClient(), + repo = repo, + localEntries = entriesState + ) + syncSummary = "Sync completed!\nUploaded: ${result.uploaded}\nDownloaded: ${result.downloaded}\nUnchanged: ${result.unchanged}" + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + syncError = e.message ?: "An unknown error occurred during sync" + } finally { + isSyncing = false + } + } + } + } + if (isCreating || selectedEntry != null) { // Details view (New or Edit) EntryDetailsScreen( entry = selectedEntry, weatherClient = weatherClient, + isSyncing = isSyncing, + onSyncRequest = { performSync() }, onSave = { entry -> scope.launch { if (isCreating) { @@ -136,27 +160,7 @@ fun EntriesScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { FloatingActionButton( - onClick = { - if (isSyncing) return@FloatingActionButton - isSyncing = true - scope.launch { - try { - val result = io.github.smiling_pixel.sync.performCloudSync( - client = getCloudDriveClient(), - repo = repo, - localEntries = entriesState - ) - syncSummary = "Sync completed!\nUploaded: ${result.uploaded}\nDownloaded: ${result.downloaded}\nUnchanged: ${result.unchanged}" - } catch (e: CancellationException) { - // Preserve coroutine cancellation instead of showing it as a sync error. - throw e - } catch (e: Exception) { - syncError = e.message ?: "An unknown error occurred during sync" - } finally { - isSyncing = false - } - } - }, + onClick = { performSync() }, shape = RoundedCornerShape(16.dp) ) { if (isSyncing) { diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt index e48b1a3..096b00b 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt @@ -7,6 +7,7 @@ import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl import kotlin.time.Clock import kotlin.time.Instant +import kotlin.math.pow import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.LocalDateTime @@ -28,6 +29,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.DateRange import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.size import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,6 +57,7 @@ import kotlin.time.ExperimentalTime import io.github.smiling_pixel.util.Logger import io.github.smiling_pixel.util.e import io.github.smiling_pixel.util.w +import io.github.smiling_pixel.filesystem.fileManager /** * Screen displaying the details of a diary entry. @@ -69,6 +74,8 @@ import io.github.smiling_pixel.util.w fun EntryDetailsScreen( entry: DiaryEntry?, weatherClient: WeatherClient, + isSyncing: Boolean = false, + onSyncRequest: () -> Unit = {}, onSave: (DiaryEntry) -> Unit, onCancel: () -> Unit ) { @@ -171,6 +178,18 @@ fun EntryDetailsScreen( fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onSyncRequest, enabled = !isSyncing) { + Icon( + Icons.Default.Refresh, + contentDescription = if (isSyncing) "Syncing" else "Sync Cloud" + ) + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } TextButton(onClick = { isEditing = true }) { Text("Edit") } @@ -289,6 +308,32 @@ fun EntryDetailsScreen( modifier = Modifier.fillMaxSize() ) } else { + var fileCount by remember(entry) { mutableStateOf(0) } + var totalFileSize by remember(entry) { mutableStateOf(0L) } + + LaunchedEffect(entry!!.content) { + var count = 0 + var size = 0L + val regex = Regex("localfile:/*([^)\\s]+)") + val matches = regex.findAll(entry.content) + for (match in matches) { + val filePath = match.groupValues[1] + // Reject potentially unsafe paths to prevent directory traversal. + if (filePath.isEmpty() || filePath.contains("..")) { + Logger.w("EntryDetailsScreen", "Rejected potentially unsafe or empty path: $filePath") + continue + } + count++ + try { + size += fileManager.getSize(filePath) + } catch (e: Exception) { + Logger.w("EntryDetailsScreen", "Failed to get size for $filePath: $e") + } + } + fileCount = count + totalFileSize = size + } + // show timestamps val createdLocal = entry!!.createdAt.toLocalDateTime(TimeZone.currentSystemDefault()) val updatedLocal = entry.updatedAt.toLocalDateTime(TimeZone.currentSystemDefault()) @@ -317,6 +362,15 @@ fun EntryDetailsScreen( color = MaterialTheme.colorScheme.outlineVariant ) + Spacer(modifier = Modifier.height(6.dp)) + + val statsText = "${entry.content.length} chars | $fileCount files, ${formatBytes(totalFileSize)}" + Text( + text = statsText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.height(12.dp)) if (entry.weatherCondition != null) { @@ -338,3 +392,22 @@ fun EntryDetailsScreen( } } } + +/** + * A utility to format byte sizes into human-readable strings (e.g., KB, MB). + * We need this custom utility because Kotlin Multiplatform does not provide + * Java's java.text.DecimalFormat out of the box, and we want a consistent + * way to calculate and display file sizes across Android, JVM, and Wasm/JS. + * It progressively divides by 1024 to find the correct magnitude and manually + * rounds to one decimal place using simple math. + */ +fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val prefixes = "KMGTPE" + val exp = (kotlin.math.ln(bytes.toDouble()) / kotlin.math.ln(1024.0)).toInt() + .coerceIn(1, prefixes.length) + val pre = prefixes[exp - 1] + val value = bytes / 1024.0.pow(exp.toDouble()) + val rounded = kotlin.math.round(value * 10.0) / 10.0 + return "$rounded ${pre}B" +} diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/sync/SyncManager.kt index 842e54f..1acb02f 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/sync/SyncManager.kt @@ -21,7 +21,8 @@ import kotlin.time.Instant as KotlinTimeInstant data class SyncResult( val uploaded: Int, val downloaded: Int, - val unchanged: Int + val unchanged: Int, + val warnings: List = emptyList() ) /** @@ -80,6 +81,7 @@ suspend fun performCloudSync( var uploaded = 0 var downloaded = 0 var unchanged = 0 + val warnings = mutableListOf() val localEntriesBySyncId = localEntries.associateBy { it.syncId }.toMutableMap() val localTombstones = loadLocalDeletionTombstones(settings).toMutableMap() @@ -134,6 +136,9 @@ suspend fun performCloudSync( if (parsed != null) { repo.update(parsed) downloaded++ + } else { + val legacyName = quarantineLegacyFile(client, driveFile, remoteContent, parentId) + warnings += "Quarantined unrecognized remote file \"${driveFile.name}\" as \"$legacyName\"" } } else { unchanged++ @@ -189,13 +194,16 @@ suspend fun performCloudSync( if (parsed != null) { repo.insert(parsed) downloaded++ + } else { + val legacyName = quarantineLegacyFile(client, driveFile, remoteContent, parentId) + warnings += "Quarantined unrecognized remote file \"${driveFile.name}\" as \"$legacyName\"" } } saveLocalDeletionTombstones(settings, localTombstones) // TODO: Optimise the synchronization process in the future (e.g., batch operations, or more efficient incremental sync). - return SyncResult(uploaded, downloaded, unchanged) + return SyncResult(uploaded, downloaded, unchanged, warnings.toList()) } @OptIn(ExperimentalTime::class) @@ -278,6 +286,7 @@ private fun looksLikeUuid(value: String): Boolean { private const val SYNC_ENTRY_FILE_EXTENSION = ".txt" private const val SYNC_ENTRY_FILE_PREFIX = "markday_entry_" private const val SYNC_TOMBSTONE_FILE_PREFIX = "markday_tombstone_" +private const val SYNC_LEGACY_FILE_PREFIX = "markday_legacy_" private const val SYNC_ENTRY_MIME_TYPE = "text/plain" private val syncPayloadJson = Json { @@ -347,45 +356,7 @@ internal fun decodeEntryForSync(bytes: ByteArray, original: DiaryEntry): DiaryEn content = content ) } catch (e: Exception) { - return decodeLegacyLineDelimitedEntryForSync(bytes, original) - } -} - -@OptIn(ExperimentalTime::class) -private fun decodeLegacyLineDelimitedEntryForSync(bytes: ByteArray, original: DiaryEntry): DiaryEntry? { - return try { - val text = bytes.decodeToString() - val lines = text.lines() - if (lines.size < 9) return null - - val syncId = lines[0] - if (!looksLikeUuid(syncId)) return null - - val title = lines[1] - val createdAt = KotlinTimeInstant.fromEpochMilliseconds(lines[2].toLong()) - val updatedAt = KotlinTimeInstant.fromEpochMilliseconds(lines[3].toLong()) - val entryDate = LocalDate.parse(lines[4]) - val weatherCondition = lines[5].takeIf { it.isNotEmpty() } - val minTemperature = lines[6].toDoubleOrNull() - val maxTemperature = lines[7].toDoubleOrNull() - var contentLines = lines.drop(8) - if (contentLines.isNotEmpty() && contentLines.last() == "") { - contentLines = contentLines.dropLast(1) - } - val content = contentLines.joinToString("\n") - original.copy( - syncId = syncId, - title = title, - createdAt = createdAt, - updatedAt = updatedAt, - entryDate = entryDate, - weatherCondition = weatherCondition, - minTemperature = minTemperature, - maxTemperature = maxTemperature, - content = content - ) - } catch (e: Exception) { - null + return null } } @@ -421,4 +392,24 @@ private suspend fun saveLocalDeletionTombstones(settings: SettingsRepository, to private fun encodeTombstoneForSync(syncId: String, deletedAtEpochMillis: Long): ByteArray { val payload = SyncDeletionTombstonePayload(syncId = syncId, deletedAtEpochMillis = deletedAtEpochMillis) return syncPayloadJson.encodeToString(SyncDeletionTombstonePayload.serializer(), payload).encodeToByteArray() +} + +/** + * Renames a remote file that cannot be decoded (e.g., legacy format) to a quarantine prefix + * so it is not re-downloaded on every subsequent sync. + * + * @return the new quarantine file name (even if the operations fail). + */ +private suspend fun quarantineLegacyFile( + client: CloudDriveClient, + driveFile: io.github.smiling_pixel.client.DriveFile, + content: ByteArray, + parentId: String? +): String { + val legacyName = driveFile.name.replaceFirst(SYNC_ENTRY_FILE_PREFIX, SYNC_LEGACY_FILE_PREFIX) + runCatching { + client.createFile(legacyName, content, SYNC_ENTRY_MIME_TYPE, parentId) + client.deleteFile(driveFile.id) + } + return legacyName } \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcherTest.kt b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcherTest.kt index abe6de5..97ca3da 100644 --- a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcherTest.kt +++ b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/filesystem/LocalFileFetcherTest.kt @@ -49,15 +49,11 @@ class LocalFileFetcherTest { val fileManager = InMemoryFileManager() val factory = LocalFileFetcher.Factory(fileManager) - // This is a dummy implementation of PlatformContext and ImageLoader because they are not used in the Factory.create() - val options = Options(coil3.PlatformContext.INSTANCE) - val imageLoader = ImageLoader(coil3.PlatformContext.INSTANCE) - - val fetcher = factory.create(Uri("localfile:///image.jpg"), options, imageLoader) + val fetcher = factory.createForUri(Uri("localfile:///image.jpg")) assertNotNull(fetcher) // Factory should trim the leading slash - val fetcher2 = factory.create(Uri("localfile:image.jpg"), options, imageLoader) + val fetcher2 = factory.createForUri(Uri("localfile:image.jpg")) assertNotNull(fetcher2) } @@ -65,11 +61,8 @@ class LocalFileFetcherTest { fun testFactoryCreateWithInvalidScheme() { val fileManager = InMemoryFileManager() val factory = LocalFileFetcher.Factory(fileManager) - - val options = Options(coil3.PlatformContext.INSTANCE) - val imageLoader = ImageLoader(coil3.PlatformContext.INSTANCE) - val fetcher = factory.create(Uri("https://example.com/image.jpg"), options, imageLoader) + val fetcher = factory.createForUri(Uri("https://example.com/image.jpg")) assertNull(fetcher) } @@ -77,16 +70,13 @@ class LocalFileFetcherTest { fun testFactoryCreateWithUnsafePath() { val fileManager = InMemoryFileManager() val factory = LocalFileFetcher.Factory(fileManager) - - val options = Options(coil3.PlatformContext.INSTANCE) - val imageLoader = ImageLoader(coil3.PlatformContext.INSTANCE) // Traversal path - val fetcher = factory.create(Uri("localfile://../image.jpg"), options, imageLoader) + val fetcher = factory.createForUri(Uri("localfile://../image.jpg")) assertNull(fetcher) // Empty path - val fetcher2 = factory.create(Uri("localfile:"), options, imageLoader) + val fetcher2 = factory.createForUri(Uri("localfile:")) assertNull(fetcher2) } } \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerCodecTest.kt b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerCodecTest.kt index f63130a..384f335 100644 --- a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerCodecTest.kt +++ b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerCodecTest.kt @@ -36,17 +36,16 @@ class SyncManagerCodecTest { @Test fun decodeReturnsNullWhenSyncIdIsNotUuid() { - val payload = buildString { - appendLine("legacy-id-42") - appendLine("Title") - appendLine("1711843200000") - appendLine("1711843200000") - appendLine("2026-03-31") - appendLine("") - appendLine("") - appendLine("") - appendLine("Body") - }.encodeToByteArray() + val payload = """ + { + "syncId": "legacy-id-42", + "title": "Title", + "createdAtEpochMillis": 1711843200000, + "updatedAtEpochMillis": 1711843200000, + "entryDateIso": "2026-03-31", + "content": "Body" + } + """.trimIndent().encodeToByteArray() val decoded = decodeEntryForSync( bytes = payload,