Desktop: rename recordings from the library

Rename button per row (hidden while queued/transcribing so the pump
never races it). Dialog validates via LibraryQueue.renameTarget:
extension is fixed, typed extension stripped (case-insensitive),
rejects empty/dot/slash names, collisions, no-ops. The .transcript.md
report moves with the audio; failed/live sets carry over by name.
4 new RenameTest cases; desktop suite 17 green. Verified in-app:
Rename visible on every row.
This commit is contained in:
avi 2026-09-13 02:35:34 -05:00
commit 9906a5af00
4 changed files with 138 additions and 0 deletions

View file

@ -594,6 +594,36 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
}
}
/**
* Rename a library file (extension fixed) and carry its report along.
* Refuses while the file is queued/transcribing so the pump can't
* race the rename. Returns null on success or a human-readable reason.
*/
fun renameFile(old: File, newBase: String): String? {
if (old.name in inFlight) return "Wait — this file is transcribing."
val target = LibraryQueue.renameTarget(old, newBase)
.getOrElse { return it.message ?: "Invalid name." }
val ok = runCatching {
old.renameTo(target).also { moved ->
if (moved) {
val oldReport = reportFile(old)
if (oldReport.exists()) {
oldReport.renameTo(reportFile(target))
}
}
}
}.getOrElse { return "Rename failed: ${it.message}" }
if (!ok) return "Rename failed (disk said no)."
// Sets/flows are keyed by file name — carry entries over.
if (old.name in failedFiles) { failedFiles -= old.name; failedFiles += target.name }
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
remove(old.name)
}
if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target)
rescan()
return null
}
fun setQuery(q: String) {
_query.value = q
val needle = q.trim().lowercase()

View file

@ -40,6 +40,33 @@ class LibraryDiff(
object LibraryQueue {
/**
* Validate renaming [oldFile] to base name [newBase] (no extension;
* a typed extension is stripped). Returns the target File or a
* human-readable failure. The extension always stays the original's.
*/
fun renameTarget(oldFile: File, newBase: String): Result<File> {
val base = newBase.trim()
.let { s ->
val ext = oldFile.extension
if (ext.isNotEmpty() && s.length > ext.length + 1 &&
s.endsWith(".$ext", ignoreCase = true)
) s.dropLast(ext.length + 1) else s
}
.trim()
fun fail(msg: String): Result<File> =
Result.failure(IllegalArgumentException(msg))
if (base.isEmpty()) return fail("Name is empty.")
if (base.any { it == '/' || it == '\\' }) return fail("Name can't contain a slash.")
if (base == "." || base == "..") return fail("Not a valid name.")
if (base.startsWith(".")) return fail("Name can't start with a dot.")
if (base.length > 120) return fail("Name is too long.")
val target = File(oldFile.parentFile, "$base.${oldFile.extension}")
if (target == oldFile) return fail("That's already the name.")
if (target.exists()) return fail("A file with that name already exists.")
return Result.success(target)
}
/** Audio candidates: regular files, known extension, not hidden. */
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
f.isFile && !f.name.startsWith(".") &&

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
@ -89,6 +90,7 @@ fun LibraryScreen(state: DesktopState) {
val query by state.query.collectAsState()
val hits by state.searchResults.collectAsState()
val connected by state.connected.collectAsState()
var renaming by remember { mutableStateOf<File?>(null) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -202,12 +204,48 @@ fun LibraryScreen(state: DesktopState) {
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") }
}
if (e.status != FileStatus.RUNNING &&
e.status != FileStatus.QUEUED) {
TextButton({ renaming = e.file }) { Text("Rename") }
}
TextButton({ state.openDetail(e.file) }) { Text("Open") }
}
}
}
}
}
renaming?.let { target ->
var text by remember(target) { mutableStateOf(target.nameWithoutExtension) }
var error by remember(target) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Rename recording") },
text = {
Column {
OutlinedTextField(
value = text,
onValueChange = { text = it; error = null },
label = { Text("New name") },
singleLine = true,
)
error?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton({
error = state.renameFile(target, text)
if (error == null) renaming = null
}) { Text("Rename") }
},
dismissButton = {
TextButton({ renaming = null }) { Text("Cancel") }
},
)
}
}
}

View file

@ -0,0 +1,43 @@
package com.shonar.desktop
import java.io.File
import kotlin.io.path.createTempDirectory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RenameTest {
private fun tmp(): File = createTempDirectory("rn").toFile()
@Test fun `plain rename keeps extension`() {
val dir = tmp()
val f = File(dir, "Recording 20260803.m4a").apply { writeText("x") }
val target = LibraryQueue.renameTarget(f, "standup intro").getOrThrow()
assertEquals(File(dir, "standup intro.m4a"), target)
}
@Test fun `typed extension is stripped`() {
val dir = tmp()
val f = File(dir, "a.m4a").apply { writeText("x") }
assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.M4A").getOrThrow())
assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.m4a").getOrThrow())
}
@Test fun `rejects empty path and dot names`() {
val dir = tmp()
val f = File(dir, "a.m4a").apply { writeText("x") }
for (bad in listOf("", " ", "x/y", "x\\y", ".", "..", ".hidden")) {
val r = LibraryQueue.renameTarget(f, bad)
assertTrue("should reject '$bad'", r.isFailure)
}
}
@Test fun `rejects collisions and no-op renames`() {
val dir = tmp()
val f = File(dir, "a.m4a").apply { writeText("x") }
File(dir, "taken.m4a").writeText("y")
assertTrue(LibraryQueue.renameTarget(f, "taken").isFailure)
assertTrue(LibraryQueue.renameTarget(f, "a").isFailure)
}
}