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:
parent
c25d3ab150
commit
a5aedb2b0a
4 changed files with 168 additions and 0 deletions
|
|
@ -646,6 +646,67 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
return null
|
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) {
|
fun setQuery(q: String) {
|
||||||
_query.value = q
|
_query.value = q
|
||||||
val needle = q.trim().lowercase()
|
val needle = q.trim().lowercase()
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,15 @@ object LibraryQueue {
|
||||||
return Result.success(target)
|
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. */
|
/** 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(".") &&
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
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) }
|
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)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -219,6 +222,16 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
TextButton({ renaming = e.file }) { Text("Rename") }
|
TextButton({ renaming = e.file }) { Text("Rename") }
|
||||||
}
|
}
|
||||||
TextButton({ state.openDetail(e.file) }) { Text("Open") }
|
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
|
val models = state.models.collectAsState().value
|
||||||
var showModels by remember { mutableStateOf(false) }
|
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)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -276,6 +322,13 @@ fun DetailScreen(state: DesktopState) {
|
||||||
Text(detail.file.nameWithoutExtension,
|
Text(detail.file.nameWithoutExtension,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.weight(1f))
|
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.
|
// Model override: "Use default" or a specific size.
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -427,6 +480,34 @@ fun DetailScreen(state: DesktopState) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,23 @@ class LibraryQueueTest {
|
||||||
assertEquals(setOf("gone.m4a"), d.removed)
|
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() {
|
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
|
||||||
val dir = createTempDirectory("lq").toFile()
|
val dir = createTempDirectory("lq").toFile()
|
||||||
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue