diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index f0a35cc..16b3ca6 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -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() diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index 45f5eb1..ea12cc0 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -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 { + 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 = + 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): Boolean = f.isFile && !f.name.startsWith(".") && diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 84d82cc..c116a91 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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(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(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") } + }, + ) + } } } diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt new file mode 100644 index 0000000..e12ae98 --- /dev/null +++ b/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt @@ -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) + } +}