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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user