Rename a whole batch at once, numbered on from the folder's own series
Selecting more than one file and choosing Rename now opens a pattern dialog rather than the
single-file one: a name, the number to start at, and a live preview of the names it will produce.
The number is the part that makes the feature worth having. A batch rename that always starts at 1
is useless the second time it is used, because the first batch already owns 1 to 12, so the dialog
reads the names already in the folder and starts at the highest "base (n)" it finds: a folder
holding "series (1)" and "series (2)" makes the next batch start at 3, which is the market survey's
most-helpful complaint answered exactly ("if I later find another group of photos which I want to
continue the batch naming with 'Ice (3)...'"). Three rules in core/BatchRename are decisions rather
than defaults, and are written down as such: the extension is kept per file, so a mixed batch of
.jpg and .png keeps each file's own; the series is counted across extensions, because "Holiday
(9).png" making the next number 10 is one series while "Holiday (10).jpg" beside "Holiday (1).png"
is two; and it continues from the highest number rather than from the first gap, because someone
renaming a second batch is carrying a series on rather than filling in the holes of one, with the
start number theirs to type where a gap matters more.
The batch is planned in full before a single file moves, and the plan has three answers and
deliberately no fourth: Ready, Blocked (naming the target that is already in the folder) or
NothingToDo. A batch that renamed nine files of twelve and then stopped would leave the user to work
out which nine and to guess whether the last three crashed or were skipped, so a collision refuses
the whole batch, the dialog names the file in the way and disables its button as the name is typed,
and a chain is declined outright - "a.txt" taking "a (1).txt" while "a (1).txt" is itself in the
batch would need the files shuffled through temporary names. Every rename still goes through the
same FileOperations.rename a single rename uses, so the refusal to overwrite anything is the same
one, and the successful renames are collected into the single Undo.Relocate step that already
existed: a feature that touches twelve files at once owes the user one tap back. The action bar now
offers Rename to any selection, since which of the two dialogs a tap means is described by the
dialog that opens rather than by the bar; Details stays single, because "the details" of twelve
files is not a screen.
Writing it turned up a quieter bug in the rename that was already there. rename(path,
"notes/final.txt") built a child path, which resolves into the subfolder rather than alongside the
file, so a name containing a separator moved the file instead of renaming it and nothing in the
dialog or the listing showed that anything had happened. Both rename paths now refuse a name that is
not one path segment (core/FileNames.isUsableName), and the dialog says which rule it is while it is
typed, which is where the user can still do something about it.
Twenty new core tests (BatchRenameTest, FileNamesTest) and six through the view model, 350 JVM
tests in total, lint clean. Verified by driving it on the phone against a folder that already held
"series (1).txt" and "series (2).txt": with three files selected and "series" typed in, Start at
moved to 3 on its own and the preview listed "series (3).txt, series (4).txt, series (5).txt";
afterwards the folder held all five series files, each file's contents having travelled with its
rename and the two originals untouched, and the snackbar's Undo put a later batch of three back in
one step. The collision and the slash were driven the same way - the file in the way named with the
button disabled, and "A name cannot contain a slash" - and the scratch folder was removed from the
device afterwards. CHANGELOG, ROADMAP (M14), COMPETITIVE_ANALYSIS (the gap moves from candidate to
closed), STORE_LISTING and EDGE_CASE_TESTING carry the feature, its decisions and the pass itself;
two tooling notes went into the last of them, that "adb input text" does not deliver a slash and
that the dialog lifts once the keyboard appears.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
/** One file's part in a batch rename: where it is now, and the name it should end up with. */
|
||||
data class BatchRenameMove(val from: StoragePath, val to: String)
|
||||
|
||||
/**
|
||||
* What a batch rename would do, worked out in full before anything is touched.
|
||||
*
|
||||
* The three outcomes are the three things that can be true of a pattern: it renames everything
|
||||
* ([Ready]), it asks for a name that is already there ([Blocked]), or every file already has the
|
||||
* name it asks for ([NothingToDo]). There is deliberately no fourth case in which the work is
|
||||
* *partly* possible: a batch that renamed nine files of twelve and then stopped would leave the
|
||||
* user to work out which nine, and to guess whether the last three crashed or were skipped.
|
||||
*/
|
||||
sealed interface BatchRenamePlan {
|
||||
/** The renames to perform, in the order the files were shown, and how many stay as they are. */
|
||||
data class Ready(val moves: List<BatchRenameMove>, val unchanged: Int) : BatchRenamePlan
|
||||
|
||||
/** Names the pattern asks for that something else in the folder already holds. */
|
||||
data class Blocked(val taken: List<String>) : BatchRenamePlan
|
||||
|
||||
/** Every selected file is already named what the pattern asks for. */
|
||||
data object NothingToDo : BatchRenamePlan
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a batch of files to one name with a number after each: `Holiday (1).jpg`,
|
||||
* `Holiday (2).jpg`, and so on.
|
||||
*
|
||||
* **The number is the point.** A batch rename that always starts at 1 is useless the second time it
|
||||
* is used — the first batch owns 1 to 12 and the next has nowhere to go — which is exactly what the
|
||||
* most-helpful complaint in the market survey describes. [nextNumber] reads the names already in the
|
||||
* folder so the next batch carries on from the highest number it finds rather than from one.
|
||||
*
|
||||
* Three rules are decisions rather than defaults, and are stated because they change what a user
|
||||
* gets:
|
||||
*
|
||||
* - **The extension is kept, per file.** A batch of mixed `.jpg` and `.png` keeps each file's own
|
||||
* extension, because a pattern names the file and not its type.
|
||||
* - **The series is counted across extensions.** `Holiday (9).png` makes the next number 10 for a
|
||||
* batch of `.jpg` files. Counting per extension would put `Holiday (10).jpg` and
|
||||
* `Holiday (1).png` in one folder while calling them the same series.
|
||||
* - **It continues from the highest number, not from the first gap.** A folder holding `Ice (1)` and
|
||||
* `Ice (9)` continues at 10: someone renaming a second batch is carrying a series on, not filling
|
||||
* in the holes in one. Where a gap matters more than the series, the start number is theirs to
|
||||
* type.
|
||||
*/
|
||||
object BatchRename {
|
||||
|
||||
/**
|
||||
* The number a batch should start at, given the names already in the folder.
|
||||
*
|
||||
* A file named exactly like the base — `Holiday.jpg` — counts as number zero, so the next name
|
||||
* is `Holiday (1).jpg`, which is the shape the rest of the app already produces when it has to
|
||||
* step around a clash.
|
||||
*/
|
||||
fun nextNumber(base: String, namesInFolder: List<String>): Int {
|
||||
val pattern = Regex("^" + Regex.escape(base) + " \\((\\d+)\\)$")
|
||||
var highest = 0
|
||||
for (name in namesInFolder) {
|
||||
val stem = name.substring(0, extensionStart(name))
|
||||
// A file named exactly like the base is number zero, which `highest` already starts at.
|
||||
if (stem == base) continue
|
||||
val number = pattern.matchEntire(stem)?.groupValues?.get(1)?.toIntOrNull() ?: continue
|
||||
if (number > highest) highest = number
|
||||
}
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out what renaming [entries] to [base] from [start] would do, in the order given.
|
||||
*
|
||||
* The order is the caller's — the listing order, which is the order the user is looking at — so
|
||||
* the first file shown becomes number [start].
|
||||
*/
|
||||
fun plan(
|
||||
base: String,
|
||||
start: Int,
|
||||
entries: List<FileEntry>,
|
||||
namesInFolder: List<String>,
|
||||
): BatchRenamePlan {
|
||||
// Numbering starts at one: there is no zeroth copy of anything.
|
||||
val first = start.coerceAtLeast(1)
|
||||
val taken = mutableListOf<String>()
|
||||
val moves = mutableListOf<BatchRenameMove>()
|
||||
var unchanged = 0
|
||||
|
||||
entries.forEachIndexed { index, entry ->
|
||||
val suffix = entry.name.substring(extensionStart(entry.name))
|
||||
val target = "$base (${first + index})$suffix"
|
||||
when {
|
||||
target == entry.name -> unchanged++
|
||||
|
||||
// Anything already in the folder blocks the whole batch — including one of the
|
||||
// selected files under its current name. A chain (`a.txt` taking the name
|
||||
// `a (1).txt` while `a (1).txt` is itself being renamed) would need the files
|
||||
// shuffled through temporary names to be done safely, and refusing it plainly is
|
||||
// better than doing it half way.
|
||||
target in namesInFolder -> taken += target
|
||||
|
||||
else -> moves += BatchRenameMove(entry.path, target)
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
taken.isNotEmpty() -> BatchRenamePlan.Blocked(taken)
|
||||
moves.isEmpty() -> BatchRenamePlan.NothingToDo
|
||||
else -> BatchRenamePlan.Ready(moves, unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [base] can be the name part of a pattern.
|
||||
*
|
||||
* The same rule a single rename needs, because a name is one path segment in both cases — see
|
||||
* [isUsableName].
|
||||
*/
|
||||
fun isUsableBase(base: String): Boolean = isUsableName(base)
|
||||
|
||||
/** Where a name's extension begins, or its length when it has none — `.hide` has none. */
|
||||
private fun extensionStart(name: String): Int {
|
||||
val dot = name.lastIndexOf('.')
|
||||
return if (dot <= 0) name.length else dot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
/**
|
||||
* Whether [name] can be the name of a file in a folder.
|
||||
*
|
||||
* A name is **one path segment**: non-blank, and with no separator in it. That second half is not
|
||||
* pedantry. `StoragePath.child("notes/final")` resolves to `notes/final`, so a rename dialog that
|
||||
* accepted it would not rename anything — it would *move* the file into a subfolder that happens to
|
||||
* exist, which is not what the dialog offered. The check lives here rather than in the two dialogs
|
||||
* that need it so that "single rename" and "batch rename" cannot disagree about what a name is.
|
||||
*/
|
||||
fun isUsableName(name: String): Boolean =
|
||||
name.isNotBlank() && name.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.softwareapp_hb.filemanager.ui.browser
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -14,12 +16,145 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.BatchRenamePlan
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
|
||||
/**
|
||||
* Renaming several files at once, with the numbering worked out before anything moves.
|
||||
*
|
||||
* This is where the sequence continuation becomes visible: the number starts at whatever the
|
||||
* folder's own names imply, and a preview shows exactly which names that produces. Nothing is
|
||||
* renamed until Rename is pressed, and a name the folder already holds is shown as the reason the
|
||||
* button is off rather than discovered as a failure afterwards.
|
||||
*/
|
||||
@Composable
|
||||
fun BatchRenameDialog(
|
||||
entries: List<FileEntry>,
|
||||
/** The names already in this folder, so the numbering can carry an existing series on. */
|
||||
namesInFolder: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (base: String, start: Int) -> Unit,
|
||||
) {
|
||||
var base by remember { mutableStateOf("") }
|
||||
|
||||
/*
|
||||
* `null` until the user types a number, and until then the field follows what the folder
|
||||
* implies — so a batch that continues a series needs no arithmetic from the user at all. Once a
|
||||
* number has been typed it is theirs and stops following the name, because a start that jumped
|
||||
* as they edited the name would be worse than one they have to correct.
|
||||
*/
|
||||
var typedStart by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val startText = typedStart ?: BatchRename.nextNumber(base, namesInFolder).toString()
|
||||
val start = startText.toIntOrNull()
|
||||
val plan = start?.let { BatchRename.plan(base, it, entries, namesInFolder) }
|
||||
val usable = BatchRename.isUsableBase(base)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.browser_rename_many_title,
|
||||
entries.size,
|
||||
entries.size,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = base,
|
||||
onValueChange = { base = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
isError = base.isNotEmpty() && !usable,
|
||||
label = { Text(stringResource(R.string.browser_name_label)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = startText,
|
||||
// Digits only: a number field that accepts anything and then rejects it is a
|
||||
// field that lies about what it wants.
|
||||
onValueChange = { typed -> typedStart = typed.filter { it.isDigit() } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.browser_rename_many_start)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = batchRenameNote(base = base, usable = usable, plan = plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (plan is BatchRenamePlan.Ready) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (start != null) onConfirm(base.trim(), start) },
|
||||
enabled = usable && plan is BatchRenamePlan.Ready,
|
||||
) {
|
||||
Text(stringResource(R.string.browser_action_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.browser_action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** How many of the resulting names the dialog spells out before it stops counting them. */
|
||||
private const val PREVIEW_NAMES = 3
|
||||
|
||||
/**
|
||||
* What the dialog says under the fields: what the pattern will produce, or why it will not.
|
||||
*
|
||||
* Written as one function returning one line on purpose — the three ways a plan can fail are the
|
||||
* only things worth saying here, and the button's own state says the rest.
|
||||
*/
|
||||
@Composable
|
||||
private fun batchRenameNote(base: String, usable: Boolean, plan: BatchRenamePlan?): String = when {
|
||||
base.isEmpty() -> ""
|
||||
!usable -> stringResource(R.string.browser_rename_many_bad_name)
|
||||
|
||||
plan is BatchRenamePlan.Blocked ->
|
||||
stringResource(R.string.browser_rename_many_taken, plan.taken.first())
|
||||
|
||||
plan is BatchRenamePlan.NothingToDo -> stringResource(R.string.browser_rename_many_nothing)
|
||||
|
||||
plan is BatchRenamePlan.Ready -> {
|
||||
val shown = plan.moves.take(PREVIEW_NAMES).joinToString(", ") { it.to }
|
||||
val lead = stringResource(R.string.browser_rename_many_preview, shown)
|
||||
if (plan.moves.size > PREVIEW_NAMES) {
|
||||
lead + "\n" + stringResource(R.string.browser_rename_many_more, plan.moves.size - PREVIEW_NAMES)
|
||||
} else {
|
||||
lead
|
||||
}
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
/** A single-field dialog, used for "New folder" and "Rename". */
|
||||
@Composable
|
||||
fun NameDialog(
|
||||
|
||||
@@ -97,6 +97,9 @@ fun BrowserScreen(
|
||||
var detailsFor by remember { mutableStateOf<FileEntry?>(null) }
|
||||
var showNewFolder by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<FileEntry?>(null) }
|
||||
|
||||
/** Several files at once: the selection, held while the pattern dialog is open. */
|
||||
var renameAllTargets by remember { mutableStateOf<List<FileEntry>?>(null) }
|
||||
var confirmDelete by remember { mutableStateOf<List<StoragePath>?>(null) }
|
||||
var searchOpen by remember { mutableStateOf(false) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
@@ -411,7 +414,16 @@ fun BrowserScreen(
|
||||
onCut = { viewModel.copyToClipboard(state.selection.toList(), cut = true) },
|
||||
onDelete = { confirmDelete = state.selection.toList() },
|
||||
onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
|
||||
onRename = { renameTarget = state.selectedEntries.singleOrNull() },
|
||||
onRename = {
|
||||
val selected = state.selectedEntries
|
||||
// One file is renamed by name; several are renamed by pattern, which is the
|
||||
// only way a batch can be given a numbering at all.
|
||||
if (selected.size > 1) {
|
||||
renameAllTargets = selected
|
||||
} else {
|
||||
renameTarget = selected.singleOrNull()
|
||||
}
|
||||
},
|
||||
onCompress = { viewModel.compress(state.selection.toList()) },
|
||||
onExtract = {
|
||||
state.selectedEntries.singleOrNull()?.let(viewModel::extract)
|
||||
@@ -534,6 +546,21 @@ fun BrowserScreen(
|
||||
)
|
||||
}
|
||||
|
||||
renameAllTargets?.let { targets ->
|
||||
BatchRenameDialog(
|
||||
entries = targets,
|
||||
// The names this folder holds, so the numbering can carry its series on. Taken from the
|
||||
// listing on screen rather than from the storage again: the dialog must predict exactly
|
||||
// what the view model will do, and the view model reads the same listing.
|
||||
namesInFolder = state.entries.map { it.name },
|
||||
onDismiss = { renameAllTargets = null },
|
||||
onConfirm = { base, start ->
|
||||
renameAllTargets = null
|
||||
viewModel.renameAll(targets, base, start)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
confirmDelete?.let { paths ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.browser_delete_title),
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.softwareapp_hb.filemanager.FileManagerApplication
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.ArchiveFormats
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.BatchRenamePlan
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileCategory
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
@@ -22,6 +24,7 @@ import de.softwareapp_hb.filemanager.core.SortKey
|
||||
import de.softwareapp_hb.filemanager.core.SortSpec
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.core.isUsableName
|
||||
import de.softwareapp_hb.filemanager.data.ApkInspector
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
@@ -1642,7 +1645,9 @@ class BrowserViewModel(
|
||||
val source = storage.activeSource() ?: return
|
||||
val parent = path.parent ?: return
|
||||
val cleanName = newName.trim()
|
||||
if (cleanName.isEmpty() || cleanName == path.name) return
|
||||
// A name is one path segment: a separator here would be read as a path and move the file
|
||||
// into a subfolder rather than rename it — see `isUsableName`.
|
||||
if (cleanName.isEmpty() || cleanName == path.name || !isUsableName(cleanName)) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) }
|
||||
@@ -1664,6 +1669,74 @@ class BrowserViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename several files to one name with a number after each — [BatchRename] works the numbering
|
||||
* out, this performs it.
|
||||
*
|
||||
* Two things are deliberate. **The plan is checked before a single file moves**, so a batch that
|
||||
* would collide is refused as a whole rather than performed until it hits the collision; the
|
||||
* dialog has already shown the same answer, and this is the view model refusing to be told
|
||||
* otherwise by a stale screen. And **every rename goes through the same
|
||||
* [FileOperations.rename] a single rename uses**, which is what keeps this from being a second,
|
||||
* subtly different way to rename a file — including its refusal to overwrite anything, so a
|
||||
* collision that happened between the plan and the work fails one file rather than destroying
|
||||
* one.
|
||||
*
|
||||
* The successful renames become **one** undo step: [Undo.Relocate] already holds a list of
|
||||
* moves, so a batch rename is as reversible as a single one, which is the least a feature that
|
||||
* touches twelve files at once owes the user.
|
||||
*/
|
||||
fun renameAll(entries: List<FileEntry>, base: String, start: Int) {
|
||||
val source = storage.activeSource() ?: return
|
||||
val parent = _state.value.path ?: return
|
||||
val cleanBase = base.trim()
|
||||
if (entries.size < 2 || !isUsableName(cleanBase)) return
|
||||
|
||||
// The listing on screen, which is the same list the dialog planned against: hidden files are
|
||||
// not in it, so a clash with one is not predicted here — it is refused by the rename itself
|
||||
// and reported as the one file that failed, rather than silently overwriting anything.
|
||||
val namesInFolder = _state.value.entries.map { it.name }
|
||||
val plan = when (val planned = BatchRename.plan(cleanBase, start, entries, namesInFolder)) {
|
||||
is BatchRenamePlan.Ready -> planned
|
||||
|
||||
is BatchRenamePlan.Blocked -> {
|
||||
message(R.string.browser_rename_many_taken, planned.taken.first())
|
||||
return
|
||||
}
|
||||
|
||||
BatchRenamePlan.NothingToDo -> {
|
||||
message(R.string.browser_rename_many_nothing)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val done = mutableListOf<UndoMove>()
|
||||
var failed = 0
|
||||
|
||||
for (move in plan.moves) {
|
||||
val target = parent.child(move.to)
|
||||
val renamed = runCatching { FileOperations(source).rename(move.from, target) }
|
||||
.getOrDefault(false)
|
||||
if (renamed) {
|
||||
done += UndoMove(from = target, to = move.from)
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
if (done.isNotEmpty()) {
|
||||
_state.update { it.copy(undo = Undo.Relocate(done)) }
|
||||
}
|
||||
when {
|
||||
failed == 0 -> messageUndoablePlural(R.plurals.message_renamed_many, done.size)
|
||||
done.isNotEmpty() -> messageUndoable(R.string.message_renamed_some, done.size, plan.moves.size)
|
||||
else -> messagePlural(R.plurals.message_failed, failed)
|
||||
}
|
||||
reloadCurrentView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete [paths].
|
||||
*
|
||||
|
||||
@@ -122,10 +122,16 @@ fun SelectionBar(
|
||||
IconButton(onClick = onCut) {
|
||||
Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
|
||||
}
|
||||
/*
|
||||
* Rename is offered for any selection: one file is renamed by name and several by
|
||||
* pattern, and which of the two a tap means is described by the dialog that opens, not
|
||||
* by the bar. Details stays single, because "the details" of twelve files is not a
|
||||
* screen.
|
||||
*/
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
|
||||
}
|
||||
if (singled != null) {
|
||||
IconButton(onClick = onRename) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
|
||||
}
|
||||
IconButton(onClick = onDetails) {
|
||||
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
|
||||
}
|
||||
|
||||
@@ -144,6 +144,28 @@
|
||||
<string name="browser_new_folder_title">New folder</string>
|
||||
<string name="browser_new_folder_default">New folder</string>
|
||||
<string name="browser_rename_title">Rename</string>
|
||||
<!-- ===== Browser: batch rename =====
|
||||
Renaming several files to one name with a number after each. The number is the point: a
|
||||
batch that always started at one would be useless the second time it was used. -->
|
||||
<plurals name="browser_rename_many_title">
|
||||
<item quantity="one">Rename %1$d file</item>
|
||||
<item quantity="other">Rename %1$d files</item>
|
||||
</plurals>
|
||||
<string name="browser_rename_many_start">Start at</string>
|
||||
<!-- %1$s lists the first few names the pattern produces, e.g. "Holiday (10).jpg, Holiday (11).jpg". -->
|
||||
<string name="browser_rename_many_preview">Will be renamed to %1$s</string>
|
||||
<!-- %1$d is how many further names there are beyond the few shown. -->
|
||||
<string name="browser_rename_many_more">… and %1$d more</string>
|
||||
<!-- %1$s is a name the pattern asks for that something in this folder already holds. -->
|
||||
<string name="browser_rename_many_taken">%1$s is already in this folder</string>
|
||||
<string name="browser_rename_many_nothing">Every selected file already has that name</string>
|
||||
<string name="browser_rename_many_bad_name">A name cannot contain a slash</string>
|
||||
<plurals name="message_renamed_many">
|
||||
<item quantity="one">Renamed %1$d file</item>
|
||||
<item quantity="other">Renamed %1$d files</item>
|
||||
</plurals>
|
||||
<!-- %1$d succeeded of %2$d attempted, when part of a batch could not be renamed. -->
|
||||
<string name="message_renamed_some">Renamed %1$d of %2$d files</string>
|
||||
<string name="browser_name_label">Name</string>
|
||||
|
||||
<!-- ===== Browser: details ===== -->
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The numbering rules, which is where this feature either earns its keep or does not: the rule a
|
||||
* user notices is the one that decides *where a second batch starts*, so most of this is about that.
|
||||
*/
|
||||
class BatchRenameTest {
|
||||
|
||||
private val folder = StoragePath.of("/storage/emulated/0/Pictures")
|
||||
|
||||
private fun file(name: String) = FileEntry(
|
||||
path = folder.child(name),
|
||||
isDirectory = false,
|
||||
sizeBytes = 1L,
|
||||
lastModifiedMillis = 0L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a second batch carries on where the first stopped`() {
|
||||
// The whole point of the feature, and the complaint it came from: "Ice (1), Ice (2)" today,
|
||||
// and no way to ask for "Ice (3), Ice (4)" tomorrow.
|
||||
val existing = listOf("Ice (1).jpg", "Ice (2).jpg", "Ice (3).jpg")
|
||||
|
||||
assertEquals(4, BatchRename.nextNumber("Ice", existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file named exactly like the base counts as number zero`() {
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", listOf("Ice.jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it continues from the highest number rather than from the first gap`() {
|
||||
// Carrying a series on, not filling holes in one.
|
||||
assertEquals(10, BatchRename.nextNumber("Ice", listOf("Ice (1).jpg", "Ice (9).jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the series is counted across extensions`() {
|
||||
// Counting per extension would put Holiday (10).jpg and Holiday (1).png in one folder and
|
||||
// call them the same series.
|
||||
assertEquals(10, BatchRename.nextNumber("Ice", listOf("Ice (9).png")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names that merely resemble the pattern are ignored`() {
|
||||
val existing = listOf(
|
||||
"Iceberg (9).jpg",
|
||||
"Ice (old).jpg",
|
||||
"Ice (9) copy.jpg",
|
||||
"Ice (9).jpg.bak",
|
||||
"Iced.jpg",
|
||||
)
|
||||
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a base that contains regex characters is matched literally`() {
|
||||
// Unescaped, the parentheses in the base would swallow the number and a second batch would
|
||||
// silently start again at 1, on top of the files that are already there.
|
||||
assertEquals(4, BatchRename.nextNumber("Ice (2024)", listOf("Ice (2024) (3).jpg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty folder starts at one`() {
|
||||
assertEquals(1, BatchRename.nextNumber("Ice", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan keeps each file's own extension and numbers it in the order shown`() {
|
||||
val entries = listOf(file("a.JPG"), file("b.png"), file("notes"))
|
||||
val names = entries.map { it.name }
|
||||
|
||||
val ready = BatchRename.plan("Holiday", 1, entries, names) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(
|
||||
listOf("Holiday (1).JPG", "Holiday (2).png", "Holiday (3)"),
|
||||
ready.moves.map { it.to },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("a.JPG", "b.png", "notes"),
|
||||
ready.moves.map { it.from.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan can continue the folder's series`() {
|
||||
val entries = listOf(file("new-one.jpg"), file("new-two.jpg"))
|
||||
val existing = listOf("new-one.jpg", "new-two.jpg", "Ice (1).jpg", "Ice (2).jpg")
|
||||
|
||||
val start = BatchRename.nextNumber("Ice", existing)
|
||||
val ready = BatchRename.plan("Ice", start, entries, existing) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(listOf("Ice (3).jpg", "Ice (4).jpg"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name that is already in the folder blocks the whole batch`() {
|
||||
val entries = listOf(file("a.jpg"), file("b.jpg"))
|
||||
val existing = listOf("a.jpg", "b.jpg", "Ice (1).jpg")
|
||||
|
||||
val blocked = BatchRename.plan("Ice", 1, entries, existing) as BatchRenamePlan.Blocked
|
||||
|
||||
assertEquals(listOf("Ice (1).jpg"), blocked.taken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chain through a selected file is refused rather than half-done`() {
|
||||
// Renaming a.txt onto a (1).txt while a (1).txt is itself being renamed needs the files
|
||||
// shuffled through temporary names. Refusing is honest; doing it half way is not.
|
||||
val entries = listOf(file("a.txt"), file("a (1).txt"))
|
||||
val existing = listOf("a.txt", "a (1).txt")
|
||||
|
||||
val blocked = BatchRename.plan("a", 1, entries, existing) as BatchRenamePlan.Blocked
|
||||
|
||||
assertEquals(listOf("a (1).txt"), blocked.taken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files that already have the pattern's names are left alone`() {
|
||||
val entries = listOf(file("Ice (1).jpg"), file("Ice (2).jpg"))
|
||||
val existing = listOf("Ice (1).jpg", "Ice (2).jpg")
|
||||
|
||||
assertEquals(BatchRenamePlan.NothingToDo, BatchRename.plan("Ice", 1, entries, existing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file that already has its target name is counted and skipped, not renamed to itself`() {
|
||||
val entries = listOf(file("Ice (1).jpg"), file("new.jpg"))
|
||||
val existing = listOf("Ice (1).jpg", "new.jpg")
|
||||
|
||||
val ready = BatchRename.plan("Ice", 1, entries, existing) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals("only the file that has to move does", listOf("Ice (2).jpg"), ready.moves.map { it.to })
|
||||
assertEquals(1, ready.unchanged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start below one is treated as one, because there is no zeroth copy`() {
|
||||
val entries = listOf(file("a.jpg"))
|
||||
val ready = BatchRename.plan("Ice", 0, entries, listOf("a.jpg")) as BatchRenamePlan.Ready
|
||||
|
||||
assertEquals(listOf("Ice (1).jpg"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no extension gains none`() {
|
||||
val entries = listOf(file(".hidden"), file("README"))
|
||||
val ready = BatchRename.plan("Ice", 1, entries, listOf(".hidden", "README")) as BatchRenamePlan.Ready
|
||||
|
||||
// A leading dot marks a hidden file rather than an extension, so the whole name is the stem.
|
||||
assertEquals(listOf("Ice (1)", "Ice (2)"), ready.moves.map { it.to })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a base has to be a name, not a path`() {
|
||||
// The rule itself lives with the other name helpers; what matters here is that a batch
|
||||
// cannot be started with something that would move a file rather than rename it.
|
||||
assertTrue(BatchRename.isUsableBase("Holiday 2026"))
|
||||
assertFalse(BatchRename.isUsableBase("notes/final"))
|
||||
assertFalse(BatchRename.isUsableBase(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.softwareapp_hb.filemanager.core
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* What counts as a name. Small, but it is the difference between renaming a file and moving it:
|
||||
* `child("notes/final")` is a path into a subfolder, so a dialog that accepted one would do
|
||||
* something other than what it offered.
|
||||
*/
|
||||
class FileNamesTest {
|
||||
|
||||
@Test
|
||||
fun `an ordinary name is usable`() {
|
||||
assertTrue(isUsableName("Holiday 2026.jpg"))
|
||||
assertTrue(isUsableName("Ice (2024)"))
|
||||
assertTrue(isUsableName("README"))
|
||||
assertTrue(isUsableName(".nomedia"))
|
||||
assertTrue(isUsableName("report.final v2.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a separator is not part of a name`() {
|
||||
assertFalse(isUsableName("notes/final.txt"))
|
||||
assertFalse(isUsableName("notes\\final.txt"))
|
||||
assertFalse(isUsableName("/absolute"))
|
||||
assertFalse(isUsableName("a/b/c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name has to say something`() {
|
||||
assertFalse(isUsableName(""))
|
||||
assertFalse(isUsableName(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null byte cannot be in a name`() {
|
||||
assertFalse(isUsableName("a\u0000b"))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.net.Uri
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.core.BatchRename
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.FileCategory
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
@@ -1283,6 +1284,102 @@ class BrowserViewModelTest {
|
||||
assertNull(model.state.value.undo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename numbers the files in the order they are listed`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
assertTrue(storage.contains(downloads.child("photo (1).txt")))
|
||||
assertTrue(storage.contains(downloads.child("photo (2).txt")))
|
||||
assertFalse(storage.contains(downloads.child("a.txt")))
|
||||
// A rename is a rename: the contents move with the name, they are not copied to a new file.
|
||||
assertEquals("first", storage.contentOf(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename continues a series the folder already has`() {
|
||||
// The complaint the feature came from: the first batch owns 1 and 2, and a second batch has
|
||||
// to be able to ask for 3 and 4.
|
||||
storage.addFile(downloads.child("photo (1).txt"), "old")
|
||||
storage.addFile(downloads.child("photo (2).txt"), "older")
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
|
||||
val names = model.state.value.entries.map { it.name }
|
||||
val start = BatchRename.nextNumber("photo", names)
|
||||
val selected = model.state.value.entries.filter { it.name == "a.txt" || it.name == "b.txt" }
|
||||
model.renameAll(selected, "photo", start)
|
||||
|
||||
assertEquals(3, start)
|
||||
assertTrue(storage.contains(downloads.child("photo (3).txt")))
|
||||
assertTrue(storage.contains(downloads.child("photo (4).txt")))
|
||||
// The files that were already in the series are untouched.
|
||||
assertEquals("old", storage.contentOf(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch rename is a single undo step`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
val undo = model.state.value.undo
|
||||
assertTrue(undo is Undo.Relocate)
|
||||
assertEquals("both renames, taken back together", 2, (undo as Undo.Relocate).moves.size)
|
||||
|
||||
model.undo()
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
assertFalse(storage.contains(downloads.child("photo (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch rename refuses a batch whose names are already taken`() {
|
||||
storage.addFile(downloads.child("photo (1).txt"), "in the way")
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { it.name == "a.txt" || it.name == "b.txt" }
|
||||
|
||||
model.renameAll(entries, "photo", 1)
|
||||
|
||||
assertTrue("nothing moved at all", storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
assertEquals(R.string.browser_rename_many_taken, model.state.value.message?.textRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a filename cannot be a path`() {
|
||||
// `notes/final.txt` resolves to a path into a subfolder, so a rename that accepted it would
|
||||
// move the file rather than rename it. The subfolder exists here, which is the case that
|
||||
// would have silently succeeded.
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
storage.addDirectory(downloads.child("notes"))
|
||||
|
||||
model.rename(downloads.child("a.txt"), "notes/final.txt")
|
||||
|
||||
assertTrue("the file stays where it was", storage.contains(downloads.child("a.txt")))
|
||||
assertFalse(storage.contains(downloads.child("notes").child("final.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch rename cannot be given a path either`() {
|
||||
val model = viewModel()
|
||||
model.openDirectory(downloads)
|
||||
val entries = model.state.value.entries.filter { !it.isDirectory }
|
||||
|
||||
model.renameAll(entries, "notes/final", 1)
|
||||
|
||||
assertTrue(storage.contains(downloads.child("a.txt")))
|
||||
assertTrue(storage.contains(downloads.child("b.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a rename puts the old name back`() {
|
||||
val model = viewModel()
|
||||
|
||||
Reference in New Issue
Block a user