Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface FileManager {
suspend fun delete(fileName: String)
suspend fun exists(fileName: String): Boolean
suspend fun list(): List<String>
suspend fun getSize(fileName: String): Long
}

class LocalFileManager(
Expand Down Expand Up @@ -65,6 +66,11 @@ class LocalFileManager(
emptyList()
}
}

override suspend fun getSize(fileName: String): Long {
val filePath = rootPath / fileName
return fileSystem.metadataOrNull(filePath)?.size ?: 0L
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ class InMemoryFileManager : FileManager {
override suspend fun list(): List<String> {
return files.keys.toList()
}

override suspend fun getSize(fileName: String): Long {
return files[fileName]?.size?.toLong() ?: 0L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class LocalFileFetcher(
*/
class Factory(private val fileManager: FileManager) : Fetcher.Factory<Uri> {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
) {
Expand Down Expand Up @@ -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
)
}
}
Comment thread
SmilingPixel marked this conversation as resolved.
TextButton(onClick = { isEditing = true }) {
Text("Edit")
}
Expand Down Expand Up @@ -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")
}
}
Comment thread
SmilingPixel marked this conversation as resolved.
fileCount = count
totalFileSize = size
}

// show timestamps
val createdLocal = entry!!.createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
val updatedLocal = entry.updatedAt.toLocalDateTime(TimeZone.currentSystemDefault())
Expand Down Expand Up @@ -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) {
Expand All @@ -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"
}
Comment thread
SmilingPixel marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptyList()
)

/**
Expand Down Expand Up @@ -80,6 +81,7 @@ suspend fun performCloudSync(
var uploaded = 0
var downloaded = 0
var unchanged = 0
val warnings = mutableListOf<String>()

val localEntriesBySyncId = localEntries.associateBy { it.syncId }.toMutableMap()
val localTombstones = loadLocalDeletionTombstones(settings).toMutableMap()
Expand Down Expand Up @@ -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++
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
}
Loading
Loading