Feat: Add bulk actions for case analysis and deletion

Introduces a new `/web/cases/bulk` endpoint to handle multiple case
actions simultaneously.

This change adds the following:
- A new `bulk` module for handling bulk operations.
- The `handle_bulk_action` function in `bulk.rs` to dispatch to specific
  actions.
- `bulk_analyze` and `bulk_delete` functions to process the actions.
- Updates `Cargo.toml` and `Cargo.lock` to include necessary
  dependencies: `form_urlencoded`, `serde_core`, `serde_html_form`, and
  `serde_path_to_error`.
- Modified `axum-extra` features to include `form`.
- Adds checks for deleted cases in `collect_pending` for both analysis
  and transcription recovery.
- Renames and modifies `handle_close_case` to `handle_analyze_case` in
  `case_actions.rs` to better reflect its functionality.
- Adds a new `handle_delete_case` and `handle_undo_delete` to
  `case_actions.rs`.
- Updates `my_cases.html` and `case_detail.html` to support bulk
  actions, including checkboxes, a bulk action bar, and an "Undo last
  delete" feature.
- Modifies `handle_audio` and `scan_cases` in `web.rs` to respect delete
  markers.
- Updates `analyze_test.rs` with new request builders for analyze and
  delete actions.
This commit is contained in:
2026-04-15 23:06:31 +02:00
parent 36b3e5f6c1
commit 0b63d8ff56
12 changed files with 662 additions and 288 deletions
+3
View File
@@ -43,6 +43,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> {
if !case_dir.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_dir).await {
continue;
}
let mut files = match tokio::fs::read_dir(&case_dir).await {
Ok(r) => r,
+143
View File
@@ -0,0 +1,143 @@
use std::sync::Arc;
use axum::extract::State;
use axum::response::Redirect;
use axum_extra::extract::Form;
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::analyze::{AnalyzeJob, AnalyzeSender};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{write_delete_marker, DeleteMarker};
use crate::routes::case_actions::{build_analysis_input, find_latest_document, write_input_create_new};
use crate::routes::user_web::locate_case;
#[derive(Debug, Deserialize)]
pub struct BulkForm {
action: String,
/// Repeated `case_id=<uuid>` form fields. Empty for an empty selection
/// (handled as a no-op).
#[serde(default, rename = "case_id")]
case_ids: Vec<String>,
}
/// POST /web/cases/bulk — apply `action` (analyze | delete) to every
/// selected case. Errors per case are logged but do not abort the batch:
/// a single bad/missing case must not block the rest.
pub async fn handle_bulk_action(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
Form(form): Form<BulkForm>,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
match form.action.as_str() {
"analyze" => bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await,
"delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await,
other => {
warn!(slug = %user.slug, action = %other, "bulk: unknown action");
return Err(AppError::BadRequest("Unbekannte Aktion".into()));
}
}
Ok(Redirect::to("/web/cases"))
}
async fn bulk_analyze(
config: &Config,
analyze_tx: &AnalyzeSender,
slug: &str,
user_root: &std::path::Path,
case_ids: &[String],
) {
if !config.llm_configured() {
warn!(slug = %slug, "bulk-analyze: LLM not configured, skipping all");
return;
}
let mut ok = 0usize;
let mut skipped = 0usize;
for case_id in case_ids {
if uuid::Uuid::parse_str(case_id).is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: invalid case_id");
skipped += 1;
continue;
}
let Some(case_dir) = locate_case(user_root, case_id).await else {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: case not found");
skipped += 1;
continue;
};
let version = find_latest_document(&case_dir)
.await
.map(|(v, _)| v + 1)
.unwrap_or(1);
let input = match build_analysis_input(&case_dir, version).await {
Ok(i) => i,
Err(_) => {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: build_analysis_input failed");
skipped += 1;
continue;
}
};
let input_path = case_dir.join(format!("analysis_input_v{version}.json"));
if write_input_create_new(&input_path, &input).await.is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: write input failed");
skipped += 1;
continue;
}
if analyze_tx
.send(AnalyzeJob {
case_dir: case_dir.clone(),
version,
})
.is_err()
{
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)");
}
ok += 1;
}
info!(slug = %slug, ok, skipped, "bulk-analyze completed");
}
async fn bulk_delete(slug: &str, user_root: &std::path::Path, case_ids: &[String]) {
// One batch UUID + one timestamp shared across the entire bulk action,
// so undo can restore exactly this group.
let batch = uuid::Uuid::new_v4();
let deleted_at = match OffsetDateTime::now_utc().format(&Rfc3339) {
Ok(s) => s,
Err(e) => {
warn!(slug = %slug, error = %e, "bulk-delete: failed to format timestamp");
return;
}
};
let mut ok = 0usize;
let mut skipped = 0usize;
for case_id in case_ids {
if uuid::Uuid::parse_str(case_id).is_err() {
warn!(slug = %slug, case_id = %case_id, "bulk-delete: invalid case_id");
skipped += 1;
continue;
}
let Some(case_dir) = locate_case(user_root, case_id).await else {
warn!(slug = %slug, case_id = %case_id, "bulk-delete: case not found");
skipped += 1;
continue;
};
let marker = DeleteMarker {
batch,
deleted_at: deleted_at.clone(),
};
if let Err(e) = write_delete_marker(&case_dir, &marker).await {
warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-delete: write marker failed");
skipped += 1;
continue;
}
ok += 1;
}
info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed");
}
+164 -85
View File
@@ -14,6 +14,7 @@ use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER};
use crate::routes::user_web::locate_case;
#[derive(Template)]
@@ -24,12 +25,14 @@ struct DocumentTemplate {
content: String,
}
/// POST /web/cases/{case_id}/close
/// POST /web/cases/{case_id}/analyze
///
/// Persist an `analysis_input_v1.json` for the case and enqueue an analyze
/// job. Redirects back to the case detail page where the UI will show
/// "wird analysiert …" until the worker writes `document_v1.md`.
pub async fn handle_close_case(
/// Trigger an LLM analysis for the case. If no document exists yet, writes
/// `analysis_input_v1.json`; otherwise writes `analysis_input_v{N+1}.json`
/// where N is the current latest document version. The worker picks up the
/// new input and produces `document_v{version}.md`. Available to all logged-in
/// users (no admin check). Race protection via `create_new` on the input file.
pub async fn handle_analyze_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
@@ -38,7 +41,6 @@ pub async fn handle_close_case(
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
// Server-side guard mirroring the UI gate: no close without a configured LLM.
if !config.llm_configured() {
return Err(AppError::ServiceUnavailable(
"LLM-Analyse nicht konfiguriert".into(),
@@ -49,20 +51,18 @@ pub async fn handle_close_case(
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "close: case not found");
warn!(slug = %user.slug, case_id = %case_id, "analyze: case not found");
return Err(AppError::NotFound("Case not found".into()));
}
};
let input_path = case_dir.join("analysis_input_v1.json");
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
return Err(AppError::Conflict(
"Analyse läuft bereits für diesen Fall".into(),
));
}
let input = build_analysis_input(&case_dir, 1).await?;
let version = find_latest_document(&case_dir)
.await
.map(|(v, _)| v + 1)
.unwrap_or(1);
let input = build_analysis_input(&case_dir, version).await?;
let input_path = case_dir.join(format!("analysis_input_v{version}.json"));
write_input_create_new(&input_path, &input).await?;
// Unbounded send: only fails if the worker has gone away (programmer error
@@ -71,7 +71,7 @@ pub async fn handle_close_case(
if analyze_tx
.send(AnalyzeJob {
case_dir: case_dir.clone(),
version: 1,
version,
})
.is_err()
{
@@ -81,76 +81,9 @@ pub async fn handle_close_case(
info!(
slug = %user.slug,
case_id = %case_id,
version,
recordings = input.recordings.len(),
"close requested; analysis enqueued"
);
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
}
/// POST /web/cases/{case_id}/reanalyze
///
/// Admin-only. Build a fresh `analysis_input_v{N+1}.json` from the current
/// transcripts and enqueue a new analyze job. `find_latest_document` picks the
/// currently-highest N; the worker will write `document_v{N+1}.md`, which then
/// wins the "highest version" race and becomes the displayed document.
pub async fn handle_reanalyze_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
AxumPath(case_id): AxumPath<String>,
) -> Result<Redirect, AppError> {
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
if !user.is_admin() {
return Err(AppError::Forbidden("Admin erforderlich".into()));
}
if !config.llm_configured() {
return Err(AppError::ServiceUnavailable(
"LLM-Analyse nicht konfiguriert".into(),
));
}
let user_root = config.data_path.join(&user.slug);
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found");
return Err(AppError::NotFound("Case not found".into()));
}
};
let current_version = find_latest_document(&case_dir)
.await
.map(|(v, _)| v)
.ok_or_else(|| {
AppError::BadRequest("Kein bestehendes Dokument — nutze 'Fall abschließen'".into())
})?;
let next_version = current_version + 1;
let input = build_analysis_input(&case_dir, next_version).await?;
let input_path = case_dir.join(format!("analysis_input_v{next_version}.json"));
write_input_create_new(&input_path, &input).await?;
if analyze_tx
.send(AnalyzeJob {
case_dir: case_dir.clone(),
version: next_version,
})
.is_err()
{
warn!(case_id = %case_id, "analyze send failed (worker gone)");
}
info!(
slug = %user.slug,
case_id = %case_id,
version = next_version,
recordings = input.recordings.len(),
"reanalyze requested; analysis enqueued"
"analyze requested; analysis enqueued"
);
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
@@ -345,6 +278,152 @@ fn parse_document_version(filename: &str) -> Option<u32> {
num.parse::<u32>().ok()
}
/// POST /web/cases/{case_id}/delete
///
/// Soft-delete: writes a `.deleted` JSON marker into the case directory.
/// The case immediately disappears from listings and detail views (404).
/// Reversible via `handle_undo_delete` while the marker still has the
/// most recent `deleted_at` timestamp among the user's deleted cases.
pub async fn handle_delete_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
AxumPath(case_id): AxumPath<String>,
) -> Result<Redirect, AppError> {
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
let user_root = config.data_path.join(&user.slug);
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "delete: case not found");
return Err(AppError::NotFound("Case not found".into()));
}
};
let marker = DeleteMarker {
batch: uuid::Uuid::new_v4(),
deleted_at: now_rfc3339()?,
};
write_delete_marker(&case_dir, &marker).await?;
info!(
slug = %user.slug,
case_id = %case_id,
batch = %marker.batch,
"case soft-deleted"
);
Ok(Redirect::to("/web/cases"))
}
/// POST /web/cases/undo-delete
///
/// Restore every case in the most recent delete batch by removing its
/// `.deleted` marker. "Most recent" = highest `deleted_at` across all
/// markers under the user's data directory; ties broken by `batch`-UUID
/// lexicographic order. No-op if no markers exist.
pub async fn handle_undo_delete(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let latest = latest_delete_batch(&user_root).await;
let Some((batch, _)) = latest else {
info!(slug = %user.slug, "undo-delete: nothing to undo");
return Ok(Redirect::to("/web/cases"));
};
let restored = restore_batch(&user_root, batch).await;
info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed");
Ok(Redirect::to("/web/cases"))
}
/// Summarize the most recent delete batch under `user_root`: returns
/// `(batch_uuid, count)` where count is the number of cases sharing that
/// batch. `None` if no `.deleted` markers exist. Single directory scan.
pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uuid, usize)> {
let (batch, _) = latest_delete_batch(user_root).await?;
let mut count = 0usize;
let mut entries = tokio::fs::read_dir(user_root).await.ok()?;
while let Ok(Some(entry)) = entries.next_entry().await {
let case_path = entry.path();
if !case_path.is_dir() {
continue;
}
if let Some(marker) = read_delete_marker(&case_path).await
&& marker.batch == batch
{
count += 1;
}
}
Some((batch, count))
}
/// Scan the user's data dir, return the `(batch, deleted_at)` of the
/// most-recently-deleted case across all markers. `None` if no markers.
async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
let mut entries = tokio::fs::read_dir(user_root).await.ok()?;
let mut best: Option<(uuid::Uuid, String)> = None;
while let Ok(Some(entry)) = entries.next_entry().await {
if !entry.path().is_dir() {
continue;
}
let Some(marker) = read_delete_marker(&entry.path()).await else {
continue;
};
let candidate = (marker.batch, marker.deleted_at);
best = match best {
None => Some(candidate),
// Order: deleted_at desc, then batch desc as tie-breaker.
Some(ref cur)
if candidate.1 > cur.1
|| (candidate.1 == cur.1 && candidate.0 > cur.0) =>
{
Some(candidate)
}
other => other,
};
}
best
}
/// Remove `.deleted` markers from every case whose marker carries `batch`.
/// Returns the number of restored cases.
async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize {
let mut count = 0;
let mut entries = match tokio::fs::read_dir(user_root).await {
Ok(r) => r,
Err(_) => return 0,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let case_path = entry.path();
if !case_path.is_dir() {
continue;
}
let Some(marker) = read_delete_marker(&case_path).await else {
continue;
};
if marker.batch != batch {
continue;
}
if let Err(e) = tokio::fs::remove_file(case_path.join(DELETE_MARKER)).await {
warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker");
continue;
}
count += 1;
}
count
}
fn now_rfc3339() -> Result<String, AppError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format timestamp: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
+10 -4
View File
@@ -1,3 +1,4 @@
mod bulk;
mod case_actions;
mod debug;
mod health;
@@ -21,13 +22,18 @@ pub fn api_router() -> Router<AppState> {
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
.route(
"/web/cases/{case_id}/close",
post(case_actions::handle_close_case),
"/web/cases/{case_id}/analyze",
post(case_actions::handle_analyze_case),
)
.route(
"/web/cases/{case_id}/reanalyze",
post(case_actions::handle_reanalyze_case),
"/web/cases/{case_id}/delete",
post(case_actions::handle_delete_case),
)
.route(
"/web/cases/undo-delete",
post(case_actions::handle_undo_delete),
)
.route("/web/cases/bulk", post(bulk::handle_bulk_action))
.route(
"/web/cases/{case_id}/document",
get(case_actions::handle_document_view),
+48 -30
View File
@@ -16,9 +16,13 @@ struct UserCaseView {
case_id_short: String,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
recordings_count: usize,
analyzing: bool,
has_document: bool,
/// True iff the inline "Analysieren"-button should be enabled.
/// Requires: not currently analyzing, all non-failed recordings
/// transcribed, and an LLM is configured.
can_analyze: bool,
}
#[derive(Template)]
@@ -26,6 +30,9 @@ struct UserCaseView {
struct MyCasesTemplate {
slug: String,
cases: Vec<UserCaseView>,
/// Number of cases that "Undo last delete" would restore. 0 hides the
/// undo button entirely.
undo_count: usize,
}
#[derive(Template)]
@@ -36,30 +43,28 @@ struct CaseDetailTemplate {
case_id_short: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
can_close: bool,
can_analyze: bool,
llm_missing: bool,
analyzing: bool,
has_document: bool,
can_reanalyze: bool,
}
/// Flags derived from filesystem state + config. Priority order
/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`,
/// then `llm_missing`. If none is true, the case is still receiving
/// recordings or has zero transcripts; no action surfaced.
/// Flags derived from filesystem state + config.
/// `can_analyze` covers both first analysis and re-analysis — same precondition
/// (all non-failed recordings transcribed, LLM configured, no analysis in
/// flight). `llm_missing` is shown to the user as an explanation when an
/// analysis would otherwise be possible but no LLM is configured.
struct CaseFlags {
has_document: bool,
analyzing: bool,
can_close: bool,
can_analyze: bool,
llm_missing: bool,
can_reanalyze: bool,
}
async fn compute_flags(
case_dir: &Path,
recordings: &[RecordingView],
llm_configured: bool,
is_admin: bool,
) -> CaseFlags {
let has_document = any_document_exists(case_dir).await;
let analyzing = !has_document
@@ -71,14 +76,13 @@ async fn compute_flags(
let all_transcribed =
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
let transcribed_stage = !has_document && !analyzing && all_transcribed;
let analyzable = !analyzing && all_transcribed;
CaseFlags {
has_document,
analyzing,
can_close: transcribed_stage && llm_configured,
llm_missing: transcribed_stage && !llm_configured,
can_reanalyze: has_document && is_admin && llm_configured,
can_analyze: analyzable && llm_configured,
llm_missing: analyzable && !llm_configured,
}
}
@@ -103,10 +107,16 @@ pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
let cases = scan_user_cases(&config.data_path, &user.slug).await;
let cases = scan_user_cases(&config, &user.slug).await;
let user_root = config.data_path.join(&user.slug);
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await
.map(|(_, n)| n)
.unwrap_or(0);
MyCasesTemplate {
slug: user.slug,
cases,
undo_count,
}
.render()
.map(Html)
@@ -139,13 +149,7 @@ pub async fn handle_case_detail(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let flags = compute_flags(
&case_dir,
&recordings,
config.llm_configured(),
user.is_admin(),
)
.await;
let flags = compute_flags(&case_dir, &recordings, config.llm_configured()).await;
let case_id_short = case_id.chars().take(8).collect();
CaseDetailTemplate {
@@ -154,11 +158,10 @@ pub async fn handle_case_detail(
case_id_short,
oneliner,
recordings,
can_close: flags.can_close,
can_analyze: flags.can_analyze,
llm_missing: flags.llm_missing,
analyzing: flags.analyzing,
has_document: flags.has_document,
can_reanalyze: flags.can_reanalyze,
}
.render()
.map(Html)
@@ -181,9 +184,11 @@ pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::
/// Scan all of the given user's cases. Returned vec is sorted by most
/// recent recording filename (lexicographic ≈ chronological since
/// timestamps are ISO-8601), newest first.
async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
let user_root = data_path.join(slug);
/// timestamps are ISO-8601), newest first. Soft-deleted cases (with
/// `.deleted` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str) -> Vec<UserCaseView> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
let mut cases = Vec::new();
let mut entries = match tokio::fs::read_dir(&user_root).await {
@@ -200,11 +205,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
if !case_entry.path().is_dir() {
let case_path = case_entry.path();
if !case_path.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_path).await {
continue;
}
let case_path = case_entry.path();
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
@@ -214,6 +222,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let recordings_count = recordings.len();
let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
let all_transcribed = non_failed_count > 0
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
let case_id_short = case_id.chars().take(8).collect();
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
@@ -226,15 +242,17 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
&& tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
.await
.unwrap_or(false);
let can_analyze = !analyzing && all_transcribed && llm_configured;
cases.push(UserCaseView {
case_id,
case_id_short,
most_recent,
oneliner,
recordings,
recordings_count,
analyzing,
has_document,
can_analyze,
});
}
+8 -1
View File
@@ -56,7 +56,11 @@ pub async fn handle_audio(
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
validate_filename(&filename)?;
let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&filename);
let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id);
if crate::paths::is_deleted(&case_path).await {
return Err(AppError::NotFound("Audio file not found".into()));
}
let file_path = case_path.join(&filename);
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::NotFound("Audio file not found".into()));
}
@@ -129,6 +133,9 @@ async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
}
let case_path = case_entry.path();
if crate::paths::is_deleted(&case_path).await {
continue;
}
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
+3
View File
@@ -55,6 +55,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> {
if !case_dir.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_dir).await {
continue;
}
let mut files = match tokio::fs::read_dir(&case_dir).await {
Ok(r) => r,