diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index ef63a8c..7289cca 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -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(null) + val deleteWarning: StateFlow = _deleteWarning.asStateFlow() + fun setQuery(q: String) { _query.value = q val needle = q.trim().lowercase() diff --git a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index ea12cc0..4c33394 100644 --- a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -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 = + listOf(audio, report, mapping).filter { it.exists() } + /** Audio candidates: regular files, known extension, not hidden. */ fun isAudioCandidate(f: File, audioExts: Set): Boolean = f.isFile && !f.name.startsWith(".") && diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index de4066b..557f19b 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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(null) } + var deleting by remember { mutableStateOf(null) } + var deleteNotice by remember { mutableStateOf(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(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) + } + } } } diff --git a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt index 6e7b673..24744c9 100644 --- a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -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") }