0b63d8ff56
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.
144 lines
4.9 KiB
Rust
144 lines
4.9 KiB
Rust
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");
|
|
}
|