From 81dc7519a83f1a23e7abcafa1cf3743c22c869ec Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:11:45 +0200 Subject: [PATCH] feat(server): add POST /api/archives/:archive_id/captures/text - Add CaptureTextBody struct for title, body, and optional MIME type - Implement capture_text_handler with validation for empty fields and MIME type - Route text submissions to perform_text_capture() in background - Reuse existing capture job tracking and polling infrastructure - Default MIME type to text/markdown when not specified - Include route tests covering happy path, validation, auth, and error cases Co-Authored-By: Claude Haiku 4.5 --- crates/archivr-server/src/routes.rs | 242 ++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index ec8b474..8f52a4f 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -275,6 +275,7 @@ pub fn app_with_state(state: AppState) -> Router { .route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob)) .route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/captures", post(capture_handler)) + .route("/api/archives/:archive_id/captures/text", post(capture_text_handler)) .route( "/api/archives/:archive_id/uploads", post(upload_handler) @@ -922,6 +923,14 @@ struct CaptureBody { sync: bool, } +#[derive(Debug, serde::Deserialize)] +struct CaptureTextBody { + title: String, + body: String, + /// MIME type: "text/plain" or "text/markdown" (defaults to "text/markdown") + mime: Option, +} + #[derive(Debug, serde::Deserialize)] struct ProbeQuery { locator: String, @@ -1187,6 +1196,102 @@ async fn capture_handler( )) } +async fn capture_text_handler( + State(state): State, + auth_user: AuthUser, + Path(archive_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_USER)?; + + // Validate title and body + if body.title.trim().is_empty() { + return Err(ApiError::bad_request("title must not be empty")); + } + if body.body.trim().is_empty() { + return Err(ApiError::bad_request("body must not be empty")); + } + + // Determine MIME type (default to markdown) + let mime = body.mime.as_deref().unwrap_or("text/markdown"); + + // Validate MIME type + if mime != "text/plain" && mime != "text/markdown" { + return Err(ApiError::bad_request( + "unsupported MIME type: must be 'text/plain' or 'text/markdown'" + )); + } + + let mounted = mounted_archive(&state, &archive_id)?; + let archive_paths = + archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; + + // Create job record in the archive DB. + let conn = database::open_or_initialize(&mounted.archive_path)?; + let job_uid = database::create_capture_job(&conn, &archive_id)?; + drop(conn); + + // Spawn background text capture. + let title = body.title.trim().to_string(); + let text_body = body.body.trim().to_string(); + let mime_str = mime.to_string(); + let job_uid_bg = job_uid.clone(); + let archive_path = mounted.archive_path.clone(); + let archive_id_bg = archive_id.clone(); + + tokio::task::spawn_blocking(move || { + let conn = match database::open_or_initialize(&archive_path) { + Ok(c) => c, + Err(e) => { + eprintln!("warn: capture job {job_uid_bg}: db open failed: {e:#}"); + return; + } + }; + database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok(); + + match capture::perform_text_capture( + &archive_paths, + &title, + &text_body, + &mime_str, + Some(&archive_id_bg), + ) { + Ok(result) => { + let job_status = if result.status == "completed" { + "completed" + } else { + "failed" + }; + database::update_capture_job_status( + &conn, + &job_uid_bg, + job_status, + Some(&result.run_uid), + None, + None, + ) + .ok(); + } + Err(e) => { + database::update_capture_job_status( + &conn, + &job_uid_bg, + "failed", + None, + Some(&format!("{e:#}")), + None, + ) + .ok(); + } + } + }); + + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ "job_uid": job_uid, "status": "pending" })), + )) +} + async fn upload_handler( State(state): State, auth_user: AuthUser, @@ -3680,6 +3785,143 @@ mod tests { ); } + #[tokio::test] + async fn text_capture_post_returns_accepted_with_job_uid() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"Test content","mime":"text/markdown"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + json["job_uid"].as_str().is_some(), + "response must have job_uid" + ); + assert_eq!(json["status"], "pending"); + } + + #[tokio::test] + async fn text_capture_rejects_empty_title() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"","body":"Test content","mime":"text/plain"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("title must not be empty"))); + } + + #[tokio::test] + async fn text_capture_rejects_empty_body() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"","mime":"text/plain"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("body must not be empty"))); + } + + #[tokio::test] + async fn text_capture_rejects_unsupported_mime() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .header("cookie", &session_cookie) + .body(Body::from( + r#"{"title":"Test Note","body":"Test content","mime":"text/html"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"] + .as_str() + .is_some_and(|e| e.contains("unsupported MIME type"))); + } + + #[tokio::test] + async fn text_capture_requires_auth() { + let (test_app, _dir) = make_test_app(); + let response = test_app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/captures/text") + .header("content-type", "application/json") + .body(Body::from( + r#"{"title":"Test","body":"Content"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn probe_requires_auth() { let (test_app, _dir) = make_test_app();