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:
parent
86e18c6b52
commit
9906a5af00
4 changed files with 138 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(".") &&
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
43
desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt
Normal file
43
desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue