Files
doctate/server/src/analyze/recovery.rs
T
Brummel 0b63d8ff56 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.
2026-04-15 23:06:31 +02:00

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);
}
}