Desktop: delete a recording from the app (library row + detail, with confirm)

Library rows and the detail header get a red Delete action that removes the
audio, its .transcript.md report, and its .shonar.json mapping, then purges
the engine's copy in the background (DELETE ?purge=true). Refuses while a
file is queued/transcribing so the pump can't resurrect it mid-flight; a
failed background purge surfaces as an inline warning instead of blocking
the local delete (stale mappings that 404 are silently fine). Confirmation
dialog on both entry points states exactly what goes. deleteSet() is pure
and unit-tested (25/25 green).
This commit is contained in:
avi 2026-09-15 05:14:20 -05:00
commit a5aedb2b0a
4 changed files with 168 additions and 0 deletions

View file

@ -646,6 +646,67 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
return null
}
/**
* Delete a library recording for good: the audio, its report, and its
* mapping sidecar go now; the server record (when this file was ever
* transcribed) is purged in the background — a failure there surfaces
* on [deleteWarning] rather than blocking the delete. Refuses while
* the file is queued/transcribing — the pump would resurrect it
* mid-flight. Returns null on success or a human-readable refusal.
*/
fun deleteFile(f: File): String? {
_deleteWarning.value = null
if (f.name in inFlight) return "Wait — this file is transcribing."
if (!f.exists()) { rescan(); return "That file is already gone." }
val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId
?: loadMapping(f)?.recordingId
val removals = LibraryQueue.deleteSet(f, reportFile(f), mappingFile(f))
val failed = removals.filter { !it.delete() }
if (f.exists()) {
return "Delete failed: ${failed.joinToString { it.name }} (disk said no)."
}
// Bookkeeping keyed by name — drop every trace so a re-added file
// with the same name starts clean.
failedFiles -= f.name
inFlight -= f.name
queue.remove(f)
_liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) }
if (_detail.value?.file == f) {
pollJob?.cancel()
_detail.value = null
go(Screen.LIBRARY)
}
rescan()
// Server purge in the background (suspend call; the engine is
// local so this lands in milliseconds, but the EDT must not wait).
// NotFound = stale mapping, nothing to warn about.
if (remoteId != null && _connected.value) {
scope.launch {
runCatching {
provider.delete(
com.shonar.provider.RemoteRef(
providerId = com.shonar.provider.ProviderRegistry.CUSTOM_SHONAR_ID,
key = remoteId, etag = null, sizeBytes = f.length(),
)
)
}.onFailure {
if (it !is ProviderError.NotFound) {
_deleteWarning.value = "Deleted locally, but the server " +
"copy couldn't be removed (${it.message ?: "engine error"})."
}
}
}
}
return null
}
/** Non-null when a background server purge failed after a local delete. */
private val _deleteWarning = MutableStateFlow<String?>(null)
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
fun setQuery(q: String) {
_query.value = q
val needle = q.trim().lowercase()

View file

@ -67,6 +67,15 @@ object LibraryQueue {
return Result.success(target)
}
/**
* What to remove from disk when the user deletes a library recording:
* the audio plus its sidecars (report, mapping). Returns only files
* that actually exist, so callers can count real removals. Never
* touches anything outside the audio file's own sidecar set.
*/
fun deleteSet(audio: File, report: File, mapping: File): List<File> =
listOf(audio, report, mapping).filter { it.exists() }
/** Audio candidates: regular files, known extension, not hidden. */
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
f.isFile && !f.name.startsWith(".") &&

View file

@ -92,6 +92,9 @@ fun LibraryScreen(state: DesktopState) {
val hits by state.searchResults.collectAsState()
val connected by state.connected.collectAsState()
var renaming by remember { mutableStateOf<File?>(null) }
var deleting by remember { mutableStateOf<File?>(null) }
var deleteNotice by remember { mutableStateOf<String?>(null) }
val deleteWarning by state.deleteWarning.collectAsState()
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -219,6 +222,16 @@ fun LibraryScreen(state: DesktopState) {
TextButton({ renaming = e.file }) { Text("Rename") }
}
TextButton({ state.openDetail(e.file) }) { Text("Open") }
if (e.status != FileStatus.RUNNING &&
e.status != FileStatus.QUEUED) {
TextButton(
{ deleting = e.file },
content = {
Text("Delete",
color = MaterialTheme.colorScheme.error)
},
)
}
}
}
}
@ -256,6 +269,37 @@ fun LibraryScreen(state: DesktopState) {
},
)
}
deleteNotice?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
deleteWarning?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
deleting?.let { target ->
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete recording?") },
text = {
Text("\"${target.nameWithoutExtension}\" and its saved " +
"transcript will be removed from this folder. If it " +
"was transcribed, the engine's copy is deleted too. " +
"This can't be undone from the app.")
},
confirmButton = {
TextButton({
deleteNotice = state.deleteFile(target)
deleting = null
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton({ deleting = null }) { Text("Cancel") }
},
)
}
}
}
@ -269,6 +313,8 @@ fun DetailScreen(state: DesktopState) {
}
val models = state.models.collectAsState().value
var showModels by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
var deleteError by remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -276,6 +322,13 @@ fun DetailScreen(state: DesktopState) {
Text(detail.file.nameWithoutExtension,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f))
TextButton(
{ confirmDelete = true },
enabled = detail.busy == null,
content = {
Text("Delete", color = MaterialTheme.colorScheme.error)
},
)
}
// Model override: "Use default" or a specific size.
Row(verticalAlignment = Alignment.CenterVertically) {
@ -427,6 +480,34 @@ fun DetailScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete recording?") },
text = {
Text("\"${detail.file.nameWithoutExtension}\" and its saved " +
"transcript will be removed from this folder. If it " +
"was transcribed, the engine's copy is deleted too. " +
"This can't be undone from the app.")
},
confirmButton = {
TextButton({
// deleteFile returns to the library on success;
// a non-null return means it refused — show why.
deleteError = state.deleteFile(detail.file)
confirmDelete = false
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton({ confirmDelete = false }) { Text("Cancel") }
},
)
deleteError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
}
}

View file

@ -30,6 +30,23 @@ class LibraryQueueTest {
assertEquals(setOf("gone.m4a"), d.removed)
}
@Test fun deleteSetReturnsOnlyExistingFiles() {
val dir = createTempDirectory("lqd").toFile()
val audio = File(dir, "a.m4a").apply { writeText("x") }
val report = File(dir, "a.transcript.md").apply { writeText("# a") }
// no mapping sidecar on disk
val set = LibraryQueue.deleteSet(audio, report, File(dir, "a.shonar.json"))
assertEquals(listOf(audio, report), set)
}
@Test fun deleteSetNeverInventsPaths() {
val dir = createTempDirectory("lqz").toFile()
assertTrue(LibraryQueue.deleteSet(
File(dir, "nope.m4a"), File(dir, "nope.transcript.md"),
File(dir, "nope.shonar.json"),
).isEmpty())
}
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
val dir = createTempDirectory("lq").toFile()
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }