1use std::collections::{HashMap, HashSet};
15
16use diesel::pg::PgConnection;
17use diesel::result::{DatabaseErrorKind, Error as DieselError};
18use rocket::form::Form;
19use rocket::http::Status;
20use rocket::response::Redirect;
21use rocket::serde::json::Json;
22use rocket::{Route, State};
23use rocket_dyn_templates::Template;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use uuid::Uuid;
27
28use crate::backend::{
29 DatabaseUrl, DbPool, GroupBy, SandboxSelection, create_sandbox, export_html_dataset,
30 from_address, progress_report,
31};
32use crate::concerns::CortexInsertable;
33use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
34use crate::frontend::helpers::{decorate_uri_encodings, uri_escape};
35use crate::frontend::jobs::JobDto;
36use crate::frontend::params::TemplateContext;
37use crate::helpers::TaskStatus;
38use crate::importer::Importer;
39use crate::jobs::{self, JobProgress};
40use crate::models::{Corpus, NewCorpus, Service};
41use rocket_okapi::openapi;
42use schemars::JsonSchema;
43use std::path::PathBuf;
44
45const IMPORT_SERVICE_ID: i32 = 2;
49
50#[derive(Debug, Serialize, JsonSchema)]
52pub struct CorpusDto {
53 pub public_id: String,
56 pub name: String,
58 pub path: String,
60 pub description: String,
62 pub complex: bool,
64 pub document_count: i64,
66 pub parent: Option<String>,
70}
71
72impl CorpusDto {
73 pub fn build(corpus: Corpus, document_count: i64, parent: Option<String>) -> Self {
78 CorpusDto {
79 public_id: corpus.public_id.to_string(),
80 name: corpus.name,
81 path: corpus.path,
82 description: corpus.description,
83 complex: corpus.complex,
84 document_count,
85 parent,
86 }
87 }
88}
89
90#[openapi(tag = "Corpora")]
92#[get("/api/corpora")]
93pub fn api_corpora(pool: &State<DbPool>) -> Json<Vec<CorpusDto>> {
94 let Ok(mut connection) = pool.get() else {
95 return Json(Vec::new());
96 };
97 let corpora = Corpus::all(&mut connection).unwrap_or_default();
98 let counts = Corpus::document_counts(&mut connection);
99 let names_by_id: HashMap<i32, String> = corpora.iter().map(|c| (c.id, c.name.clone())).collect();
101 Json(
102 corpora
103 .into_iter()
104 .map(|corpus| {
105 let count = counts.get(&corpus.id).copied().unwrap_or(0);
106 let parent = corpus
107 .parent_corpus_id
108 .and_then(|pid| names_by_id.get(&pid).cloned());
109 CorpusDto::build(corpus, count, parent)
110 })
111 .collect(),
112 )
113}
114
115#[derive(Debug, Serialize, JsonSchema)]
117pub struct ServiceStatusDto {
118 pub name: String,
120 pub version: f32,
122 pub total: i64,
124 pub no_problem: i64,
126 pub warning: i64,
128 pub error: i64,
130 pub fatal: i64,
132 pub invalid: i64,
134 pub todo: i64,
136}
137
138#[derive(Debug, Serialize, JsonSchema)]
141pub struct SandboxProvenanceDto {
142 pub parent: String,
144 pub filter: String,
146 pub selection: Option<serde_json::Value>,
149}
150
151#[derive(Debug, Serialize, JsonSchema)]
153pub struct CorpusDetailDto {
154 pub name: String,
156 pub path: String,
158 pub description: String,
160 pub complex: bool,
162 pub sandbox: Option<SandboxProvenanceDto>,
164 pub services: Vec<ServiceStatusDto>,
166}
167
168#[openapi(tag = "Corpora")]
170#[get("/api/corpora/<name>")]
171pub fn api_corpus(name: &str, pool: &State<DbPool>) -> Result<Json<CorpusDetailDto>, Status> {
172 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
173 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
174 let services = corpus.select_services(&mut connection).unwrap_or_default();
175 let mut service_status = Vec::new();
176 for service in services {
177 let report = progress_report(&mut connection, corpus.id, service.id);
178 let count = |key: &str| report.get(key).copied().unwrap_or(0.0) as i64;
179 service_status.push(ServiceStatusDto {
180 name: service.name,
181 version: service.version,
182 total: count("total"),
183 no_problem: count("no_problem"),
184 warning: count("warning"),
185 error: count("error"),
186 fatal: count("fatal"),
187 invalid: count("invalid"),
188 todo: count("todo"),
189 });
190 }
191 let sandbox =
192 sandbox_provenance(&corpus, &mut connection).map(|(parent, filter)| SandboxProvenanceDto {
193 parent,
194 filter,
195 selection: corpus.selection.clone(),
196 });
197 Ok(Json(CorpusDetailDto {
198 name: corpus.name,
199 path: corpus.path,
200 description: corpus.description,
201 complex: corpus.complex,
202 sandbox,
203 services: service_status,
204 }))
205}
206
207fn sandbox_provenance(corpus: &Corpus, connection: &mut PgConnection) -> Option<(String, String)> {
211 let parent_id = corpus.parent_corpus_id?;
212 let parent = Corpus::find_by_id(parent_id, connection).ok()?;
213 let filter = corpus
214 .selection
215 .as_ref()
216 .and_then(|value| serde_json::from_value::<SandboxSelection>(value.clone()).ok())
217 .map(|selection| selection.filter_summary())
218 .unwrap_or_default();
219 Some((parent.name, filter))
220}
221
222#[derive(Debug, Deserialize, schemars::JsonSchema)]
224pub struct ImportRequest {
225 pub name: String,
227 pub path: String,
229 pub complex: bool,
231 pub description: Option<String>,
233}
234
235#[rocket_okapi::openapi(tag = "Corpora")]
242#[post("/api/corpora", format = "json", data = "<request>")]
243pub fn import_corpus(
244 request: Json<ImportRequest>,
245 actor: Actor,
246 pool: &State<DbPool>,
247 database_url: &State<DatabaseUrl>,
248) -> Result<(Status, Json<JobDto>), Status> {
249 let request = request.into_inner();
250 let job_uuid = start_import(
251 pool,
252 &database_url.0,
253 &actor.owner,
254 request.name,
255 request.path,
256 request.complex,
257 request.description.unwrap_or_default(),
258 )?;
259 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
260 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
261 Ok((Status::Accepted, Json(JobDto::from(job))))
262}
263
264#[allow(clippy::too_many_arguments)]
269fn start_import(
270 pool: &DbPool,
271 database_url: &str,
272 actor: &str,
273 name: String,
274 path: String,
275 complex: bool,
276 description: String,
277) -> Result<Uuid, Status> {
278 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
279 if name.trim().is_empty() {
282 return Err(Status::BadRequest);
283 }
284 if Corpus::find_by_name(&name, &mut connection).is_ok() {
285 return Err(Status::Conflict);
286 }
287 if !std::fs::metadata(path.trim_end())
292 .map(|meta| meta.is_dir())
293 .unwrap_or(false)
294 {
295 return Err(Status::UnprocessableEntity);
296 }
297 NewCorpus {
298 name: name.clone(),
299 path: path.clone(),
300 complex,
301 description,
302 }
303 .create(&mut connection)
304 .map_err(|error| match error {
305 DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => Status::Conflict,
307 _ => Status::InternalServerError,
308 })?;
309 let corpus =
310 Corpus::find_by_name(&name, &mut connection).map_err(|_| Status::InternalServerError)?;
311 drop(connection);
312
313 let database_url = database_url.to_string();
314 let params = serde_json::json!({ "name": name, "path": path });
315 jobs::spawn_job(
316 pool.clone(),
317 "corpus_import",
318 actor,
319 params,
320 move |progress| run_import(&database_url, corpus, progress),
321 )
322 .map_err(|_| Status::InternalServerError)
323}
324
325#[derive(FromForm)]
327pub struct ImportForm {
328 pub name: String,
330 pub path: String,
332 pub complex: bool,
334 pub description: Option<String>,
336}
337
338#[allow(clippy::result_large_err)]
345#[post("/corpus/import", data = "<form>")]
346pub fn import_corpus_human(
347 form: Form<ImportForm>,
348 session: Option<AdminSession>,
349 pool: &State<DbPool>,
350 database_url: &State<DatabaseUrl>,
351) -> Result<Redirect, Template> {
352 let Some(session) = session else {
353 return Ok(Redirect::to("/admin/login"));
354 };
355 let form = form.into_inner();
356 let description = form.description.clone().unwrap_or_default();
357 match start_import(
358 pool,
359 &database_url.0,
360 &session.owner,
361 form.name.clone(),
362 form.path.clone(),
363 form.complex,
364 description.clone(),
365 ) {
366 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
367 Err(status) => {
370 let message = match status.code {
371 409 => format!(
372 "A corpus named “{}” or a corpus at “{}” already exists — choose a different name or \
373 path.",
374 form.name, form.path
375 ),
376 422 => format!(
377 "“{}” is not a readable directory on the server — check the path.",
378 form.path
379 ),
380 503 => "The database is temporarily unavailable — please try again.".to_string(),
381 _ => "Could not register the corpus — check the values and try again.".to_string(),
382 };
383 Err(render_corpora_new(
384 Some(&message),
385 &form.name,
386 &form.path,
387 &description,
388 form.complex,
389 ))
390 },
391 }
392}
393
394fn run_import(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
397 let corpus_id = corpus.id;
398 let mut importer = Importer {
399 corpus,
400 backend: from_address(database_url),
401 cwd: Importer::cwd(),
402 active_prefixes: HashSet::new(),
403 };
404 progress.step(0, None, "importing corpus");
405 importer.process().map_err(|error| error.to_string())?;
406 let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
407 progress.step(imported, Some(imported), "import complete");
408 Ok(serde_json::json!({ "imported": imported }))
409}
410
411fn count_service_tasks(connection: &mut PgConnection, corpus: i32, service: i32) -> i32 {
413 use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
414 use diesel::prelude::*;
415 tasks
416 .filter(corpus_id.eq(corpus))
417 .filter(service_id.eq(service))
418 .count()
419 .get_result::<i64>(connection)
420 .unwrap_or(0) as i32
421}
422
423#[derive(Debug, Deserialize, JsonSchema)]
426pub struct ExportRequest {
427 pub out: String,
430 #[serde(default)]
433 pub group_by: Option<String>,
434 #[serde(default)]
437 pub severities: Option<Vec<String>>,
438 #[serde(default)]
442 pub max_archive_mb: Option<u64>,
443}
444
445fn default_export_severities() -> Vec<String> {
448 vec![
449 "no_problem".to_string(),
450 "warning".to_string(),
451 "error".to_string(),
452 ]
453}
454
455#[openapi(tag = "Corpora")]
463#[post(
464 "/api/corpora/<corpus>/services/<service>/export-dataset",
465 format = "json",
466 data = "<request>"
467)]
468pub fn export_dataset(
469 corpus: &str,
470 service: &str,
471 request: Json<ExportRequest>,
472 actor: Actor,
473 pool: &State<DbPool>,
474 database_url: &State<DatabaseUrl>,
475) -> Result<(Status, Json<JobDto>), Status> {
476 let job_uuid = start_export(
477 pool,
478 &database_url.0,
479 &actor.owner,
480 corpus,
481 service,
482 request.into_inner(),
483 )?;
484 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
485 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
486 Ok((Status::Accepted, Json(JobDto::from(job))))
487}
488
489fn start_export(
494 pool: &DbPool,
495 database_url: &str,
496 actor: &str,
497 corpus_name: &str,
498 service_name: &str,
499 request: ExportRequest,
500) -> Result<Uuid, Status> {
501 let group_by_key = request.group_by.unwrap_or_else(|| "month".to_string());
503 let group_by = GroupBy::from_key(&group_by_key).ok_or(Status::UnprocessableEntity)?;
504 let severity_keys = request.severities.unwrap_or_else(default_export_severities);
505 let severities = severity_keys
506 .iter()
507 .map(|key| TaskStatus::from_key(key))
508 .collect::<Option<Vec<_>>>()
509 .ok_or(Status::UnprocessableEntity)?;
510 if severities.is_empty() {
511 return Err(Status::UnprocessableEntity);
512 }
513
514 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
515 let corpus = Corpus::find_by_name(&corpus_name.to_lowercase(), &mut connection)
516 .map_err(|_| Status::NotFound)?;
517 let service = Service::find_by_name(&service_name.to_lowercase(), &mut connection)
518 .map_err(|_| Status::NotFound)?;
519 drop(connection);
520
521 let out = PathBuf::from(request.out);
522 let max_archive_mb = request.max_archive_mb;
523 let database_url = database_url.to_string();
524 let params = serde_json::json!({
525 "corpus": corpus.name,
526 "service": service.name,
527 "out": out.display().to_string(),
528 "group_by": group_by_key,
529 "severities": severity_keys,
530 "max_archive_mb": max_archive_mb,
531 });
532 jobs::spawn_job(
533 pool.clone(),
534 "dataset_export",
535 actor,
536 params,
537 move |progress| {
538 run_export(
539 &database_url,
540 corpus,
541 service,
542 severities,
543 group_by,
544 max_archive_mb,
545 out,
546 progress,
547 )
548 },
549 )
550 .map_err(|_| Status::InternalServerError)
551}
552
553#[allow(clippy::too_many_arguments)] fn run_export(
559 database_url: &str,
560 corpus: Corpus,
561 service: Service,
562 severities: Vec<TaskStatus>,
563 group_by: GroupBy,
564 max_archive_mb: Option<u64>,
565 out: PathBuf,
566 progress: &JobProgress,
567) -> Result<Value, String> {
568 let mut backend = from_address(database_url);
569 let outcome = export_html_dataset(
570 &mut backend.connection,
571 &corpus,
572 &service,
573 &severities,
574 group_by,
575 max_archive_mb,
576 &out,
577 |line| progress.step(0, None, line),
578 )?;
579 let total = outcome.total_entries as i32;
580 progress.step(total, Some(total), "export complete");
581 serde_json::to_value(&outcome).map_err(|error| error.to_string())
582}
583
584#[derive(FromForm)]
586pub struct ExportForm {
587 pub out: String,
589 pub group_by: String,
591 pub severities: Vec<String>,
593 pub max_archive_mb: Option<String>,
596}
597
598fn render_export_form(
603 corpus: &str,
604 service: &str,
605 error: Option<&str>,
606 out: &str,
607 group_by: &str,
608 selected: &[String],
609 max_archive_mb: &str,
610) -> Template {
611 let mut global = HashMap::new();
612 global.insert("title".to_string(), "Export dataset".to_string());
613 global.insert(
614 "description".to_string(),
615 format!("Bundle {corpus} / {service} converted HTML into ZIP archives"),
616 );
617 global.insert("corpus_name".to_string(), corpus.to_string());
618 global.insert("service_name".to_string(), service.to_string());
619 global.insert("out".to_string(), out.to_string());
620 global.insert("group_by".to_string(), group_by.to_string());
621 global.insert("max_archive_mb".to_string(), max_archive_mb.to_string());
622 if let Some(message) = error {
623 global.insert("error".to_string(), message.to_string());
624 }
625 for key in ["no_problem", "warning", "error", "fatal", "invalid"] {
628 let checked = selected.iter().any(|s| s == key);
629 global.insert(format!("sev_{key}_checked"), checked.to_string());
630 }
631 let mut context = TemplateContext {
632 global,
633 is_admin: true,
634 ..TemplateContext::default()
635 };
636 decorate_uri_encodings(&mut context);
637 Template::render("export-dataset", context)
638}
639
640#[allow(clippy::result_large_err)] #[get("/export/<corpus>/<service>")]
647pub fn export_dataset_page(
648 corpus: &str,
649 service: &str,
650 session: Option<AdminSession>,
651 return_to: ReturnTo,
652) -> Result<Template, AdminReject> {
653 require_admin_to(session, &return_to)?;
654 let default_out = format!("/data/datasets/{corpus}-{service}");
655 Ok(render_export_form(
656 corpus,
657 service,
658 None,
659 &default_out,
660 "month",
661 &default_export_severities(),
662 "",
663 ))
664}
665
666#[allow(clippy::result_large_err)]
674#[post("/export/<corpus>/<service>", data = "<form>")]
675pub fn export_dataset_human(
676 corpus: &str,
677 service: &str,
678 form: Form<ExportForm>,
679 session: Option<AdminSession>,
680 pool: &State<DbPool>,
681 database_url: &State<DatabaseUrl>,
682) -> Result<Redirect, Template> {
683 let Some(session) = session else {
684 return Ok(Redirect::to("/admin/login"));
685 };
686 let form = form.into_inner();
687 let request = ExportRequest {
688 out: form.out.clone(),
689 group_by: Some(form.group_by.clone()),
690 severities: Some(form.severities.clone()),
691 max_archive_mb: form
693 .max_archive_mb
694 .as_deref()
695 .map(str::trim)
696 .filter(|s| !s.is_empty())
697 .and_then(|s| s.parse::<u64>().ok()),
698 };
699 match start_export(
700 pool,
701 &database_url.0,
702 &session.owner,
703 corpus,
704 service,
705 request,
706 ) {
707 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
708 Err(status) => {
709 let message = match status.code {
710 404 => format!("Corpus “{corpus}” / service “{service}” not found — check the names."),
711 422 => "Pick a grouping and at least one valid severity.".to_string(),
712 503 => "The database is temporarily unavailable — please try again.".to_string(),
713 _ => "Could not start the export — check the values and try again.".to_string(),
714 };
715 Err(render_export_form(
716 corpus,
717 service,
718 Some(&message),
719 &form.out,
720 &form.group_by,
721 &form.severities,
722 form.max_archive_mb.as_deref().unwrap_or(""),
723 ))
724 },
725 }
726}
727
728#[derive(Debug, Deserialize, JsonSchema)]
732pub struct SandboxRequest {
733 pub name: String,
735 pub service_id: i32,
737 #[serde(default)]
740 pub status: Option<String>,
741 #[serde(default)]
744 pub message_severity: Option<String>,
745 #[serde(default)]
747 pub category: Option<String>,
748 #[serde(default)]
750 pub what: Option<String>,
751 #[serde(default)]
754 pub entry: Option<String>,
755 #[serde(default)]
758 pub max_entries: Option<i64>,
759}
760
761impl From<&SandboxRequest> for SandboxSelection {
762 fn from(request: &SandboxRequest) -> Self {
763 SandboxSelection {
764 service_id: request.service_id,
765 status: request.status.clone(),
766 message_severity: request.message_severity.clone(),
767 category: request.category.clone(),
768 what: request.what.clone(),
769 entry: request.entry.clone(),
770 max_entries: request.max_entries,
771 severity: None,
772 }
773 }
774}
775
776#[rocket_okapi::openapi(tag = "Corpora")]
782#[post("/api/corpora/<parent>/sandbox", format = "json", data = "<request>")]
783pub fn create_sandbox_corpus(
784 parent: &str,
785 request: Json<SandboxRequest>,
786 actor: Actor,
787 pool: &State<DbPool>,
788 database_url: &State<DatabaseUrl>,
789) -> Result<(Status, Json<JobDto>), Status> {
790 let request = request.into_inner();
791 let job_uuid = start_sandbox(pool, &database_url.0, &actor.owner, parent, &request)?;
792 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
793 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
794 Ok((Status::Accepted, Json(JobDto::from(job))))
795}
796
797fn start_sandbox(
800 pool: &DbPool,
801 database_url: &str,
802 actor: &str,
803 parent: &str,
804 request: &SandboxRequest,
805) -> Result<Uuid, Status> {
806 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
807 let parent_corpus =
808 Corpus::find_by_name(parent, &mut connection).map_err(|_| Status::NotFound)?;
809 if request.name.trim().is_empty() {
811 return Err(Status::BadRequest);
812 }
813 if Corpus::find_by_name(&request.name, &mut connection).is_ok() {
814 return Err(Status::Conflict);
815 }
816 drop(connection);
817
818 let database_url = database_url.to_string();
819 let name = request.name.clone();
820 let selection = SandboxSelection::from(request);
821 selection
824 .validate()
825 .map_err(|_| Status::UnprocessableEntity)?;
826 let params = serde_json::json!({
827 "parent": parent, "name": name, "selection": serde_json::to_value(&selection).ok(),
828 });
829 jobs::spawn_job(
830 pool.clone(),
831 "corpus_sandbox",
832 actor,
833 params,
834 move |progress| run_sandbox(&database_url, parent_corpus, name, selection, progress),
835 )
836 .map_err(|_| Status::InternalServerError)
837}
838
839#[derive(FromForm)]
841pub struct SandboxForm {
842 pub name: String,
844 pub service_id: i32,
846 pub status: Option<String>,
848 pub message_severity: Option<String>,
850 pub category: Option<String>,
852 pub what: Option<String>,
854 pub entry: Option<String>,
856 pub max_entries: Option<i64>,
858}
859
860#[post("/corpus/<parent>/sandbox", data = "<form>")]
864pub fn create_sandbox_human(
865 parent: &str,
866 form: Form<SandboxForm>,
867 session: Option<AdminSession>,
868 pool: &State<DbPool>,
869 database_url: &State<DatabaseUrl>,
870) -> Result<Redirect, Status> {
871 let Some(session) = session else {
872 return Ok(Redirect::to("/admin/login"));
873 };
874 let form = form.into_inner();
875 let blank_to_none = |value: Option<String>| value.filter(|text| !text.trim().is_empty());
877 let request = SandboxRequest {
878 name: form.name,
879 service_id: form.service_id,
880 status: blank_to_none(form.status),
881 message_severity: blank_to_none(form.message_severity),
882 category: blank_to_none(form.category),
883 what: blank_to_none(form.what),
884 entry: blank_to_none(form.entry),
885 max_entries: form.max_entries.filter(|n| *n > 0),
887 };
888 if let Err(reason) = SandboxSelection::from(&request).validate() {
892 return Ok(Redirect::to(format!(
893 "/corpus/{}?sandbox_invalid={}",
894 uri_escape(Some(parent.to_string())).unwrap_or_default(),
895 uri_escape(Some(reason)).unwrap_or_default()
896 )));
897 }
898 match start_sandbox(pool, &database_url.0, &session.owner, parent, &request) {
899 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
900 Err(status) if status == Status::Conflict => Ok(Redirect::to(format!(
903 "/corpus/{}?sandbox_taken={}",
904 uri_escape(Some(parent.to_string())).unwrap_or_default(),
905 uri_escape(Some(request.name)).unwrap_or_default()
906 ))),
907 Err(status) => Err(status),
908 }
909}
910
911fn run_sandbox(
914 database_url: &str,
915 parent: Corpus,
916 name: String,
917 selection: SandboxSelection,
918 progress: &JobProgress,
919) -> Result<Value, String> {
920 progress.step(0, None, &format!("carving sandbox '{name}'"));
921 let mut backend = from_address(database_url);
922 let outcome = create_sandbox(&mut backend.connection, &parent, &name, &selection)
923 .map_err(|error| error.to_string())?;
924 let captured = outcome.entry_count as i32;
925 progress.step(captured, Some(captured), "sandbox created");
926 Ok(serde_json::json!({ "sandbox": outcome.sandbox.name, "entries": outcome.entry_count }))
927}
928
929#[rocket_okapi::openapi(tag = "Corpora")]
934#[post("/api/corpora/<name>/extend")]
935pub fn extend_corpus(
936 name: &str,
937 actor: Actor,
938 pool: &State<DbPool>,
939 database_url: &State<DatabaseUrl>,
940) -> Result<(Status, Json<JobDto>), Status> {
941 let job_uuid = start_extend(pool, &database_url.0, &actor.owner, name)?;
942 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
943 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
944 Ok((Status::Accepted, Json(JobDto::from(job))))
945}
946
947fn start_extend(
950 pool: &DbPool,
951 database_url: &str,
952 actor: &str,
953 name: &str,
954) -> Result<Uuid, Status> {
955 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
956 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
957 if corpus.parent_corpus_id.is_some() {
960 return Err(Status::Conflict);
961 }
962 if !std::fs::metadata(corpus.path.trim_end())
967 .map(|meta| meta.is_dir())
968 .unwrap_or(false)
969 {
970 return Err(Status::UnprocessableEntity);
971 }
972 drop(connection);
973 let database_url = database_url.to_string();
974 let params = serde_json::json!({ "name": name });
975 jobs::spawn_job(
976 pool.clone(),
977 "corpus_extend",
978 actor,
979 params,
980 move |progress| run_extend(&database_url, corpus, progress),
981 )
982 .map_err(|_| Status::InternalServerError)
983}
984
985#[post("/corpus/<name>/extend")]
989pub fn extend_corpus_human(
990 name: &str,
991 session: Option<AdminSession>,
992 pool: &State<DbPool>,
993 database_url: &State<DatabaseUrl>,
994) -> Result<Redirect, Status> {
995 let Some(session) = session else {
996 return Ok(Redirect::to("/admin/login"));
997 };
998 let uuid = start_extend(pool, &database_url.0, &session.owner, name)?;
999 Ok(Redirect::to(format!("/jobs/{uuid}")))
1000}
1001
1002fn run_extend(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
1005 let corpus_id = corpus.id;
1006 let mut importer = Importer {
1007 corpus,
1008 backend: from_address(database_url),
1009 cwd: Importer::cwd(),
1010 active_prefixes: HashSet::new(),
1011 };
1012 progress.step(0, None, "extending corpus");
1013 importer
1014 .extend_corpus()
1015 .map_err(|error| error.to_string())?;
1016 let services = importer
1017 .corpus
1018 .select_services(&mut importer.backend.connection)
1019 .unwrap_or_default();
1020 for service in services.iter().filter(|service| service.id > 2) {
1021 importer
1022 .backend
1023 .extend_service(service, &importer.corpus)
1024 .map_err(|error| error.to_string())?;
1025 }
1026 let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
1027 progress.step(imported, Some(imported), "extend complete");
1028 Ok(serde_json::json!({ "import_tasks": imported }))
1029}
1030
1031#[rocket_okapi::openapi(tag = "Corpora")]
1039#[post("/api/corpora/<corpus>/services/<service>")]
1040pub fn activate_service(
1041 corpus: &str,
1042 service: &str,
1043 actor: Actor,
1044 pool: &State<DbPool>,
1045 database_url: &State<DatabaseUrl>,
1046) -> Result<(Status, Json<JobDto>), Status> {
1047 let job_uuid = start_activate(pool, &database_url.0, &actor.owner, corpus, service)?;
1048 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1049 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
1050 Ok((Status::Accepted, Json(JobDto::from(job))))
1051}
1052
1053pub(crate) fn start_activate(
1058 pool: &DbPool,
1059 database_url: &str,
1060 actor: &str,
1061 corpus: &str,
1062 service: &str,
1063) -> Result<Uuid, Status> {
1064 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1065 let corpus_record =
1066 Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1067 let service_record =
1068 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1069 if count_service_tasks(&mut connection, corpus_record.id, service_record.id) > 0 {
1075 return Err(Status::Conflict);
1076 }
1077 drop(connection);
1078 let database_url = database_url.to_string();
1079 let owner = actor.to_string();
1080 let params = serde_json::json!({ "corpus": corpus, "service": service });
1081 jobs::spawn_job(
1082 pool.clone(),
1083 "service_activate",
1084 actor,
1085 params,
1086 move |progress| {
1087 run_activate(
1088 &database_url,
1089 corpus_record,
1090 service_record,
1091 owner,
1092 progress,
1093 )
1094 },
1095 )
1096 .map_err(|_| Status::InternalServerError)
1097}
1098
1099#[derive(FromForm)]
1101pub struct ActivateForm {
1102 pub service: String,
1104}
1105
1106#[post("/corpus/<corpus>/activate", data = "<form>")]
1110pub fn activate_service_human(
1111 corpus: &str,
1112 form: Form<ActivateForm>,
1113 session: Option<AdminSession>,
1114 pool: &State<DbPool>,
1115 database_url: &State<DatabaseUrl>,
1116) -> Result<Redirect, Status> {
1117 let Some(session) = session else {
1118 return Ok(Redirect::to("/admin/login"));
1119 };
1120 let uuid = start_activate(pool, &database_url.0, &session.owner, corpus, &form.service)?;
1121 Ok(Redirect::to(format!("/jobs/{uuid}")))
1122}
1123
1124fn run_activate(
1127 database_url: &str,
1128 corpus: Corpus,
1129 service: Service,
1130 owner: String,
1131 progress: &JobProgress,
1132) -> Result<Value, String> {
1133 let (corpus_id, service_id) = (corpus.id, service.id);
1134 let corpus_name = corpus.name.clone();
1135 let service_name = service.name.clone();
1136 let mut backend = from_address(database_url);
1137 progress.step(
1138 0,
1139 None,
1140 &format!("registering {service_name} on {corpus_name}"),
1141 );
1142 backend
1143 .register_service(
1144 &service,
1145 &corpus,
1146 owner,
1147 format!("Activated service {service_name} on {corpus_name}"),
1148 )
1149 .map_err(|error| error.to_string())?;
1150 let activated = count_service_tasks(&mut backend.connection, corpus_id, service_id);
1151 progress.step(
1152 activated,
1153 Some(activated),
1154 &format!("registered {service_name} on {corpus_name} ({activated} tasks)"),
1155 );
1156 Ok(serde_json::json!({ "tasks": activated, "corpus": corpus_name, "service": service_name }))
1157}
1158
1159#[rocket_okapi::openapi(tag = "Corpora")]
1166#[delete("/api/corpora/<name>?<confirm>")]
1167pub fn delete_corpus(
1168 name: &str,
1169 confirm: Option<&str>,
1170 actor: Actor,
1171 pool: &State<DbPool>,
1172) -> Status {
1173 if confirm != Some(name) {
1174 return Status::BadRequest;
1175 }
1176 let mut connection = match pool.get() {
1177 Ok(connection) => connection,
1178 Err(_) => return Status::ServiceUnavailable,
1179 };
1180 let corpus = match Corpus::find_by_name(name, &mut connection) {
1181 Ok(corpus) => corpus,
1182 Err(_) => return Status::NotFound,
1183 };
1184 match delete_corpus_cascade(&mut connection, corpus) {
1185 Ok(()) => {
1186 tracing::info!(actor = %actor.owner, corpus = name, "corpus deleted via API");
1187 Status::NoContent
1188 },
1189 Err(status) => status,
1190 }
1191}
1192
1193#[derive(FromForm)]
1195pub struct DeleteForm {
1196 pub confirm: String,
1198}
1199
1200#[post("/corpus/<name>/delete", data = "<form>")]
1205pub fn delete_corpus_human(
1206 name: &str,
1207 form: Form<DeleteForm>,
1208 session: Option<AdminSession>,
1209 pool: &State<DbPool>,
1210) -> Result<Redirect, Status> {
1211 if session.is_none() {
1212 return Ok(Redirect::to("/admin/login"));
1213 }
1214 if form.confirm != name {
1215 return Err(Status::BadRequest);
1216 }
1217 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1218 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1219 delete_corpus_cascade(&mut connection, corpus)?;
1220 Ok(Redirect::to(format!("/?deleted={name}")))
1222}
1223
1224#[rocket_okapi::openapi(tag = "Corpora")]
1230#[delete("/api/corpora/<corpus>/services/<service>?<confirm>")]
1231pub fn deactivate_service(
1232 corpus: &str,
1233 service: &str,
1234 confirm: Option<&str>,
1235 actor: Actor,
1236 pool: &State<DbPool>,
1237) -> Status {
1238 if confirm != Some(service) {
1239 return Status::BadRequest;
1240 }
1241 let mut connection = match pool.get() {
1242 Ok(connection) => connection,
1243 Err(_) => return Status::ServiceUnavailable,
1244 };
1245 let corpus_record = match Corpus::find_by_name(corpus, &mut connection) {
1246 Ok(corpus) => corpus,
1247 Err(_) => return Status::NotFound,
1248 };
1249 let service_record = match Service::find_by_name(service, &mut connection) {
1250 Ok(service) => service,
1251 Err(_) => return Status::NotFound,
1252 };
1253 if service_record.id <= IMPORT_SERVICE_ID {
1256 return Status::Forbidden;
1257 }
1258 match service_record.deactivate_from_corpus(&corpus_record, &mut connection) {
1259 Ok(_) => {
1260 tracing::info!(actor = %actor.owner, corpus, service, "service deactivated from corpus via API");
1261 Status::NoContent
1262 },
1263 Err(_) => Status::InternalServerError,
1264 }
1265}
1266
1267#[derive(FromForm)]
1269pub struct DeactivateForm {
1270 pub confirm: String,
1272}
1273
1274#[post("/corpus/<corpus>/services/<service>/deactivate", data = "<form>")]
1279pub fn deactivate_service_human(
1280 corpus: &str,
1281 service: &str,
1282 form: Form<DeactivateForm>,
1283 session: Option<AdminSession>,
1284 pool: &State<DbPool>,
1285) -> Result<Redirect, Status> {
1286 if session.is_none() {
1287 return Ok(Redirect::to("/admin/login"));
1288 }
1289 if form.confirm != service {
1290 return Err(Status::BadRequest);
1291 }
1292 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1293 let corpus_record =
1294 Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1295 let service_record =
1296 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1297 if service_record.id <= IMPORT_SERVICE_ID {
1299 return Err(Status::Forbidden);
1300 }
1301 service_record
1302 .deactivate_from_corpus(&corpus_record, &mut connection)
1303 .map_err(|_| Status::InternalServerError)?;
1304 Ok(Redirect::to(format!(
1305 "/corpus/{corpus}?deactivated={service}"
1306 )))
1307}
1308
1309#[derive(Serialize, JsonSchema)]
1312pub struct SnapshotAckDto {
1313 pub corpus: String,
1315 pub service: String,
1317 pub actor: String,
1319 pub saved: usize,
1321}
1322
1323#[rocket_okapi::openapi(tag = "Management")]
1333#[post("/api/corpora/<corpus>/services/<service>/snapshot")]
1334pub fn snapshot_tasks(
1335 corpus: &str,
1336 service: &str,
1337 actor: Actor,
1338 database_url: &State<DatabaseUrl>,
1339) -> Result<(Status, Json<SnapshotAckDto>), Status> {
1340 let mut backend = from_address(&database_url.0);
1341 let corpus_record =
1342 Corpus::find_by_name(corpus, &mut backend.connection).map_err(|_| Status::NotFound)?;
1343 let service_record =
1344 Service::find_by_name(service, &mut backend.connection).map_err(|_| Status::NotFound)?;
1345 let progress = progress_report(&mut backend.connection, corpus_record.id, service_record.id);
1349 let in_progress =
1350 progress.get("todo").copied().unwrap_or(0.0) + progress.get("queued").copied().unwrap_or(0.0);
1351 if in_progress > 0.0 {
1352 return Err(Status::Conflict);
1353 }
1354 let saved = backend
1355 .save_historical_tasks(&corpus_record, &service_record)
1356 .map_err(|_| Status::InternalServerError)?;
1357 tracing::info!(actor = %actor.owner, corpus, service, saved, "snapshot via API");
1358 Ok((
1359 Status::Accepted,
1360 Json(SnapshotAckDto {
1361 corpus: corpus.to_string(),
1362 service: service.to_string(),
1363 actor: actor.owner,
1364 saved,
1365 }),
1366 ))
1367}
1368
1369fn delete_corpus_cascade(connection: &mut PgConnection, corpus: Corpus) -> Result<(), Status> {
1371 corpus
1374 .destroy(connection)
1375 .map(|_| ())
1376 .map_err(|_| Status::InternalServerError)
1377}
1378
1379#[get("/?<deleted>")]
1387pub fn overview_page(deleted: Option<&str>, pool: &State<DbPool>) -> Result<Template, Status> {
1388 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1389 let counts = Corpus::document_counts(&mut connection);
1390 let all = Corpus::all(&mut connection).unwrap_or_default();
1391 let names_by_id: HashMap<i32, String> = all.iter().map(|c| (c.id, c.name.clone())).collect();
1393 let corpora = all
1394 .iter()
1395 .map(|corpus| {
1396 let mut hash = corpus.to_hash();
1397 if let Some(count) = counts.get(&corpus.id) {
1400 hash.insert(
1401 "document_count".to_string(),
1402 crate::frontend::helpers::group_thousands(*count),
1403 );
1404 }
1405 if let Some(parent) = corpus
1408 .parent_corpus_id
1409 .and_then(|pid| names_by_id.get(&pid))
1410 {
1411 hash.insert("sandbox_parent".to_string(), parent.clone());
1412 }
1413 hash
1414 })
1415 .collect::<Vec<_>>();
1416 let mut global = HashMap::new();
1417 global.insert(
1418 "title".to_string(),
1419 "Overview of available Corpora".to_string(),
1420 );
1421 global.insert(
1422 "description".to_string(),
1423 "An analysis framework for corpora of TeX/LaTeX documents - overview page".to_string(),
1424 );
1425 global.insert("is_landing".to_string(), "true".to_string());
1428 if let Some(name) = deleted {
1431 global.insert("deleted".to_string(), name.to_string());
1432 }
1433 let mut context = TemplateContext {
1434 global,
1435 corpora: Some(corpora),
1436 ..TemplateContext::default()
1437 };
1438 decorate_uri_encodings(&mut context);
1439 Ok(Template::render("overview", context))
1440}
1441
1442#[get("/corpus/<name>?<deactivated>&<sandbox_taken>&<sandbox_invalid>")]
1445pub fn corpus_page(
1446 name: &str,
1447 deactivated: Option<&str>,
1448 sandbox_taken: Option<&str>,
1449 sandbox_invalid: Option<&str>,
1450 session: Option<AdminSession>,
1451 pool: &State<DbPool>,
1452) -> Result<Template, Status> {
1453 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1454 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1455 let mut global = HashMap::new();
1456 if let Some(service) = deactivated {
1458 global.insert("deactivated".to_string(), service.to_string());
1459 }
1460 if let Some(taken) = sandbox_taken {
1463 global.insert(
1464 "sandbox_error".to_string(),
1465 format!("A corpus named “{taken}” already exists — choose a different sandbox name."),
1466 );
1467 }
1468 if let Some(reason) = sandbox_invalid {
1471 global.insert(
1472 "sandbox_error".to_string(),
1473 format!("That sandbox filter isn't valid: {reason}."),
1474 );
1475 }
1476 global.insert(
1477 "title".to_string(),
1478 format!("Registered services for {}", corpus.name),
1479 );
1480 global.insert(
1481 "description".to_string(),
1482 format!(
1483 "An analysis framework for corpora of TeX/LaTeX documents - registered services for {}",
1484 corpus.name
1485 ),
1486 );
1487 global.insert("corpus_name".to_string(), corpus.name.clone());
1488 global.insert("corpus_description".to_string(), corpus.description.clone());
1489 global.insert("corpus_path".to_string(), corpus.path.clone());
1492 if let Some((parent, filter)) = sandbox_provenance(&corpus, &mut connection) {
1496 global.insert("sandbox_parent".to_string(), parent);
1497 global.insert("sandbox_filter".to_string(), filter);
1498 }
1499 let sandboxes = corpus.sandboxes(&mut connection).unwrap_or_default();
1501 if !sandboxes.is_empty() {
1502 global.insert("corpus_sandboxes".to_string(), sandboxes.join(", "));
1503 }
1504 let service_records = corpus.select_services(&mut connection).unwrap_or_default();
1507 let mut services = Vec::with_capacity(service_records.len());
1508 for service in &service_records {
1509 let mut hash = service.to_hash();
1510 let report = progress_report(&mut connection, corpus.id, service.id);
1511 for key in [
1512 "total",
1513 "no_problem",
1514 "warning",
1515 "error",
1516 "fatal",
1517 "invalid",
1518 "todo",
1519 ] {
1520 hash.insert(
1521 key.to_string(),
1522 (report.get(key).copied().unwrap_or(0.0) as i64).to_string(),
1523 );
1524 }
1525 services.push(hash);
1526 }
1527 let all_services = Service::all(&mut connection)
1532 .unwrap_or_default()
1533 .iter()
1534 .filter(|service| {
1535 service.id > IMPORT_SERVICE_ID
1536 && !service_records
1537 .iter()
1538 .any(|activated| activated.id == service.id)
1539 })
1540 .map(Service::to_hash)
1541 .collect::<Vec<_>>();
1542 let mut context = TemplateContext {
1543 global,
1544 services: Some(services),
1545 all_services: Some(all_services),
1546 is_admin: session.is_some(),
1547 ..TemplateContext::default()
1548 };
1549 decorate_uri_encodings(&mut context);
1550 Ok(Template::render("services", context))
1551}
1552
1553#[allow(clippy::result_large_err)] fn render_corpora_new(
1562 error: Option<&str>,
1563 name: &str,
1564 path: &str,
1565 description: &str,
1566 complex: bool,
1567) -> Template {
1568 let mut global = HashMap::new();
1569 global.insert("title".to_string(), "Add a corpus".to_string());
1570 global.insert(
1571 "description".to_string(),
1572 "Register a new corpus and import its documents".to_string(),
1573 );
1574 if let Some(message) = error {
1575 global.insert("error".to_string(), message.to_string());
1576 }
1577 global.insert("name".to_string(), name.to_string());
1578 global.insert("path".to_string(), path.to_string());
1579 global.insert("corpus_description".to_string(), description.to_string());
1580 global.insert("complex".to_string(), complex.to_string());
1581 Template::render(
1582 "corpora-new",
1583 TemplateContext {
1584 global,
1585 is_admin: true,
1586 ..TemplateContext::default()
1587 },
1588 )
1589}
1590
1591#[allow(clippy::result_large_err)] #[get("/corpora/new")]
1594pub fn new_corpus_page(
1595 session: Option<AdminSession>,
1596 return_to: ReturnTo,
1597) -> Result<Template, AdminReject> {
1598 require_admin_to(session, &return_to)?;
1599 Ok(render_corpora_new(None, "", "", "", false))
1600}
1601
1602pub fn routes() -> Vec<Route> {
1604 routes![
1608 import_corpus_human,
1609 extend_corpus_human,
1610 delete_corpus_human,
1611 activate_service_human,
1612 deactivate_service_human,
1613 create_sandbox_human,
1614 export_dataset_page,
1615 export_dataset_human,
1616 overview_page,
1617 new_corpus_page,
1618 corpus_page
1619 ]
1620}