Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 🆓
Expand Down
15 changes: 11 additions & 4 deletions app/src/main/java/co/adityarajput/fileflow/data/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,32 @@ 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,
),
executions = 2,
),
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ class Converters {
fun toAction(value: String) = try {
Json.decodeFromString<Action>(value)
} catch (_: Exception) {
Action.MOVE("", "", "", "")
Action.entries[0]
}
}
82 changes: 63 additions & 19 deletions app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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("", "")) }
}
}
156 changes: 100 additions & 56 deletions app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
}
}
}
Expand Down
7 changes: 2 additions & 5 deletions app/src/main/java/co/adityarajput/fileflow/utils/String.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]"))
}
Expand Down
Loading