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) {
|
fun setQuery(q: String) {
|
||||||
_query.value = q
|
_query.value = q
|
||||||
val needle = q.trim().lowercase()
|
val needle = q.trim().lowercase()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,33 @@ class LibraryDiff(
|
||||||
|
|
||||||
object LibraryQueue {
|
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. */
|
/** Audio candidates: regular files, known extension, not hidden. */
|
||||||
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
||||||
f.isFile && !f.name.startsWith(".") &&
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
|
@ -89,6 +90,7 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
val query by state.query.collectAsState()
|
val query by state.query.collectAsState()
|
||||||
val hits by state.searchResults.collectAsState()
|
val hits by state.searchResults.collectAsState()
|
||||||
val connected by state.connected.collectAsState()
|
val connected by state.connected.collectAsState()
|
||||||
|
var renaming by remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -202,12 +204,48 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
|
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
|
||||||
TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") }
|
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") }
|
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