1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

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 <noreply@anthropic.com>
This commit is contained in:
TheGeneralist 2026-08-22 14:11:45 +02:00
parent 6c4994ae04
commit 81dc7519a8
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4

View file

@ -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/blobs/:sha256", get(serve_blob))
.route("/api/archives/:archive_id/runs", get(list_runs)) .route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler)) .route("/api/archives/:archive_id/captures", post(capture_handler))
.route("/api/archives/:archive_id/captures/text", post(capture_text_handler))
.route( .route(
"/api/archives/:archive_id/uploads", "/api/archives/:archive_id/uploads",
post(upload_handler) post(upload_handler)
@ -922,6 +923,14 @@ struct CaptureBody {
sync: bool, 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<String>,
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct ProbeQuery { struct ProbeQuery {
locator: String, locator: String,
@ -1187,6 +1196,102 @@ async fn capture_handler(
)) ))
} }
async fn capture_text_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<CaptureTextBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), 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( async fn upload_handler(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, 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] #[tokio::test]
async fn probe_requires_auth() { async fn probe_requires_auth() {
let (test_app, _dir) = make_test_app(); let (test_app, _dir) = make_test_app();