1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: browser capture button (Track 4)

- Add archivr-core/src/capture.rs: Source enum, all capture helpers,
  fail_run (replaces process::exit fail_archive_and_exit), and
  perform_capture() as the public entry point
- Slim archivr-cli/src/main.rs to 103 lines: thin adapter over
  archivr_core::capture::perform_capture
- Move all source-classification and tweet-entry tests from CLI to
  archivr-core/src/capture.rs (they test core logic, belong in core)
- Add POST /api/archives/:archive_id/captures route in archivr-server:
  validates non-empty locator (400), resolves archive (404), delegates
  to perform_capture, returns {run_uid, status}
- Add capture dialog to browser UI: dialog markup in index.html,
  showModal/submit/cancel/loading/error wiring in app.js, dialog
  styles in styles.css
- 77 tests pass, clean build (0 warnings)
This commit is contained in:
TheGeneralist 2026-06-22 22:18:44 +02:00
parent b70ab4945e
commit 8d0352c2c4
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
7 changed files with 1415 additions and 1227 deletions

View file

@ -1,12 +1,12 @@
use std::{path::PathBuf, sync::Arc};
use archivr_core::{archive, database};
use archivr_core::{archive, capture, database};
use axum::{
Json, Router,
extract::{Path, Query, Request, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get},
routing::{delete, get, post},
};
use tower_http::services::{ServeDir, ServeFile};
use tower::ServiceExt;
@ -44,6 +44,7 @@ pub fn app(registry: ServerRegistry) -> Router {
get(serve_artifact),
)
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler))
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags",
@ -211,6 +212,27 @@ async fn remove_entry_tag_handler(
}
}
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
}
async fn capture_handler(
State(state): State<AppState>,
Path(archive_id): Path<String>,
Json(body): Json<CaptureBody>,
) -> Result<Json<capture::CaptureResult>, ApiError> {
if body.locator.trim().is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
.map_err(ApiError::from)?;
let result = capture::perform_capture(&archive_paths, &body.locator)
.map_err(ApiError::from)?;
Ok(Json(result))
}
fn mounted_archive<'a>(
state: &'a AppState,
archive_id: &str,
@ -892,4 +914,36 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn capture_rejects_empty_locator() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.body(Body::from(r#"{"locator":""}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn capture_rejects_unknown_archive() {
let response = app(ServerRegistry::default())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/nonexistent/captures")
.header("content-type", "application/json")
.body(Body::from(r#"{"locator":"tweet:1234567890"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}