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.
205 lines
6.5 KiB
Rust
205 lines
6.5 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use askama::Template;
|
|
use axum::body::Body;
|
|
use axum::extract::{Path as AxumPath, State};
|
|
use axum::http::header;
|
|
use axum::response::{Html, Response};
|
|
use tracing::warn;
|
|
|
|
use crate::config::Config;
|
|
use crate::error::AppError;
|
|
use crate::routes::user_web::any_document_exists;
|
|
|
|
pub(crate) struct RecordingView {
|
|
pub(crate) filename: String,
|
|
pub(crate) transcript: Option<String>,
|
|
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
|
|
/// after a non-recoverable ffmpeg or whisper error.
|
|
pub(crate) failed: bool,
|
|
}
|
|
|
|
struct CaseView {
|
|
user_slug: String,
|
|
case_id: String,
|
|
case_id_short: String,
|
|
has_document: bool,
|
|
most_recent: String,
|
|
oneliner: Option<String>,
|
|
recordings: Vec<RecordingView>,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "cases.html")]
|
|
struct CasesTemplate {
|
|
cases: Vec<CaseView>,
|
|
}
|
|
|
|
pub async fn handle_case_list(
|
|
State(config): State<Arc<Config>>,
|
|
) -> Result<Html<String>, AppError> {
|
|
let cases = scan_cases(&config.data_path).await;
|
|
let template = CasesTemplate { cases };
|
|
template
|
|
.render()
|
|
.map(Html)
|
|
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
|
}
|
|
|
|
pub async fn handle_audio(
|
|
State(config): State<Arc<Config>>,
|
|
AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
|
|
) -> Result<Response, AppError> {
|
|
validate_user_slug(&user)?;
|
|
uuid::Uuid::parse_str(&case_id)
|
|
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
|
|
validate_filename(&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()));
|
|
}
|
|
|
|
let bytes = tokio::fs::read(&file_path).await?;
|
|
|
|
Response::builder()
|
|
.header(header::CONTENT_TYPE, "audio/mp4")
|
|
.body(Body::from(bytes))
|
|
.map_err(|e| AppError::Internal(e.to_string()))
|
|
}
|
|
|
|
fn validate_user_slug(user: &str) -> Result<(), AppError> {
|
|
if user.is_empty() || user.contains('/') || user.contains('\\') || user.contains("..") {
|
|
return Err(AppError::BadRequest("Invalid user slug".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_filename(filename: &str) -> Result<(), AppError> {
|
|
let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
|
|
if !ok_suffix
|
|
|| filename.contains('/')
|
|
|| filename.contains('\\')
|
|
|| filename.contains("..")
|
|
{
|
|
return Err(AppError::BadRequest("Invalid filename".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Scan the data directory for all cases across all users.
|
|
/// Silently skips invalid entries so a single broken directory does not break the page.
|
|
async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
|
|
let mut cases = Vec::new();
|
|
|
|
let mut users = match tokio::fs::read_dir(data_path).await {
|
|
Ok(r) => r,
|
|
Err(_) => return cases, // data_path may not exist yet
|
|
};
|
|
|
|
while let Ok(Some(user_entry)) = users.next_entry().await {
|
|
let user_slug = match user_entry.file_name().into_string() {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
if validate_user_slug(&user_slug).is_err() {
|
|
continue;
|
|
}
|
|
if !user_entry.path().is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await {
|
|
Ok(r) => r,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
while let Ok(Some(case_entry)) = case_entries.next_entry().await {
|
|
let case_id = match case_entry.file_name().into_string() {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
if uuid::Uuid::parse_str(&case_id).is_err() {
|
|
warn!(case_id = %case_id, "Skipping non-UUID directory");
|
|
continue;
|
|
}
|
|
if !case_entry.path().is_dir() {
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let most_recent = recordings
|
|
.last()
|
|
.map(|r| r.filename.clone())
|
|
.unwrap_or_default();
|
|
let case_id_short = case_id.chars().take(8).collect();
|
|
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
|
|
.await
|
|
.ok()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty());
|
|
let has_document = any_document_exists(&case_path).await;
|
|
|
|
cases.push(CaseView {
|
|
user_slug: user_slug.clone(),
|
|
case_id,
|
|
case_id_short,
|
|
has_document,
|
|
most_recent,
|
|
oneliner,
|
|
recordings,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Most recent case first.
|
|
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
|
|
cases
|
|
}
|
|
|
|
pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
|
|
let mut recordings = Vec::new();
|
|
let mut entries = match tokio::fs::read_dir(case_dir).await {
|
|
Ok(r) => r,
|
|
Err(_) => return recordings,
|
|
};
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let filename = match entry.file_name().into_string() {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
let failed = filename.ends_with(".m4a.failed");
|
|
let is_audio = filename.ends_with(".m4a") || failed;
|
|
if !is_audio {
|
|
continue;
|
|
}
|
|
// Transcript naming uses the original `<ts>.m4a` stem in both cases
|
|
// (failed recordings never produced one, but the lookup still works).
|
|
let stem_path = case_dir.join(filename.trim_end_matches(".failed"));
|
|
let transcript_path = stem_path.with_extension("transcript.txt");
|
|
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
|
|
recordings.push(RecordingView {
|
|
filename,
|
|
transcript,
|
|
failed,
|
|
});
|
|
}
|
|
|
|
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
|
|
recordings
|
|
}
|