1use std::collections::{HashMap, HashSet};
15
16use diesel::pg::PgConnection;
17use rocket::form::Form;
18use rocket::http::Status;
19use rocket::response::Redirect;
20use rocket::serde::json::Json;
21use rocket::{Route, State};
22use rocket_dyn_templates::Template;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use uuid::Uuid;
26
27use crate::backend::{
28 DatabaseUrl, DbPool, GroupBy, SandboxSelection, create_sandbox, export_html_dataset,
29 from_address, progress_report,
30};
31use crate::concerns::CortexInsertable;
32use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
33use crate::frontend::helpers::{decorate_uri_encodings, uri_escape};
34use crate::frontend::jobs::JobDto;
35use crate::frontend::params::TemplateContext;
36use crate::helpers::TaskStatus;
37use crate::importer::Importer;
38use crate::jobs::{self, JobProgress};
39use crate::models::{Corpus, NewCorpus, Service};
40use rocket_okapi::openapi;
41use schemars::JsonSchema;
42use std::path::PathBuf;
43
44const IMPORT_SERVICE_ID: i32 = 2;
48
49#[derive(Debug, Serialize, JsonSchema)]
51pub struct CorpusDto {
52 pub public_id: String,
55 pub name: String,
57 pub path: String,
59 pub description: String,
61 pub complex: bool,
63 pub document_count: i64,
65 pub parent: Option<String>,
69}
70
71impl CorpusDto {
72 pub fn build(corpus: Corpus, document_count: i64, parent: Option<String>) -> Self {
77 CorpusDto {
78 public_id: corpus.public_id.to_string(),
79 name: corpus.name,
80 path: corpus.path,
81 description: corpus.description,
82 complex: corpus.complex,
83 document_count,
84 parent,
85 }
86 }
87}
88
89#[openapi(tag = "Corpora")]
91#[get("/api/corpora")]
92pub fn api_corpora(pool: &State<DbPool>) -> Json<Vec<CorpusDto>> {
93 let Ok(mut connection) = pool.get() else {
94 return Json(Vec::new());
95 };
96 let corpora = Corpus::all(&mut connection).unwrap_or_default();
97 let counts = Corpus::document_counts(&mut connection);
98 let names_by_id: HashMap<i32, String> = corpora.iter().map(|c| (c.id, c.name.clone())).collect();
100 Json(
101 corpora
102 .into_iter()
103 .map(|corpus| {
104 let count = counts.get(&corpus.id).copied().unwrap_or(0);
105 let parent = corpus
106 .parent_corpus_id
107 .and_then(|pid| names_by_id.get(&pid).cloned());
108 CorpusDto::build(corpus, count, parent)
109 })
110 .collect(),
111 )
112}
113
114#[derive(Debug, Serialize, JsonSchema)]
116pub struct ServiceStatusDto {
117 pub name: String,
119 pub version: f32,
121 pub total: i64,
123 pub no_problem: i64,
125 pub warning: i64,
127 pub error: i64,
129 pub fatal: i64,
131 pub invalid: i64,
133 pub todo: i64,
135}
136
137#[derive(Debug, Serialize, JsonSchema)]
140pub struct SandboxProvenanceDto {
141 pub parent: String,
143 pub filter: String,
145 pub selection: Option<serde_json::Value>,
148}
149
150#[derive(Debug, Serialize, JsonSchema)]
152pub struct CorpusDetailDto {
153 pub name: String,
155 pub path: String,
157 pub description: String,
159 pub complex: bool,
161 pub sandbox: Option<SandboxProvenanceDto>,
163 pub services: Vec<ServiceStatusDto>,
165}
166
167#[openapi(tag = "Corpora")]
169#[get("/api/corpora/<name>")]
170pub fn api_corpus(name: &str, pool: &State<DbPool>) -> Result<Json<CorpusDetailDto>, Status> {
171 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
172 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
173 let services = corpus.select_services(&mut connection).unwrap_or_default();
174 let mut service_status = Vec::new();
175 for service in services {
176 let report = progress_report(&mut connection, corpus.id, service.id);
177 let count = |key: &str| report.get(key).copied().unwrap_or(0.0) as i64;
178 service_status.push(ServiceStatusDto {
179 name: service.name,
180 version: service.version,
181 total: count("total"),
182 no_problem: count("no_problem"),
183 warning: count("warning"),
184 error: count("error"),
185 fatal: count("fatal"),
186 invalid: count("invalid"),
187 todo: count("todo"),
188 });
189 }
190 let sandbox =
191 sandbox_provenance(&corpus, &mut connection).map(|(parent, filter)| SandboxProvenanceDto {
192 parent,
193 filter,
194 selection: corpus.selection.clone(),
195 });
196 Ok(Json(CorpusDetailDto {
197 name: corpus.name,
198 path: corpus.path,
199 description: corpus.description,
200 complex: corpus.complex,
201 sandbox,
202 services: service_status,
203 }))
204}
205
206fn sandbox_provenance(corpus: &Corpus, connection: &mut PgConnection) -> Option<(String, String)> {
210 let parent_id = corpus.parent_corpus_id?;
211 let parent = Corpus::find_by_id(parent_id, connection).ok()?;
212 let filter = corpus
213 .selection
214 .as_ref()
215 .and_then(|value| serde_json::from_value::<SandboxSelection>(value.clone()).ok())
216 .map(|selection| selection.filter_summary())
217 .unwrap_or_default();
218 Some((parent.name, filter))
219}
220
221#[derive(Debug, Deserialize, schemars::JsonSchema)]
223pub struct ImportRequest {
224 pub name: String,
226 pub path: String,
228 pub complex: bool,
230 pub description: Option<String>,
232}
233
234#[rocket_okapi::openapi(tag = "Corpora")]
240#[post("/api/corpora", format = "json", data = "<request>")]
241pub fn import_corpus(
242 request: Json<ImportRequest>,
243 actor: Actor,
244 pool: &State<DbPool>,
245 database_url: &State<DatabaseUrl>,
246) -> Result<(Status, Json<JobDto>), Status> {
247 let request = request.into_inner();
248 let job_uuid = start_import(
249 pool,
250 &database_url.0,
251 &actor.owner,
252 request.name,
253 request.path,
254 request.complex,
255 request.description.unwrap_or_default(),
256 )?;
257 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
258 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
259 Ok((Status::Accepted, Json(JobDto::from(job))))
260}
261
262#[allow(clippy::too_many_arguments)]
266fn start_import(
267 pool: &DbPool,
268 database_url: &str,
269 actor: &str,
270 name: String,
271 path: String,
272 complex: bool,
273 description: String,
274) -> Result<Uuid, Status> {
275 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
276 if name.trim().is_empty() {
279 return Err(Status::BadRequest);
280 }
281 if Corpus::find_by_name(&name, &mut connection).is_ok() {
282 return Err(Status::Conflict);
283 }
284 if !std::fs::metadata(path.trim_end())
289 .map(|meta| meta.is_dir())
290 .unwrap_or(false)
291 {
292 return Err(Status::UnprocessableEntity);
293 }
294 NewCorpus {
295 name: name.clone(),
296 path: path.clone(),
297 complex,
298 description,
299 }
300 .create(&mut connection)
301 .map_err(|_| Status::InternalServerError)?;
302 let corpus =
303 Corpus::find_by_name(&name, &mut connection).map_err(|_| Status::InternalServerError)?;
304 drop(connection);
305
306 let database_url = database_url.to_string();
307 let params = serde_json::json!({ "name": name, "path": path });
308 jobs::spawn_job(
309 pool.clone(),
310 "corpus_import",
311 actor,
312 params,
313 move |progress| run_import(&database_url, corpus, progress),
314 )
315 .map_err(|_| Status::InternalServerError)
316}
317
318#[derive(FromForm)]
320pub struct ImportForm {
321 pub name: String,
323 pub path: String,
325 pub complex: bool,
327 pub description: Option<String>,
329}
330
331#[allow(clippy::result_large_err)]
338#[post("/corpus/import", data = "<form>")]
339pub fn import_corpus_human(
340 form: Form<ImportForm>,
341 session: Option<AdminSession>,
342 pool: &State<DbPool>,
343 database_url: &State<DatabaseUrl>,
344) -> Result<Redirect, Template> {
345 let Some(session) = session else {
346 return Ok(Redirect::to("/admin/login"));
347 };
348 let form = form.into_inner();
349 let description = form.description.clone().unwrap_or_default();
350 match start_import(
351 pool,
352 &database_url.0,
353 &session.owner,
354 form.name.clone(),
355 form.path.clone(),
356 form.complex,
357 description.clone(),
358 ) {
359 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
360 Err(status) => {
363 let message = match status.code {
364 409 => format!(
365 "A corpus named “{}” already exists — choose a different name.",
366 form.name
367 ),
368 422 => format!(
369 "“{}” is not a readable directory on the server — check the path.",
370 form.path
371 ),
372 503 => "The database is temporarily unavailable — please try again.".to_string(),
373 _ => "Could not register the corpus — check the values and try again.".to_string(),
374 };
375 Err(render_corpora_new(
376 Some(&message),
377 &form.name,
378 &form.path,
379 &description,
380 form.complex,
381 ))
382 },
383 }
384}
385
386fn run_import(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
389 let corpus_id = corpus.id;
390 let mut importer = Importer {
391 corpus,
392 backend: from_address(database_url),
393 cwd: Importer::cwd(),
394 active_prefixes: HashSet::new(),
395 };
396 progress.step(0, None, "importing corpus");
397 importer.process().map_err(|error| error.to_string())?;
398 let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
399 progress.step(imported, Some(imported), "import complete");
400 Ok(serde_json::json!({ "imported": imported }))
401}
402
403fn count_service_tasks(connection: &mut PgConnection, corpus: i32, service: i32) -> i32 {
405 use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
406 use diesel::prelude::*;
407 tasks
408 .filter(corpus_id.eq(corpus))
409 .filter(service_id.eq(service))
410 .count()
411 .get_result::<i64>(connection)
412 .unwrap_or(0) as i32
413}
414
415#[derive(Debug, Deserialize, JsonSchema)]
418pub struct ExportRequest {
419 pub out: String,
422 #[serde(default)]
425 pub group_by: Option<String>,
426 #[serde(default)]
429 pub severities: Option<Vec<String>>,
430 #[serde(default)]
434 pub max_archive_mb: Option<u64>,
435}
436
437fn default_export_severities() -> Vec<String> {
440 vec![
441 "no_problem".to_string(),
442 "warning".to_string(),
443 "error".to_string(),
444 ]
445}
446
447#[openapi(tag = "Corpora")]
455#[post(
456 "/api/corpora/<corpus>/services/<service>/export-dataset",
457 format = "json",
458 data = "<request>"
459)]
460pub fn export_dataset(
461 corpus: &str,
462 service: &str,
463 request: Json<ExportRequest>,
464 actor: Actor,
465 pool: &State<DbPool>,
466 database_url: &State<DatabaseUrl>,
467) -> Result<(Status, Json<JobDto>), Status> {
468 let job_uuid = start_export(
469 pool,
470 &database_url.0,
471 &actor.owner,
472 corpus,
473 service,
474 request.into_inner(),
475 )?;
476 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
477 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
478 Ok((Status::Accepted, Json(JobDto::from(job))))
479}
480
481fn start_export(
486 pool: &DbPool,
487 database_url: &str,
488 actor: &str,
489 corpus_name: &str,
490 service_name: &str,
491 request: ExportRequest,
492) -> Result<Uuid, Status> {
493 let group_by_key = request.group_by.unwrap_or_else(|| "month".to_string());
495 let group_by = GroupBy::from_key(&group_by_key).ok_or(Status::UnprocessableEntity)?;
496 let severity_keys = request.severities.unwrap_or_else(default_export_severities);
497 let severities = severity_keys
498 .iter()
499 .map(|key| TaskStatus::from_key(key))
500 .collect::<Option<Vec<_>>>()
501 .ok_or(Status::UnprocessableEntity)?;
502 if severities.is_empty() {
503 return Err(Status::UnprocessableEntity);
504 }
505
506 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
507 let corpus = Corpus::find_by_name(&corpus_name.to_lowercase(), &mut connection)
508 .map_err(|_| Status::NotFound)?;
509 let service = Service::find_by_name(&service_name.to_lowercase(), &mut connection)
510 .map_err(|_| Status::NotFound)?;
511 drop(connection);
512
513 let out = PathBuf::from(request.out);
514 let max_archive_mb = request.max_archive_mb;
515 let database_url = database_url.to_string();
516 let params = serde_json::json!({
517 "corpus": corpus.name,
518 "service": service.name,
519 "out": out.display().to_string(),
520 "group_by": group_by_key,
521 "severities": severity_keys,
522 "max_archive_mb": max_archive_mb,
523 });
524 jobs::spawn_job(
525 pool.clone(),
526 "dataset_export",
527 actor,
528 params,
529 move |progress| {
530 run_export(
531 &database_url,
532 corpus,
533 service,
534 severities,
535 group_by,
536 max_archive_mb,
537 out,
538 progress,
539 )
540 },
541 )
542 .map_err(|_| Status::InternalServerError)
543}
544
545#[allow(clippy::too_many_arguments)] fn run_export(
551 database_url: &str,
552 corpus: Corpus,
553 service: Service,
554 severities: Vec<TaskStatus>,
555 group_by: GroupBy,
556 max_archive_mb: Option<u64>,
557 out: PathBuf,
558 progress: &JobProgress,
559) -> Result<Value, String> {
560 let mut backend = from_address(database_url);
561 let outcome = export_html_dataset(
562 &mut backend.connection,
563 &corpus,
564 &service,
565 &severities,
566 group_by,
567 max_archive_mb,
568 &out,
569 |line| progress.step(0, None, line),
570 )?;
571 let total = outcome.total_entries as i32;
572 progress.step(total, Some(total), "export complete");
573 serde_json::to_value(&outcome).map_err(|error| error.to_string())
574}
575
576#[derive(FromForm)]
578pub struct ExportForm {
579 pub out: String,
581 pub group_by: String,
583 pub severities: Vec<String>,
585 pub max_archive_mb: Option<String>,
588}
589
590fn render_export_form(
595 corpus: &str,
596 service: &str,
597 error: Option<&str>,
598 out: &str,
599 group_by: &str,
600 selected: &[String],
601 max_archive_mb: &str,
602) -> Template {
603 let mut global = HashMap::new();
604 global.insert("title".to_string(), "Export dataset".to_string());
605 global.insert(
606 "description".to_string(),
607 format!("Bundle {corpus} / {service} converted HTML into ZIP archives"),
608 );
609 global.insert("corpus_name".to_string(), corpus.to_string());
610 global.insert("service_name".to_string(), service.to_string());
611 global.insert("out".to_string(), out.to_string());
612 global.insert("group_by".to_string(), group_by.to_string());
613 global.insert("max_archive_mb".to_string(), max_archive_mb.to_string());
614 if let Some(message) = error {
615 global.insert("error".to_string(), message.to_string());
616 }
617 for key in ["no_problem", "warning", "error", "fatal", "invalid"] {
620 let checked = selected.iter().any(|s| s == key);
621 global.insert(format!("sev_{key}_checked"), checked.to_string());
622 }
623 let mut context = TemplateContext {
624 global,
625 is_admin: true,
626 ..TemplateContext::default()
627 };
628 decorate_uri_encodings(&mut context);
629 Template::render("export-dataset", context)
630}
631
632#[allow(clippy::result_large_err)] #[get("/export/<corpus>/<service>")]
639pub fn export_dataset_page(
640 corpus: &str,
641 service: &str,
642 session: Option<AdminSession>,
643 return_to: ReturnTo,
644) -> Result<Template, AdminReject> {
645 require_admin_to(session, &return_to)?;
646 let default_out = format!("/data/datasets/{corpus}-{service}");
647 Ok(render_export_form(
648 corpus,
649 service,
650 None,
651 &default_out,
652 "month",
653 &default_export_severities(),
654 "",
655 ))
656}
657
658#[allow(clippy::result_large_err)]
666#[post("/export/<corpus>/<service>", data = "<form>")]
667pub fn export_dataset_human(
668 corpus: &str,
669 service: &str,
670 form: Form<ExportForm>,
671 session: Option<AdminSession>,
672 pool: &State<DbPool>,
673 database_url: &State<DatabaseUrl>,
674) -> Result<Redirect, Template> {
675 let Some(session) = session else {
676 return Ok(Redirect::to("/admin/login"));
677 };
678 let form = form.into_inner();
679 let request = ExportRequest {
680 out: form.out.clone(),
681 group_by: Some(form.group_by.clone()),
682 severities: Some(form.severities.clone()),
683 max_archive_mb: form
685 .max_archive_mb
686 .as_deref()
687 .map(str::trim)
688 .filter(|s| !s.is_empty())
689 .and_then(|s| s.parse::<u64>().ok()),
690 };
691 match start_export(
692 pool,
693 &database_url.0,
694 &session.owner,
695 corpus,
696 service,
697 request,
698 ) {
699 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
700 Err(status) => {
701 let message = match status.code {
702 404 => format!("Corpus “{corpus}” / service “{service}” not found — check the names."),
703 422 => "Pick a grouping and at least one valid severity.".to_string(),
704 503 => "The database is temporarily unavailable — please try again.".to_string(),
705 _ => "Could not start the export — check the values and try again.".to_string(),
706 };
707 Err(render_export_form(
708 corpus,
709 service,
710 Some(&message),
711 &form.out,
712 &form.group_by,
713 &form.severities,
714 form.max_archive_mb.as_deref().unwrap_or(""),
715 ))
716 },
717 }
718}
719
720#[derive(Debug, Deserialize, JsonSchema)]
724pub struct SandboxRequest {
725 pub name: String,
727 pub service_id: i32,
729 #[serde(default)]
732 pub status: Option<String>,
733 #[serde(default)]
736 pub message_severity: Option<String>,
737 #[serde(default)]
739 pub category: Option<String>,
740 #[serde(default)]
742 pub what: Option<String>,
743 #[serde(default)]
746 pub entry: Option<String>,
747 #[serde(default)]
750 pub max_entries: Option<i64>,
751}
752
753impl From<&SandboxRequest> for SandboxSelection {
754 fn from(request: &SandboxRequest) -> Self {
755 SandboxSelection {
756 service_id: request.service_id,
757 status: request.status.clone(),
758 message_severity: request.message_severity.clone(),
759 category: request.category.clone(),
760 what: request.what.clone(),
761 entry: request.entry.clone(),
762 max_entries: request.max_entries,
763 severity: None,
764 }
765 }
766}
767
768#[rocket_okapi::openapi(tag = "Corpora")]
774#[post("/api/corpora/<parent>/sandbox", format = "json", data = "<request>")]
775pub fn create_sandbox_corpus(
776 parent: &str,
777 request: Json<SandboxRequest>,
778 actor: Actor,
779 pool: &State<DbPool>,
780 database_url: &State<DatabaseUrl>,
781) -> Result<(Status, Json<JobDto>), Status> {
782 let request = request.into_inner();
783 let job_uuid = start_sandbox(pool, &database_url.0, &actor.owner, parent, &request)?;
784 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
785 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
786 Ok((Status::Accepted, Json(JobDto::from(job))))
787}
788
789fn start_sandbox(
792 pool: &DbPool,
793 database_url: &str,
794 actor: &str,
795 parent: &str,
796 request: &SandboxRequest,
797) -> Result<Uuid, Status> {
798 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
799 let parent_corpus =
800 Corpus::find_by_name(parent, &mut connection).map_err(|_| Status::NotFound)?;
801 if request.name.trim().is_empty() {
803 return Err(Status::BadRequest);
804 }
805 if Corpus::find_by_name(&request.name, &mut connection).is_ok() {
806 return Err(Status::Conflict);
807 }
808 drop(connection);
809
810 let database_url = database_url.to_string();
811 let name = request.name.clone();
812 let selection = SandboxSelection::from(request);
813 selection
816 .validate()
817 .map_err(|_| Status::UnprocessableEntity)?;
818 let params = serde_json::json!({
819 "parent": parent, "name": name, "selection": serde_json::to_value(&selection).ok(),
820 });
821 jobs::spawn_job(
822 pool.clone(),
823 "corpus_sandbox",
824 actor,
825 params,
826 move |progress| run_sandbox(&database_url, parent_corpus, name, selection, progress),
827 )
828 .map_err(|_| Status::InternalServerError)
829}
830
831#[derive(FromForm)]
833pub struct SandboxForm {
834 pub name: String,
836 pub service_id: i32,
838 pub status: Option<String>,
840 pub message_severity: Option<String>,
842 pub category: Option<String>,
844 pub what: Option<String>,
846 pub entry: Option<String>,
848 pub max_entries: Option<i64>,
850}
851
852#[post("/corpus/<parent>/sandbox", data = "<form>")]
856pub fn create_sandbox_human(
857 parent: &str,
858 form: Form<SandboxForm>,
859 session: Option<AdminSession>,
860 pool: &State<DbPool>,
861 database_url: &State<DatabaseUrl>,
862) -> Result<Redirect, Status> {
863 let Some(session) = session else {
864 return Ok(Redirect::to("/admin/login"));
865 };
866 let form = form.into_inner();
867 let blank_to_none = |value: Option<String>| value.filter(|text| !text.trim().is_empty());
869 let request = SandboxRequest {
870 name: form.name,
871 service_id: form.service_id,
872 status: blank_to_none(form.status),
873 message_severity: blank_to_none(form.message_severity),
874 category: blank_to_none(form.category),
875 what: blank_to_none(form.what),
876 entry: blank_to_none(form.entry),
877 max_entries: form.max_entries.filter(|n| *n > 0),
879 };
880 if let Err(reason) = SandboxSelection::from(&request).validate() {
884 return Ok(Redirect::to(format!(
885 "/corpus/{}?sandbox_invalid={}",
886 uri_escape(Some(parent.to_string())).unwrap_or_default(),
887 uri_escape(Some(reason)).unwrap_or_default()
888 )));
889 }
890 match start_sandbox(pool, &database_url.0, &session.owner, parent, &request) {
891 Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
892 Err(status) if status == Status::Conflict => Ok(Redirect::to(format!(
895 "/corpus/{}?sandbox_taken={}",
896 uri_escape(Some(parent.to_string())).unwrap_or_default(),
897 uri_escape(Some(request.name)).unwrap_or_default()
898 ))),
899 Err(status) => Err(status),
900 }
901}
902
903fn run_sandbox(
906 database_url: &str,
907 parent: Corpus,
908 name: String,
909 selection: SandboxSelection,
910 progress: &JobProgress,
911) -> Result<Value, String> {
912 progress.step(0, None, &format!("carving sandbox '{name}'"));
913 let mut backend = from_address(database_url);
914 let outcome = create_sandbox(&mut backend.connection, &parent, &name, &selection)
915 .map_err(|error| error.to_string())?;
916 let captured = outcome.entry_count as i32;
917 progress.step(captured, Some(captured), "sandbox created");
918 Ok(serde_json::json!({ "sandbox": outcome.sandbox.name, "entries": outcome.entry_count }))
919}
920
921#[rocket_okapi::openapi(tag = "Corpora")]
925#[post("/api/corpora/<name>/extend")]
926pub fn extend_corpus(
927 name: &str,
928 actor: Actor,
929 pool: &State<DbPool>,
930 database_url: &State<DatabaseUrl>,
931) -> Result<(Status, Json<JobDto>), Status> {
932 let job_uuid = start_extend(pool, &database_url.0, &actor.owner, name)?;
933 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
934 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
935 Ok((Status::Accepted, Json(JobDto::from(job))))
936}
937
938fn start_extend(
941 pool: &DbPool,
942 database_url: &str,
943 actor: &str,
944 name: &str,
945) -> Result<Uuid, Status> {
946 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
947 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
948 if !std::fs::metadata(corpus.path.trim_end())
953 .map(|meta| meta.is_dir())
954 .unwrap_or(false)
955 {
956 return Err(Status::UnprocessableEntity);
957 }
958 drop(connection);
959 let database_url = database_url.to_string();
960 let params = serde_json::json!({ "name": name });
961 jobs::spawn_job(
962 pool.clone(),
963 "corpus_extend",
964 actor,
965 params,
966 move |progress| run_extend(&database_url, corpus, progress),
967 )
968 .map_err(|_| Status::InternalServerError)
969}
970
971#[post("/corpus/<name>/extend")]
975pub fn extend_corpus_human(
976 name: &str,
977 session: Option<AdminSession>,
978 pool: &State<DbPool>,
979 database_url: &State<DatabaseUrl>,
980) -> Result<Redirect, Status> {
981 let Some(session) = session else {
982 return Ok(Redirect::to("/admin/login"));
983 };
984 let uuid = start_extend(pool, &database_url.0, &session.owner, name)?;
985 Ok(Redirect::to(format!("/jobs/{uuid}")))
986}
987
988fn run_extend(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
991 let corpus_id = corpus.id;
992 let mut importer = Importer {
993 corpus,
994 backend: from_address(database_url),
995 cwd: Importer::cwd(),
996 active_prefixes: HashSet::new(),
997 };
998 progress.step(0, None, "extending corpus");
999 importer
1000 .extend_corpus()
1001 .map_err(|error| error.to_string())?;
1002 let services = importer
1003 .corpus
1004 .select_services(&mut importer.backend.connection)
1005 .unwrap_or_default();
1006 for service in services.iter().filter(|service| service.id > 2) {
1007 importer
1008 .backend
1009 .extend_service(service, &importer.corpus)
1010 .map_err(|error| error.to_string())?;
1011 }
1012 let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
1013 progress.step(imported, Some(imported), "extend complete");
1014 Ok(serde_json::json!({ "import_tasks": imported }))
1015}
1016
1017#[rocket_okapi::openapi(tag = "Corpora")]
1025#[post("/api/corpora/<corpus>/services/<service>")]
1026pub fn activate_service(
1027 corpus: &str,
1028 service: &str,
1029 actor: Actor,
1030 pool: &State<DbPool>,
1031 database_url: &State<DatabaseUrl>,
1032) -> Result<(Status, Json<JobDto>), Status> {
1033 let job_uuid = start_activate(pool, &database_url.0, &actor.owner, corpus, service)?;
1034 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1035 let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
1036 Ok((Status::Accepted, Json(JobDto::from(job))))
1037}
1038
1039pub(crate) fn start_activate(
1044 pool: &DbPool,
1045 database_url: &str,
1046 actor: &str,
1047 corpus: &str,
1048 service: &str,
1049) -> Result<Uuid, Status> {
1050 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1051 let corpus_record =
1052 Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1053 let service_record =
1054 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1055 if count_service_tasks(&mut connection, corpus_record.id, service_record.id) > 0 {
1061 return Err(Status::Conflict);
1062 }
1063 drop(connection);
1064 let database_url = database_url.to_string();
1065 let owner = actor.to_string();
1066 let params = serde_json::json!({ "corpus": corpus, "service": service });
1067 jobs::spawn_job(
1068 pool.clone(),
1069 "service_activate",
1070 actor,
1071 params,
1072 move |progress| {
1073 run_activate(
1074 &database_url,
1075 corpus_record,
1076 service_record,
1077 owner,
1078 progress,
1079 )
1080 },
1081 )
1082 .map_err(|_| Status::InternalServerError)
1083}
1084
1085#[derive(FromForm)]
1087pub struct ActivateForm {
1088 pub service: String,
1090}
1091
1092#[post("/corpus/<corpus>/activate", data = "<form>")]
1096pub fn activate_service_human(
1097 corpus: &str,
1098 form: Form<ActivateForm>,
1099 session: Option<AdminSession>,
1100 pool: &State<DbPool>,
1101 database_url: &State<DatabaseUrl>,
1102) -> Result<Redirect, Status> {
1103 let Some(session) = session else {
1104 return Ok(Redirect::to("/admin/login"));
1105 };
1106 let uuid = start_activate(pool, &database_url.0, &session.owner, corpus, &form.service)?;
1107 Ok(Redirect::to(format!("/jobs/{uuid}")))
1108}
1109
1110fn run_activate(
1113 database_url: &str,
1114 corpus: Corpus,
1115 service: Service,
1116 owner: String,
1117 progress: &JobProgress,
1118) -> Result<Value, String> {
1119 let (corpus_id, service_id) = (corpus.id, service.id);
1120 let corpus_name = corpus.name.clone();
1121 let service_name = service.name.clone();
1122 let mut backend = from_address(database_url);
1123 progress.step(
1124 0,
1125 None,
1126 &format!("registering {service_name} on {corpus_name}"),
1127 );
1128 backend
1129 .register_service(
1130 &service,
1131 &corpus,
1132 owner,
1133 format!("Activated service {service_name} on {corpus_name}"),
1134 )
1135 .map_err(|error| error.to_string())?;
1136 let activated = count_service_tasks(&mut backend.connection, corpus_id, service_id);
1137 progress.step(
1138 activated,
1139 Some(activated),
1140 &format!("registered {service_name} on {corpus_name} ({activated} tasks)"),
1141 );
1142 Ok(serde_json::json!({ "tasks": activated, "corpus": corpus_name, "service": service_name }))
1143}
1144
1145#[rocket_okapi::openapi(tag = "Corpora")]
1151#[delete("/api/corpora/<name>?<confirm>")]
1152pub fn delete_corpus(
1153 name: &str,
1154 confirm: Option<&str>,
1155 actor: Actor,
1156 pool: &State<DbPool>,
1157) -> Status {
1158 if confirm != Some(name) {
1159 return Status::BadRequest;
1160 }
1161 let mut connection = match pool.get() {
1162 Ok(connection) => connection,
1163 Err(_) => return Status::ServiceUnavailable,
1164 };
1165 let corpus = match Corpus::find_by_name(name, &mut connection) {
1166 Ok(corpus) => corpus,
1167 Err(_) => return Status::NotFound,
1168 };
1169 match delete_corpus_cascade(&mut connection, corpus) {
1170 Ok(()) => {
1171 tracing::info!(actor = %actor.owner, corpus = name, "corpus deleted via API");
1172 Status::NoContent
1173 },
1174 Err(status) => status,
1175 }
1176}
1177
1178#[derive(FromForm)]
1180pub struct DeleteForm {
1181 pub confirm: String,
1183}
1184
1185#[post("/corpus/<name>/delete", data = "<form>")]
1190pub fn delete_corpus_human(
1191 name: &str,
1192 form: Form<DeleteForm>,
1193 session: Option<AdminSession>,
1194 pool: &State<DbPool>,
1195) -> Result<Redirect, Status> {
1196 if session.is_none() {
1197 return Ok(Redirect::to("/admin/login"));
1198 }
1199 if form.confirm != name {
1200 return Err(Status::BadRequest);
1201 }
1202 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1203 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1204 delete_corpus_cascade(&mut connection, corpus)?;
1205 Ok(Redirect::to(format!("/?deleted={name}")))
1207}
1208
1209#[rocket_okapi::openapi(tag = "Corpora")]
1215#[delete("/api/corpora/<corpus>/services/<service>?<confirm>")]
1216pub fn deactivate_service(
1217 corpus: &str,
1218 service: &str,
1219 confirm: Option<&str>,
1220 actor: Actor,
1221 pool: &State<DbPool>,
1222) -> Status {
1223 if confirm != Some(service) {
1224 return Status::BadRequest;
1225 }
1226 let mut connection = match pool.get() {
1227 Ok(connection) => connection,
1228 Err(_) => return Status::ServiceUnavailable,
1229 };
1230 let corpus_record = match Corpus::find_by_name(corpus, &mut connection) {
1231 Ok(corpus) => corpus,
1232 Err(_) => return Status::NotFound,
1233 };
1234 let service_record = match Service::find_by_name(service, &mut connection) {
1235 Ok(service) => service,
1236 Err(_) => return Status::NotFound,
1237 };
1238 if service_record.id <= IMPORT_SERVICE_ID {
1241 return Status::Forbidden;
1242 }
1243 match service_record.deactivate_from_corpus(&corpus_record, &mut connection) {
1244 Ok(_) => {
1245 tracing::info!(actor = %actor.owner, corpus, service, "service deactivated from corpus via API");
1246 Status::NoContent
1247 },
1248 Err(_) => Status::InternalServerError,
1249 }
1250}
1251
1252#[derive(FromForm)]
1254pub struct DeactivateForm {
1255 pub confirm: String,
1257}
1258
1259#[post("/corpus/<corpus>/services/<service>/deactivate", data = "<form>")]
1264pub fn deactivate_service_human(
1265 corpus: &str,
1266 service: &str,
1267 form: Form<DeactivateForm>,
1268 session: Option<AdminSession>,
1269 pool: &State<DbPool>,
1270) -> Result<Redirect, Status> {
1271 if session.is_none() {
1272 return Ok(Redirect::to("/admin/login"));
1273 }
1274 if form.confirm != service {
1275 return Err(Status::BadRequest);
1276 }
1277 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1278 let corpus_record =
1279 Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1280 let service_record =
1281 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1282 if service_record.id <= IMPORT_SERVICE_ID {
1284 return Err(Status::Forbidden);
1285 }
1286 service_record
1287 .deactivate_from_corpus(&corpus_record, &mut connection)
1288 .map_err(|_| Status::InternalServerError)?;
1289 Ok(Redirect::to(format!(
1290 "/corpus/{corpus}?deactivated={service}"
1291 )))
1292}
1293
1294#[derive(Serialize, JsonSchema)]
1297pub struct SnapshotAckDto {
1298 pub corpus: String,
1300 pub service: String,
1302 pub actor: String,
1304 pub saved: usize,
1306}
1307
1308#[rocket_okapi::openapi(tag = "Management")]
1318#[post("/api/corpora/<corpus>/services/<service>/snapshot")]
1319pub fn snapshot_tasks(
1320 corpus: &str,
1321 service: &str,
1322 actor: Actor,
1323 database_url: &State<DatabaseUrl>,
1324) -> Result<(Status, Json<SnapshotAckDto>), Status> {
1325 let mut backend = from_address(&database_url.0);
1326 let corpus_record =
1327 Corpus::find_by_name(corpus, &mut backend.connection).map_err(|_| Status::NotFound)?;
1328 let service_record =
1329 Service::find_by_name(service, &mut backend.connection).map_err(|_| Status::NotFound)?;
1330 let progress = progress_report(&mut backend.connection, corpus_record.id, service_record.id);
1334 let in_progress =
1335 progress.get("todo").copied().unwrap_or(0.0) + progress.get("queued").copied().unwrap_or(0.0);
1336 if in_progress > 0.0 {
1337 return Err(Status::Conflict);
1338 }
1339 let saved = backend
1340 .save_historical_tasks(&corpus_record, &service_record)
1341 .map_err(|_| Status::InternalServerError)?;
1342 tracing::info!(actor = %actor.owner, corpus, service, saved, "snapshot via API");
1343 Ok((
1344 Status::Accepted,
1345 Json(SnapshotAckDto {
1346 corpus: corpus.to_string(),
1347 service: service.to_string(),
1348 actor: actor.owner,
1349 saved,
1350 }),
1351 ))
1352}
1353
1354fn delete_corpus_cascade(connection: &mut PgConnection, corpus: Corpus) -> Result<(), Status> {
1357 corpus
1360 .destroy(connection)
1361 .map(|_| ())
1362 .map_err(|_| Status::InternalServerError)
1363}
1364
1365#[get("/?<deleted>")]
1373pub fn overview_page(deleted: Option<&str>, pool: &State<DbPool>) -> Result<Template, Status> {
1374 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1375 let counts = Corpus::document_counts(&mut connection);
1376 let all = Corpus::all(&mut connection).unwrap_or_default();
1377 let names_by_id: HashMap<i32, String> = all.iter().map(|c| (c.id, c.name.clone())).collect();
1379 let corpora = all
1380 .iter()
1381 .map(|corpus| {
1382 let mut hash = corpus.to_hash();
1383 if let Some(count) = counts.get(&corpus.id) {
1386 hash.insert(
1387 "document_count".to_string(),
1388 crate::frontend::helpers::group_thousands(*count),
1389 );
1390 }
1391 if let Some(parent) = corpus
1394 .parent_corpus_id
1395 .and_then(|pid| names_by_id.get(&pid))
1396 {
1397 hash.insert("sandbox_parent".to_string(), parent.clone());
1398 }
1399 hash
1400 })
1401 .collect::<Vec<_>>();
1402 let mut global = HashMap::new();
1403 global.insert(
1404 "title".to_string(),
1405 "Overview of available Corpora".to_string(),
1406 );
1407 global.insert(
1408 "description".to_string(),
1409 "An analysis framework for corpora of TeX/LaTeX documents - overview page".to_string(),
1410 );
1411 global.insert("is_landing".to_string(), "true".to_string());
1414 if let Some(name) = deleted {
1417 global.insert("deleted".to_string(), name.to_string());
1418 }
1419 let mut context = TemplateContext {
1420 global,
1421 corpora: Some(corpora),
1422 ..TemplateContext::default()
1423 };
1424 decorate_uri_encodings(&mut context);
1425 Ok(Template::render("overview", context))
1426}
1427
1428#[get("/corpus/<name>?<deactivated>&<sandbox_taken>&<sandbox_invalid>")]
1431pub fn corpus_page(
1432 name: &str,
1433 deactivated: Option<&str>,
1434 sandbox_taken: Option<&str>,
1435 sandbox_invalid: Option<&str>,
1436 session: Option<AdminSession>,
1437 pool: &State<DbPool>,
1438) -> Result<Template, Status> {
1439 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1440 let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1441 let mut global = HashMap::new();
1442 if let Some(service) = deactivated {
1444 global.insert("deactivated".to_string(), service.to_string());
1445 }
1446 if let Some(taken) = sandbox_taken {
1449 global.insert(
1450 "sandbox_error".to_string(),
1451 format!("A corpus named “{taken}” already exists — choose a different sandbox name."),
1452 );
1453 }
1454 if let Some(reason) = sandbox_invalid {
1457 global.insert(
1458 "sandbox_error".to_string(),
1459 format!("That sandbox filter isn't valid: {reason}."),
1460 );
1461 }
1462 global.insert(
1463 "title".to_string(),
1464 format!("Registered services for {}", corpus.name),
1465 );
1466 global.insert(
1467 "description".to_string(),
1468 format!(
1469 "An analysis framework for corpora of TeX/LaTeX documents - registered services for {}",
1470 corpus.name
1471 ),
1472 );
1473 global.insert("corpus_name".to_string(), corpus.name.clone());
1474 global.insert("corpus_description".to_string(), corpus.description.clone());
1475 global.insert("corpus_path".to_string(), corpus.path.clone());
1478 if let Some((parent, filter)) = sandbox_provenance(&corpus, &mut connection) {
1482 global.insert("sandbox_parent".to_string(), parent);
1483 global.insert("sandbox_filter".to_string(), filter);
1484 }
1485 let service_records = corpus.select_services(&mut connection).unwrap_or_default();
1488 let mut services = Vec::with_capacity(service_records.len());
1489 for service in &service_records {
1490 let mut hash = service.to_hash();
1491 let report = progress_report(&mut connection, corpus.id, service.id);
1492 for key in [
1493 "total",
1494 "no_problem",
1495 "warning",
1496 "error",
1497 "fatal",
1498 "invalid",
1499 "todo",
1500 ] {
1501 hash.insert(
1502 key.to_string(),
1503 (report.get(key).copied().unwrap_or(0.0) as i64).to_string(),
1504 );
1505 }
1506 services.push(hash);
1507 }
1508 let all_services = Service::all(&mut connection)
1513 .unwrap_or_default()
1514 .iter()
1515 .filter(|service| {
1516 service.id > IMPORT_SERVICE_ID
1517 && !service_records
1518 .iter()
1519 .any(|activated| activated.id == service.id)
1520 })
1521 .map(Service::to_hash)
1522 .collect::<Vec<_>>();
1523 let mut context = TemplateContext {
1524 global,
1525 services: Some(services),
1526 all_services: Some(all_services),
1527 is_admin: session.is_some(),
1528 ..TemplateContext::default()
1529 };
1530 decorate_uri_encodings(&mut context);
1531 Ok(Template::render("services", context))
1532}
1533
1534#[allow(clippy::result_large_err)] fn render_corpora_new(
1543 error: Option<&str>,
1544 name: &str,
1545 path: &str,
1546 description: &str,
1547 complex: bool,
1548) -> Template {
1549 let mut global = HashMap::new();
1550 global.insert("title".to_string(), "Add a corpus".to_string());
1551 global.insert(
1552 "description".to_string(),
1553 "Register a new corpus and import its documents".to_string(),
1554 );
1555 if let Some(message) = error {
1556 global.insert("error".to_string(), message.to_string());
1557 }
1558 global.insert("name".to_string(), name.to_string());
1559 global.insert("path".to_string(), path.to_string());
1560 global.insert("corpus_description".to_string(), description.to_string());
1561 global.insert("complex".to_string(), complex.to_string());
1562 Template::render(
1563 "corpora-new",
1564 TemplateContext {
1565 global,
1566 is_admin: true,
1567 ..TemplateContext::default()
1568 },
1569 )
1570}
1571
1572#[allow(clippy::result_large_err)] #[get("/corpora/new")]
1575pub fn new_corpus_page(
1576 session: Option<AdminSession>,
1577 return_to: ReturnTo,
1578) -> Result<Template, AdminReject> {
1579 require_admin_to(session, &return_to)?;
1580 Ok(render_corpora_new(None, "", "", "", false))
1581}
1582
1583pub fn routes() -> Vec<Route> {
1585 routes![
1589 import_corpus_human,
1590 extend_corpus_human,
1591 delete_corpus_human,
1592 activate_service_human,
1593 deactivate_service_human,
1594 create_sandbox_human,
1595 export_dataset_page,
1596 export_dataset_human,
1597 overview_page,
1598 new_corpus_page,
1599 corpus_page
1600 ]
1601}