Desktop: multi-select library rows for bulk delete
Checkbox on every row; selecting any reveals a toolbar (N selected, Select all/Clear all, Clear, red Delete N). One confirmation dialog names every file (first 8 + count) before anything goes. deleteFiles() re-checks in-flight state at confirm time and skips anything the pump grabbed since selection (named in the notice) instead of yanking a live transcribe. Selection keys on absolute paths and prunes vanished files on rescan so the count never lies. Selection logic pure + unit-tested (28/28).
This commit is contained in:
parent
a5aedb2b0a
commit
fb1d0e4880
4 changed files with 152 additions and 0 deletions
|
|
@ -459,6 +459,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Selection is user intent across rescans, but a file that no
|
||||||
|
// longer exists must drop out (bulk count + dialog stay honest).
|
||||||
|
if (_selected.value.isNotEmpty()) {
|
||||||
|
val existing = files.map { it.absolutePath }.toSet()
|
||||||
|
_selected.value = _selected.value.intersect(existing)
|
||||||
|
}
|
||||||
startWatcher()
|
startWatcher()
|
||||||
if (_autoTranscribe.value && _connected.value) pumpNewFiles()
|
if (_autoTranscribe.value && _connected.value) pumpNewFiles()
|
||||||
}
|
}
|
||||||
|
|
@ -707,6 +713,41 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
private val _deleteWarning = MutableStateFlow<String?>(null)
|
private val _deleteWarning = MutableStateFlow<String?>(null)
|
||||||
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
|
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete many recordings at once. Files the pump grabbed between
|
||||||
|
* selection and confirm are skipped (named in the return) rather than
|
||||||
|
* yanked mid-transcribe. Returns null when everything went, else a
|
||||||
|
* summary of what was skipped/failed.
|
||||||
|
*/
|
||||||
|
fun deleteFiles(files: List<File>): String? {
|
||||||
|
val (now, waiting) = LibraryQueue.planDelete(files, inFlight.toSet())
|
||||||
|
val failed = now.mapNotNull { deleteFile(it) }
|
||||||
|
val notes = buildList {
|
||||||
|
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") }
|
||||||
|
failed.forEach { add(it) }
|
||||||
|
}
|
||||||
|
_selected.value = emptySet()
|
||||||
|
return notes.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paths selected for bulk actions (rescan-proof: keyed by absolute path). */
|
||||||
|
private val _selected = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val selected: StateFlow<Set<String>> = _selected.asStateFlow()
|
||||||
|
|
||||||
|
fun toggleSelected(path: String) {
|
||||||
|
_selected.value = LibraryQueue.toggleSelection(_selected.value, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAllOrClear(candidatePaths: List<String>) {
|
||||||
|
_selected.value = LibraryQueue.selectOrClearAll(_selected.value, candidatePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() { _selected.value = emptySet() }
|
||||||
|
|
||||||
|
/** Selected files that still exist on disk, in list order. */
|
||||||
|
fun selectedFiles(): List<File> =
|
||||||
|
_entries.value.filter { it.file.absolutePath in _selected.value }.map { it.file }
|
||||||
|
|
||||||
fun setQuery(q: String) {
|
fun setQuery(q: String) {
|
||||||
_query.value = q
|
_query.value = q
|
||||||
val needle = q.trim().lowercase()
|
val needle = q.trim().lowercase()
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,25 @@ object LibraryQueue {
|
||||||
fun deleteSet(audio: File, report: File, mapping: File): List<File> =
|
fun deleteSet(audio: File, report: File, mapping: File): List<File> =
|
||||||
listOf(audio, report, mapping).filter { it.exists() }
|
listOf(audio, report, mapping).filter { it.exists() }
|
||||||
|
|
||||||
|
// ---- multi-select (paths as keys; survives rescans) ---------------------
|
||||||
|
|
||||||
|
fun toggleSelection(current: Set<String>, path: String): Set<String> =
|
||||||
|
if (path in current) current - path else current + path
|
||||||
|
|
||||||
|
/** Select-all toggle: everything selected already → clear; else select
|
||||||
|
* all [candidates]. */
|
||||||
|
fun selectOrClearAll(current: Set<String>, candidates: List<String>): Set<String> =
|
||||||
|
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
|
||||||
|
else candidates.toSet()
|
||||||
|
|
||||||
|
/** Split a selection into what can go now vs. files that must wait
|
||||||
|
* because the pump has them in flight. */
|
||||||
|
fun planDelete(
|
||||||
|
selected: List<File>,
|
||||||
|
inFlightNames: Set<String>,
|
||||||
|
): Pair<List<File>, List<File>> =
|
||||||
|
selected.partition { it.name !in inFlightNames }
|
||||||
|
|
||||||
/** 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(".") &&
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
@ -93,8 +94,10 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
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 deleting by remember { mutableStateOf<File?>(null) }
|
||||||
|
var deletingMany by remember { mutableStateOf(false) }
|
||||||
var deleteNotice by remember { mutableStateOf<String?>(null) }
|
var deleteNotice by remember { mutableStateOf<String?>(null) }
|
||||||
val deleteWarning by state.deleteWarning.collectAsState()
|
val deleteWarning by state.deleteWarning.collectAsState()
|
||||||
|
val selected by state.selected.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) {
|
||||||
|
|
@ -133,6 +136,31 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
OutlinedTextField(query, { state.setQuery(it) },
|
OutlinedTextField(query, { state.setQuery(it) },
|
||||||
label = { Text("Search saved transcripts") },
|
label = { Text("Search saved transcripts") },
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth())
|
singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (selected.isNotEmpty() && query.isBlank()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("${selected.size} selected",
|
||||||
|
style = MaterialTheme.typography.titleSmall)
|
||||||
|
TextButton({
|
||||||
|
state.selectAllOrClear(entries.map { it.file.absolutePath })
|
||||||
|
}) {
|
||||||
|
val allIn = entries.isNotEmpty() &&
|
||||||
|
selected.containsAll(entries.map { it.file.absolutePath })
|
||||||
|
Text(if (allIn) "Clear all" else "Select all")
|
||||||
|
}
|
||||||
|
TextButton({ state.clearSelection() }) { Text("Clear") }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = { deletingMany = true },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("Delete ${selected.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (query.isNotBlank()) {
|
if (query.isNotBlank()) {
|
||||||
if (hits.isEmpty()) {
|
if (hits.isEmpty()) {
|
||||||
Text("No matches.", style = MaterialTheme.typography.bodySmall)
|
Text("No matches.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
@ -169,9 +197,14 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
items(entries, key = { it.file.absolutePath }) { e ->
|
items(entries, key = { it.file.absolutePath }) { e ->
|
||||||
|
val isSelected = e.file.absolutePath in selected
|
||||||
Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) {
|
Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp),
|
Row(Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { state.toggleSelected(e.file.absolutePath) },
|
||||||
|
)
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(e.file.nameWithoutExtension,
|
Text(e.file.nameWithoutExtension,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
|
@ -300,6 +333,37 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deletingMany) {
|
||||||
|
val victims = state.selectedFiles()
|
||||||
|
val names = victims.take(8).joinToString("\n") {
|
||||||
|
"• " + it.nameWithoutExtension
|
||||||
|
} + (if (victims.size > 8) "\n• …and ${victims.size - 8} more" else "")
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deletingMany = false },
|
||||||
|
title = { Text("Delete ${victims.size} recordings?") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(names, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Saved transcripts go with them; transcribed " +
|
||||||
|
"files are also removed from the engine. " +
|
||||||
|
"Anything mid-transcribe is skipped, not killed.",
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton({
|
||||||
|
deleteNotice = state.deleteFiles(victims)
|
||||||
|
deletingMany = false
|
||||||
|
}) { Text("Delete ${victims.size}",
|
||||||
|
color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton({ deletingMany = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,34 @@ class LibraryQueueTest {
|
||||||
).isEmpty())
|
).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun toggleSelectionAddsThenRemoves() {
|
||||||
|
val a = LibraryQueue.toggleSelection(emptySet(), "/x/a.m4a")
|
||||||
|
assertEquals(setOf("/x/a.m4a"), a)
|
||||||
|
assertEquals(emptySet<String>(), LibraryQueue.toggleSelection(a, "/x/a.m4a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun selectAllOrClearToggles() {
|
||||||
|
val all = listOf("/x/a.m4a", "/x/b.m4a")
|
||||||
|
assertEquals(all.toSet(), LibraryQueue.selectOrClearAll(emptySet(), all))
|
||||||
|
// everything already selected (plus a stale extra) -> clear
|
||||||
|
assertEquals(emptySet<String>(),
|
||||||
|
LibraryQueue.selectOrClearAll((all + "/gone.m4a").toSet(), all))
|
||||||
|
// partial -> full select
|
||||||
|
assertEquals(all.toSet(),
|
||||||
|
LibraryQueue.selectOrClearAll(setOf("/x/a.m4a"), all))
|
||||||
|
assertEquals(emptySet<String>(),
|
||||||
|
LibraryQueue.selectOrClearAll(emptySet(), emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun planDeleteHoldsBackInFlightFiles() {
|
||||||
|
val dir = createTempDirectory("lqp").toFile()
|
||||||
|
val a = File(dir, "a.m4a").apply { writeText("x") }
|
||||||
|
val b = File(dir, "b.m4a").apply { writeText("x") }
|
||||||
|
val (now, waiting) = LibraryQueue.planDelete(listOf(a, b), setOf("b.m4a"))
|
||||||
|
assertEquals(listOf(a), now)
|
||||||
|
assertEquals(listOf(b), waiting)
|
||||||
|
}
|
||||||
|
|
||||||
@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