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.
109 lines
3.7 KiB
Rust
109 lines
3.7 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use tracing::{info, warn};
|
|
|
|
use super::{AnalyzeJob, AnalyzeSender};
|
|
|
|
/// Walk `data_path/*/*/analysis_input_v*.json` and enqueue every input
|
|
/// that does not yet have a matching `document_v*.md` sibling.
|
|
///
|
|
/// Intended to run once at startup. Send is synchronous on the unbounded
|
|
/// channel — only fails if the worker is gone, which is a programming error.
|
|
pub async fn scan_and_enqueue(data_path: &Path, tx: &AnalyzeSender) {
|
|
let pending = collect_pending(data_path).await;
|
|
let total = pending.len();
|
|
info!(count = total, "Analyze recovery scan found pending inputs");
|
|
|
|
for (case_dir, version) in pending {
|
|
if tx.send(AnalyzeJob { case_dir: case_dir.clone(), version }).is_err() {
|
|
warn!(case = %case_dir.display(), "Recovery send failed (worker gone)");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns `(case_dir, version)` tuples for every `analysis_input_v{N}.json`
|
|
/// whose `document_v{N}.md` is missing.
|
|
async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> {
|
|
let mut out: Vec<(PathBuf, u32)> = Vec::new();
|
|
|
|
let mut users = match tokio::fs::read_dir(data_path).await {
|
|
Ok(r) => r,
|
|
Err(_) => return out,
|
|
};
|
|
|
|
while let Ok(Some(user_entry)) = users.next_entry().await {
|
|
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
|
|
Ok(r) => r,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
|
let case_dir = case_entry.path();
|
|
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,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
while let Ok(Some(file)) = files.next_entry().await {
|
|
let path = file.path();
|
|
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
|
continue;
|
|
};
|
|
let Some(version) = parse_input_version(name) else {
|
|
continue;
|
|
};
|
|
let document = case_dir.join(format!("document_v{version}.md"));
|
|
if !document.exists() {
|
|
out.push((case_dir.clone(), version));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Oldest case directories first — not strictly required for correctness,
|
|
// but gives deterministic recovery order. Cases are UUIDs so sorting is
|
|
// purely lexicographic, which is fine.
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// Parse `analysis_input_v{N}.json` and return `N` (as u32) if the pattern
|
|
/// matches exactly. Rejects malformed names, leading zeros, and overflow.
|
|
fn parse_input_version(filename: &str) -> Option<u32> {
|
|
let rest = filename.strip_prefix("analysis_input_v")?;
|
|
let num = rest.strip_suffix(".json")?;
|
|
if num.is_empty() || (num.starts_with('0') && num.len() > 1) {
|
|
return None;
|
|
}
|
|
num.parse::<u32>().ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_valid_names() {
|
|
assert_eq!(parse_input_version("analysis_input_v1.json"), Some(1));
|
|
assert_eq!(parse_input_version("analysis_input_v42.json"), Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_names() {
|
|
assert_eq!(parse_input_version("analysis_input_v.json"), None);
|
|
assert_eq!(parse_input_version("analysis_input_v01.json"), None);
|
|
assert_eq!(parse_input_version("analysis_input_vx.json"), None);
|
|
assert_eq!(parse_input_version("document_v1.md"), None);
|
|
assert_eq!(parse_input_version("analysis_input_v1.txt"), None);
|
|
assert_eq!(parse_input_version(""), None);
|
|
}
|
|
}
|