From ba4f4a0ba34ab88ddae630786d360aac2c976fa7 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Sat, 7 Mar 2026 12:41:10 +0530 Subject: [PATCH 1/3] Don't toast outdated error logs --- .../java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt index abc4e1a..e86bfe3 100644 --- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt +++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt @@ -36,7 +36,6 @@ class RulesViewModel(private val repository: Repository) : ViewModel() { val recentErrorLog = Logger.logs .dropWhile { it != latestLogBeforeExecution }.drop(1) .firstOrNull { it.contains("[ERROR]") } - ?: Logger.logs.lastOrNull { it.contains("[ERROR]") } if (recentErrorLog != null) { showToast("Error:" + recentErrorLog.substringAfter("[ERROR]")) } From 1ddb69faa6982673a105ea8fe24a559ec0ef1a47 Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Sun, 8 Mar 2026 13:00:00 +0530 Subject: [PATCH 2/3] Add "DELETE_STALE" action --- .../fileflow/data/AppContainer.kt | 15 +- .../adityarajput/fileflow/data/Converters.kt | 2 +- .../fileflow/data/models/Action.kt | 82 +++-- .../fileflow/services/FlowExecutor.kt | 156 ++++++---- .../co/adityarajput/fileflow/utils/String.kt | 7 +- .../fileflow/utils/Superlatives.kt | 1 + .../viewmodels/UpsertRuleViewModel.kt | 78 +++-- .../views/screens/ExecutionsScreen.kt | 5 +- .../fileflow/views/screens/RulesScreen.kt | 4 +- .../views/screens/UpsertRuleScreen.kt | 293 +++++++++++------- app/src/main/res/values/plurals.xml | 16 +- app/src/main/res/values/strings.xml | 14 +- 12 files changed, 435 insertions(+), 238 deletions(-) diff --git a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt index 7e49846..50ab22a 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt @@ -25,9 +25,9 @@ class AppContainer(private val context: Context) { repository.upsert( Rule( Action.MOVE( - "content://com.android.externalstorage.documents/tree/primary%3AAntennaPod", + "/storage/emulated/0/AntennaPod", "AntennaPodBackup-\\d{4}-\\d{2}-\\d{2}.db", - "content://com.android.externalstorage.documents/tree/primary%3ABackups", + "/storage/emulated/0/Backups", "AntennaPod.db", overwriteExisting = true, ), @@ -35,15 +35,22 @@ class AppContainer(private val context: Context) { ), Rule( Action.MOVE( - "content://com.android.externalstorage.documents/tree/primary%3ABackups", + "/storage/emulated/0/Backups", "TubularData-\\d{8}_\\d{6}.zip", - "content://com.android.externalstorage.documents/tree/primary%3ABackups", + "/storage/emulated/0/Backups", "Tubular.zip", keepOriginal = false, overwriteExisting = true, ), executions = 3, ), + Rule( + Action.DELETE_STALE( + "/storage/emulated/0/Download", + "Alarmetrics_v[\\d\\.]+.apk", + ), + enabled = false, + ), ) repository.upsert( Execution( diff --git a/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt b/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt index 7de5702..763a907 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/Converters.kt @@ -12,6 +12,6 @@ class Converters { fun toAction(value: String) = try { Json.decodeFromString(value) } catch (_: Exception) { - Action.MOVE("", "", "", "") + Action.entries[0] } } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt index adec208..8f17b1a 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt @@ -2,43 +2,87 @@ package co.adityarajput.fileflow.data.models import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import co.adityarajput.fileflow.R import co.adityarajput.fileflow.utils.FileSuperlative import co.adityarajput.fileflow.utils.getGetDirectoryFromUri +import co.adityarajput.fileflow.utils.toShortHumanReadableTime import kotlinx.serialization.Serializable +@Suppress("ClassName") @Serializable -sealed class Action(val title: String) { +sealed class Action { + abstract val src: String + abstract val srcFileNamePattern: String + + abstract val verb: Int + abstract val phrase: Int + + @Composable + abstract fun getDescription(): AnnotatedString + + val base: Action + get() = when (this) { + is MOVE -> entries[0] + is DELETE_STALE -> entries[1] + } + + infix fun isSimilarTo(other: Action) = this::class == other::class + @Serializable data class MOVE( - val src: String, - val srcFileNamePattern: String, + override val src: String, + override val srcFileNamePattern: String, val dest: String, val destFileNameTemplate: String, val keepOriginal: Boolean = true, val overwriteExisting: Boolean = false, val superlative: FileSuperlative = FileSuperlative.LATEST, - ) : Action(srcFileNamePattern) + ) : Action() { + override val verb get() = if (keepOriginal) R.string.copy else R.string.move - val verb - get() = when (this) { - is MOVE -> if (keepOriginal) R.string.copy else R.string.move + override val phrase = R.string.move_phrase + + @Composable + override fun getDescription() = buildAnnotatedString { + val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant) + + withStyle(dullStyle) { append("from ") } + append(src.getGetDirectoryFromUri()) + withStyle(dullStyle) { append("\nto ") } + append(dest.getGetDirectoryFromUri()) + withStyle(dullStyle) { append("\nas ") } + append(destFileNameTemplate) } + } + + @Serializable + data class DELETE_STALE( + override val src: String, + override val srcFileNamePattern: String, + val retentionDays: Int = 30, + ) : Action() { + override val verb get() = R.string.delete_stale - val description - @Composable get() = when (this) { - is MOVE -> buildAnnotatedString { - val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant) - - withStyle(dullStyle) { append("from ") } - append(src.getGetDirectoryFromUri()) - withStyle(dullStyle) { append("\nto ") } - append(dest.getGetDirectoryFromUri()) - withStyle(dullStyle) { append("\nas ") } - append(destFileNameTemplate) - } + override val phrase = R.string.delete_stale_phrase + + fun retentionTimeInMillis() = retentionDays * 86_400_000L + + @Composable + override fun getDescription() = buildAnnotatedString { + val dullStyle = SpanStyle(MaterialTheme.colorScheme.onSurfaceVariant) + + withStyle(dullStyle) { append("in ") } + append(src.getGetDirectoryFromUri()) + withStyle(dullStyle) { append("\nif unmodified for ") } + append((retentionTimeInMillis()).toShortHumanReadableTime()) } + } + + companion object { + val entries by lazy { listOf(MOVE("", "", "", ""), DELETE_STALE("", "")) } + } } diff --git a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt index 8e2c4ec..39ea914 100644 --- a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt +++ b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt @@ -5,9 +5,7 @@ import co.adityarajput.fileflow.data.AppContainer import co.adityarajput.fileflow.data.models.Action import co.adityarajput.fileflow.data.models.Execution import co.adityarajput.fileflow.data.models.Rule -import co.adityarajput.fileflow.utils.File -import co.adityarajput.fileflow.utils.Logger -import co.adityarajput.fileflow.utils.copyFile +import co.adityarajput.fileflow.utils.* import kotlinx.coroutines.flow.first class FlowExecutor(private val context: Context) { @@ -17,65 +15,111 @@ class FlowExecutor(private val context: Context) { for (rule in rules ?: repository.rules().first()) { Logger.d("FlowExecutor", "Executing $rule") - if (!rule.enabled || rule.action !is Action.MOVE) continue + if (!rule.enabled) continue val regex = Regex(rule.action.srcFileNamePattern) - val destDir = File.fromPath(context, rule.action.dest) - if (destDir == null) { - Logger.e("FlowExecutor", "${rule.action.dest} is invalid") - continue - } - - val srcFile = File.fromPath(context, rule.action.src)?.listFiles() - ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) } - ?.maxByOrNull(rule.action.superlative.selector) - ?: continue - - val destFileName = regex.replace(srcFile.name!!, rule.action.destFileNameTemplate) - var destFile = destDir.listFiles().firstOrNull { it.isFile && it.name == destFileName } - - if (destFile != null) { - if (!rule.action.overwriteExisting) { - Logger.e("FlowExecutor", "${destFile.name} already exists") - continue + when (rule.action) { + is Action.MOVE -> { + val destDir = File.fromPath(context, rule.action.dest) + + if (destDir == null) { + Logger.e("FlowExecutor", "${rule.action.dest} is invalid") + continue + } + + val srcFiles = File.fromPath(context, rule.action.src)?.listFiles() + ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) } + ?.let { + if (rule.action.superlative != FileSuperlative.NONE) + listOf(it.maxByOrNull(rule.action.superlative.selector) ?: continue) + else + it + } ?: continue + + for (srcFile in srcFiles) { + val destFileName = regex.replace( + srcFile.name!!, + rule.action.destFileNameTemplate, + ) + var destFile = destDir.listFiles().firstOrNull { + it.isFile && it.name == destFileName + } + + if (destFile != null) { + if (!rule.action.overwriteExisting) { + Logger.e("FlowExecutor", "${destFile.name} already exists") + continue + } + + if (srcFile.isIdenticalTo(destFile, context)) { + Logger.i( + "FlowExecutor", + "Source and destination files are identical", + ) + continue + } + + + Logger.i("FlowExecutor", "Deleting existing ${destFile.name}") + destFile.delete() + } + + destFile = destDir.createFile(srcFile.type, destFileName) + + if (destFile == null) { + Logger.e("FlowExecutor", "Failed to create $destFileName") + continue + } + + val result = context.copyFile(srcFile, destFile) + if (!result) { + Logger.e( + "FlowExecutor", + "Failed to copy ${srcFile.name} to ${destFile.name}", + ) + destFile.delete() + continue + } + + repository.registerExecution( + rule, + Execution(srcFile.name!!, rule.action.verb), + ) + + if (!rule.action.keepOriginal) { + Logger.i("FlowExecutor", "Deleting original ${srcFile.name}") + srcFile.delete() + } + } } - if (srcFile.isIdenticalTo(destFile, context)) { - Logger.i( - "FlowExecutor", - "Source and destination files are identical", - ) - continue + is Action.DELETE_STALE -> { + val srcFiles = File.fromPath(context, rule.action.src)?.listFiles() + ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) } + ?.filter { + System.currentTimeMillis() - it.lastModified() >= + // INFO: While debugging, treat days as seconds + if (context.isDebugBuild()) rule.action.retentionDays * 1000L + else rule.action.retentionTimeInMillis() + } + ?: continue + + for (srcFile in srcFiles) { + Logger.i("FlowExecutor", "Deleting ${srcFile.name}") + + val result = srcFile.delete() + if (!result) { + Logger.e("FlowExecutor", "Failed to delete ${srcFile.name}") + continue + } + + repository.registerExecution( + rule, + Execution(srcFile.name!!, rule.action.verb), + ) + } } - - - Logger.i("FlowExecutor", "Deleting existing ${destFile.name}") - destFile.delete() - } - - destFile = destDir.createFile(srcFile.type, destFileName) - - if (destFile == null) { - Logger.e("FlowExecutor", "Failed to create $destFileName") - continue - } - - val result = context.copyFile(srcFile, destFile) - if (!result) { - Logger.e("FlowExecutor", "Failed to copy ${srcFile.name} to ${destFile.name}") - destFile.delete() - continue - } - - repository.registerExecution( - rule, - Execution(srcFile.name!!, rule.action.verb), - ) - - if (!rule.action.keepOriginal) { - Logger.i("FlowExecutor", "Deleting original ${srcFile.name}") - srcFile.delete() } } } diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt index a7baa65..a9a6c6b 100644 --- a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt +++ b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt @@ -7,16 +7,13 @@ import co.adityarajput.fileflow.R @Composable fun Long.toShortHumanReadableTime(): String { - val now = System.currentTimeMillis() - val delta = now - this - - val seconds = delta / 1000 + val seconds = this / 1000 val minutes = seconds / 60 val hours = minutes / 60 val days = hours / 24 return when { - days > 1000 -> stringResource(R.string.many_days_ago) + days > 1000 -> stringResource(R.string.many_days) days > 0 -> pluralStringResource(R.plurals.day, days.toInt(), days) hours > 0 -> pluralStringResource(R.plurals.hour, hours.toInt(), hours) minutes > 0 -> pluralStringResource(R.plurals.minute, minutes.toInt(), minutes) diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt index 28c4233..c3cd7f8 100644 --- a/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt +++ b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt @@ -7,4 +7,5 @@ enum class FileSuperlative(val displayName: Int, val selector: (File) -> Long) { LATEST(R.string.latest, { it.lastModified() }), SMALLEST(R.string.smallest, { -it.length() }), LARGEST(R.string.largest, { it.length() }), + NONE(R.string.all, { 0L }) } diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt index a9eab40..bdaceb9 100644 --- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt +++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt @@ -25,6 +25,7 @@ class UpsertRuleViewModel( data class Values( val ruleId: Int = 0, + val actionBase: Action = Action.entries[0], val src: String = "", val srcFileNamePattern: String = "", val dest: String = "", @@ -34,25 +35,44 @@ class UpsertRuleViewModel( val overwriteExisting: Boolean = false, val currentSrcFileNames: List? = null, val predictedDestFileNames: List? = null, + val retentionDays: Int = 30, ) { - constructor(rule: Rule) : this( - rule.id, (rule.action as Action.MOVE).src, rule.action.srcFileNamePattern, - rule.action.dest, rule.action.destFileNameTemplate, rule.action.superlative, - rule.action.keepOriginal, rule.action.overwriteExisting, - ) + companion object { + fun from(rule: Rule) = when (rule.action) { + is Action.MOVE -> + Values( + rule.id, rule.action.base, rule.action.src, + rule.action.srcFileNamePattern, rule.action.dest, + rule.action.destFileNameTemplate, rule.action.superlative, + rule.action.keepOriginal, rule.action.overwriteExisting, + ) - fun toRule() = Rule( - Action.MOVE( - src, srcFileNamePattern, dest, destFileNameTemplate, - keepOriginal, overwriteExisting, superlative, - ), - id = ruleId, - ) + is Action.DELETE_STALE -> + Values( + rule.id, rule.action.base, rule.action.src, + rule.action.srcFileNamePattern, retentionDays = rule.action.retentionDays, + ) + } + } + + fun toRule() = when (actionBase) { + is Action.MOVE -> + Rule( + Action.MOVE( + src, srcFileNamePattern, dest, destFileNameTemplate, keepOriginal, + overwriteExisting, superlative, + ), + id = ruleId, + ) + + is Action.DELETE_STALE -> + Rule(Action.DELETE_STALE(src, srcFileNamePattern, retentionDays), id = ruleId) + } } var state by mutableStateOf( if (rule == null) State() - else State(Values(rule), null), + else State(Values.from(rule), null), ) var folderPickerState by mutableStateOf(null) @@ -69,16 +89,17 @@ class UpsertRuleViewModel( var predictedDestFileNames: List? = null var warning: FormWarning? = null - try { - val regex = Regex(values.srcFileNamePattern) + if (currentSrcFileNames != null && values.destFileNameTemplate.isNotBlank()) { + try { + val regex = Regex(values.srcFileNamePattern) - if (values.destFileNameTemplate.isNotBlank()) predictedDestFileNames = currentSrcFileNames - ?.filter { regex.matches(it) } - ?.also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC } - ?.map { regex.replace(it, values.destFileNameTemplate) } - ?.distinct() - } catch (_: Exception) { + .filter { regex.matches(it) } + .also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC } + .map { regex.replace(it, values.destFileNameTemplate) } + .distinct() + } catch (_: Exception) { + } } val values = values.copy( @@ -90,17 +111,18 @@ class UpsertRuleViewModel( private fun getError(values: Values): FormError? { try { - if ( - values.src.isBlank() || - values.srcFileNamePattern.isBlank() || - values.dest.isBlank() || - values.destFileNameTemplate.isBlank() - ) return FormError.BLANK_FIELDS + if (values.src.isBlank() || values.srcFileNamePattern.isBlank()) + return FormError.BLANK_FIELDS if (Regex(values.srcFileNamePattern).pattern != values.srcFileNamePattern) return FormError.INVALID_REGEX - if (values.predictedDestFileNames == null) return FormError.INVALID_TEMPLATE + if (values.actionBase is Action.MOVE) { + if (values.dest.isBlank() || values.destFileNameTemplate.isBlank()) + return FormError.BLANK_FIELDS + if (values.predictedDestFileNames == null) + return FormError.INVALID_TEMPLATE + } } catch (_: Exception) { Logger.d("UpsertRuleViewModel", "Invalid regex") return FormError.INVALID_REGEX diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt index b5338ff..a4d0b51 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/ExecutionsScreen.kt @@ -64,7 +64,10 @@ fun ExecutionsScreen( Tile( it.fileName, stringResource(it.actionVerb), - it.timestamp.toShortHumanReadableTime(), + stringResource( + R.string.ago, + (System.currentTimeMillis() - it.timestamp).toShortHumanReadableTime(), + ), ) } } diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt index 594dd56..2ea1de3 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/RulesScreen.kt @@ -86,7 +86,7 @@ fun RulesScreen( ) { items(state.value.rules!!, { it.id }) { Tile( - it.action.title, + it.action.srcFileNamePattern, stringResource(it.action.verb), if (!it.enabled) stringResource(R.string.disabled) else pluralStringResource( @@ -96,7 +96,7 @@ fun RulesScreen( ), { Text( - it.action.description, + it.action.getDescription(), style = MaterialTheme.typography.bodySmall, ) }, diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt index dcea548..5b6e236 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt @@ -5,7 +5,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* @@ -17,9 +19,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration import androidx.lifecycle.viewmodel.compose.viewModel import co.adityarajput.fileflow.R +import co.adityarajput.fileflow.data.models.Action import co.adityarajput.fileflow.utils.* import co.adityarajput.fileflow.viewmodels.FormError import co.adityarajput.fileflow.viewmodels.FormWarning @@ -118,23 +122,50 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { var superlativeDropdownExpanded by remember { mutableStateOf(false) } - val srcPicker = - rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - context.requestPersistableFolderPermission(uri) - viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString())) - } - val destPicker = - rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - context.requestPersistableFolderPermission(uri) - viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString())) - } + val srcPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + context.requestPersistableFolderPermission(uri) + viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString())) + } + val destPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + context.requestPersistableFolderPermission(uri) + viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString())) + } + Text( + stringResource(R.string.action), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + ) + Action.entries.forEach { + Row( + Modifier + .fillMaxWidth() + .selectable(it isSimilarTo viewModel.state.values.actionBase) { + viewModel.updateForm( + context, + viewModel.state.values.copy(actionBase = it), + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + it isSimilarTo viewModel.state.values.actionBase, + null, + Modifier.padding(horizontal = dimensionResource(R.dimen.padding_small)), + ) + Text( + stringResource(it.phrase), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, + ) + } + } Text( buildAnnotatedString { append(stringResource(R.string.source)) @@ -183,108 +214,146 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { ), singleLine = true, ) - Row( - Modifier.toggleable(!viewModel.state.values.keepOriginal) { - viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it)) - }, - Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), - Alignment.Top, - ) { - Checkbox(!viewModel.state.values.keepOriginal, null) - Text( - stringResource(R.string.delete_original), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Normal, - ) - } - Box { - Text( - buildAnnotatedString { - append(stringResource(R.string.choose_superlative)) - withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { - append(stringResource(viewModel.state.values.superlative.displayName)) - } - }, - Modifier.clickable { superlativeDropdownExpanded = true }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal, - ) - DropdownMenu(superlativeDropdownExpanded, { superlativeDropdownExpanded = false }) { - FileSuperlative.entries.forEach { - DropdownMenuItem( - { Text(stringResource(it.displayName)) }, - { - viewModel.updateForm(context, viewModel.state.values.copy(superlative = it)) - superlativeDropdownExpanded = false - }, + when (viewModel.state.values.actionBase) { + is Action.MOVE -> { + Row( + Modifier.toggleable(!viewModel.state.values.keepOriginal) { + viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it)) + }, + Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + Alignment.Top, + ) { + Checkbox(!viewModel.state.values.keepOriginal, null) + Text( + stringResource(R.string.delete_original), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, ) } - } - } - Icon( - painterResource(R.drawable.arrow_down), - stringResource(R.string.arrow_down), - Modifier.align(Alignment.CenterHorizontally), - ) - Text( - buildAnnotatedString { - append(stringResource(R.string.destination)) - withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { - append( - viewModel.state.values.dest.getGetDirectoryFromUri() - .ifBlank { stringResource(R.string.select_folder) }, + Box { + Text( + buildAnnotatedString { + append(stringResource(R.string.choose_superlative)) + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append(stringResource(viewModel.state.values.superlative.displayName)) + } + }, + Modifier.clickable { superlativeDropdownExpanded = true }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, ) + DropdownMenu(superlativeDropdownExpanded, { superlativeDropdownExpanded = false }) { + FileSuperlative.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.displayName)) }, + { + viewModel.updateForm( + context, + viewModel.state.values.copy(superlative = it), + ) + superlativeDropdownExpanded = false + }, + ) + } + } } - }, - Modifier - .fillMaxWidth() - .clickable { - if (shouldUseCustomPicker) viewModel.folderPickerState = FolderPickerState.DEST - else destPicker.launch(null) - }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal, - ) - OutlinedTextField( - viewModel.state.values.destFileNameTemplate, - { - viewModel.updateForm(context, viewModel.state.values.copy(destFileNameTemplate = it)) - }, - Modifier.fillMaxWidth(), - label = { - Text(stringResource(R.string.file_name_template)) - }, - placeholder = { Text(stringResource(R.string.template_placeholder)) }, - supportingText = { - if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false) + Icon( + painterResource(R.drawable.arrow_down), + stringResource(R.string.arrow_down), + Modifier.align(Alignment.CenterHorizontally), + ) + Text( + buildAnnotatedString { + append(stringResource(R.string.destination)) + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append( + viewModel.state.values.dest.getGetDirectoryFromUri() + .ifBlank { stringResource(R.string.select_folder) }, + ) + } + }, + Modifier + .fillMaxWidth() + .clickable { + if (shouldUseCustomPicker) viewModel.folderPickerState = + FolderPickerState.DEST + else destPicker.launch(null) + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + ) + OutlinedTextField( + viewModel.state.values.destFileNameTemplate, + { + viewModel.updateForm( + context, + viewModel.state.values.copy(destFileNameTemplate = it), + ) + }, + Modifier.fillMaxWidth(), + label = { + Text(stringResource(R.string.file_name_template)) + }, + placeholder = { Text(stringResource(R.string.template_placeholder)) }, + supportingText = { + if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false) + Text( + stringResource( + R.string.template_will_yield, + viewModel.state.values.predictedDestFileNames!! + .joinToString(stringResource(R.string.or), limit = 3), + ), + ) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + singleLine = true, + ) + Row( + Modifier.toggleable(viewModel.state.values.overwriteExisting) { + viewModel.updateForm( + context, + viewModel.state.values.copy(overwriteExisting = it), + ) + }, + Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), + Alignment.Top, + ) { + Checkbox(viewModel.state.values.overwriteExisting, null) Text( - stringResource( - R.string.template_will_yield, - viewModel.state.values.predictedDestFileNames!! - .joinToString(stringResource(R.string.or), limit = 3), - ), + stringResource(R.string.overwrite_existing), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, ) - }, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, - disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - singleLine = true, - ) - Row( - Modifier.toggleable(viewModel.state.values.overwriteExisting) { - viewModel.updateForm(context, viewModel.state.values.copy(overwriteExisting = it)) - }, - Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), - Alignment.Top, - ) { - Checkbox(viewModel.state.values.overwriteExisting, null) - Text( - stringResource(R.string.overwrite_existing), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Normal, - ) + } + } + + is Action.DELETE_STALE -> { + OutlinedTextField( + viewModel.state.values.retentionDays.toString(), + { + it.toIntOrNull()?.let { days -> + viewModel.updateForm( + context, + viewModel.state.values.copy(retentionDays = days), + ) + } + }, + Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.retention_days)) }, + suffix = { Text(stringResource(R.string.days)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + } } Text( AnnotatedString.fromHtml( diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 85f734a..1cf6872 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -6,19 +6,19 @@ - %1$d day ago - %1$d days ago + %1$d day + %1$d days - %1$d hour ago - %1$d hours ago + %1$d hour + %1$d hours - %1$d minute ago - %1$d minutes ago + %1$d minute + %1$d minutes - %1$d second ago - %1$d seconds ago + %1$d second + %1$d seconds diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54cbfe6..9290b38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,8 +18,9 @@ History No executions yet. + %1$s ago - "1k+ days ago" + "1k+ days" just now @@ -55,8 +56,9 @@ Delete - MOVE COPY + MOVE + DELETE @@ -84,6 +86,7 @@ Cancel Add Save + Action: "Source: " select folder File name pattern @@ -99,10 +102,16 @@ Enter a regex template Template will yield %1$s Overwrite existing files in the destination, in case of conflict + Mark stale if unmodified for + days hereβ†— for example patterns and templates.]]> Regex pattern contains errors Regex template contains errors Regex pattern doesn\'t match any file in the source folder + + Copy or move to another folder + Delete if unmodified for a while + Back (Empty) @@ -115,6 +124,7 @@ latest smallest largest + all From 35297880781b280a9a292673a0f61285934c2cdd Mon Sep 17 00:00:00 2001 From: Aditya Rajput Date: Sun, 8 Mar 2026 13:12:59 +0530 Subject: [PATCH 3/3] Update metadata --- README.md | 5 ++++- metadata/en-US/full_description.txt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 559904a..141ee5d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ FileFlow scans your files periodically and organizes them according to your rule ## Features - **Rules** - Use [regex](https://github.com/BURG3R5/FileFlow/wiki/Examples) to precisely target - files and template strings to rename them 🎯 + files 🎯 +- **Actions** - Choose what to do to your files βš™ + 1. Copy, move, or rename files πŸ“ + 2. Delete stale files πŸ—‘ - **History** - Recent executions are stored (locally) ⏳ - **Free, open-source & private** - No ads, subscriptions, or in-app purchases πŸ†“ diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 8d20d85..090be01 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -1 +1 @@ -

FileFlow scans your files periodically and organizes them according to your rules.

Features:

  • Rules: Use regex to precisely target files and template strings to rename them.
  • History: Recent executions are stored (locally).
  • Private: Fully offline; your data never leaves your device.
  • Lightweight: Runs in the background with minimal battery and memory usage.

\ No newline at end of file +

FileFlow scans your files periodically and organizes them according to your rules.

Features:

  • Rules: Use regex to precisely target files.
  • Actions: Choose what to do with the files - copy, move, rename, or delete.
  • History: Recent executions are stored (locally).
  • Private: Fully offline; your data never leaves your device.
  • Lightweight: Runs in the background with minimal battery and memory usage.