1use std::fs::{self, File};
22use std::io::{Read, Write};
23use std::path::PathBuf;
24
25use diesel::PgConnection;
26use diesel::prelude::*;
27
28use crate::helpers::{TaskStatus, result_archive_path};
29use crate::models::{Corpus, Service};
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum GroupBy {
35 Month,
38 Severity,
41}
42
43impl GroupBy {
44 pub fn from_key(key: &str) -> Option<Self> {
46 match key {
47 "month" => Some(GroupBy::Month),
48 "severity" => Some(GroupBy::Severity),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, serde::Serialize)]
56pub struct DatasetArchive {
57 pub name: String,
59 pub entries: usize,
61}
62
63#[derive(Debug, serde::Serialize)]
66pub struct DatasetExportOutcome {
67 pub corpus: String,
69 pub service: String,
71 pub group_by: String,
73 pub max_archive_mb: Option<u64>,
76 pub severities: Vec<String>,
78 pub generated_at: String,
80 pub cortex_version: String,
82 pub archives: Vec<DatasetArchive>,
84 pub total_entries: usize,
86 pub skipped: usize,
88}
89
90const EXPORT_PAGE_SIZE: i64 = 10_000;
95
96fn zip_options() -> zip::write::FileOptions<'static, ()> {
98 zip::write::FileOptions::default()
99 .compression_method(zip::CompressionMethod::Deflated)
100 .compression_level(Some(9))
101}
102
103#[allow(clippy::too_many_arguments)] pub fn export_html_dataset(
115 connection: &mut PgConnection,
116 corpus: &Corpus,
117 service: &Service,
118 severities: &[TaskStatus],
119 group_by: GroupBy,
120 max_archive_mb: Option<u64>,
121 out_dir: &PathBuf,
122 mut progress: impl FnMut(&str),
123) -> Result<DatasetExportOutcome, String> {
124 fs::create_dir_all(out_dir).map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?;
125
126 progress("Streaming HTML dataset export...");
127 let max_bytes = max_archive_mb.map(|mb| mb.saturating_mul(1024 * 1024));
133 let mut streamer = ArchiveStreamer::new(out_dir, corpus, &service.name, group_by, max_bytes);
134
135 match group_by {
136 GroupBy::Month => {
140 let raws: Vec<i32> = severities.iter().map(|s| s.raw()).collect();
141 let mut after: Option<String> = None;
142 loop {
143 let page = fetch_entry_page(connection, corpus.id, service.id, &raws, after.as_deref())?;
144 let full = page.len() as i64 == EXPORT_PAGE_SIZE;
145 for entry in &page {
146 streamer.feed(entry, None, &mut progress)?;
147 }
148 after = page.into_iter().next_back();
149 if !full {
150 break;
151 }
152 }
153 },
154 GroupBy::Severity => {
157 for status in severities {
158 let key = status.to_key();
159 let raws = [status.raw()];
160 let mut after: Option<String> = None;
161 loop {
162 let page = fetch_entry_page(connection, corpus.id, service.id, &raws, after.as_deref())?;
163 let full = page.len() as i64 == EXPORT_PAGE_SIZE;
164 for entry in &page {
165 streamer.feed(entry, Some(&key), &mut progress)?;
166 }
167 after = page.into_iter().next_back();
168 if !full {
169 break;
170 }
171 }
172 }
173 },
174 }
175
176 let (archives, total_entries, skipped) = streamer.finish(&mut progress)?;
177
178 let outcome = DatasetExportOutcome {
179 corpus: corpus.name.clone(),
180 service: service.name.clone(),
181 group_by: match group_by {
182 GroupBy::Month => "month",
183 GroupBy::Severity => "severity",
184 }
185 .to_string(),
186 max_archive_mb,
187 severities: severities.iter().map(|s| s.to_key()).collect(),
188 generated_at: chrono::Utc::now().to_rfc3339(),
189 cortex_version: env!("CARGO_PKG_VERSION").to_string(),
190 archives,
191 total_entries,
192 skipped,
193 };
194
195 let manifest_path = out_dir.join(format!("{}-manifest.json", corpus.name));
198 let manifest = serde_json::to_string_pretty(&outcome)
199 .map_err(|e| format!("serializing manifest failed: {e}"))?;
200 fs::write(&manifest_path, manifest)
201 .map_err(|e| format!("writing {} failed: {e}", manifest_path.display()))?;
202 progress(&format!("Wrote {}", manifest_path.display()));
203
204 Ok(outcome)
205}
206
207fn fetch_entry_page(
212 connection: &mut PgConnection,
213 corpus_id: i32,
214 service_id: i32,
215 status_raws: &[i32],
216 after: Option<&str>,
217) -> Result<Vec<String>, String> {
218 use crate::schema::tasks::dsl as t;
219 let mut query = t::tasks
220 .filter(t::corpus_id.eq(corpus_id))
221 .filter(t::service_id.eq(service_id))
222 .filter(t::status.eq_any(status_raws.to_vec()))
223 .select(t::entry)
224 .order(t::entry.asc())
225 .into_boxed();
226 if let Some(cursor) = after {
227 query = query.filter(t::entry.gt(cursor.to_string()));
228 }
229 query
230 .limit(EXPORT_PAGE_SIZE)
231 .load(connection)
232 .map_err(|e| format!("querying export tasks failed: {e}"))
233}
234
235struct OpenArchive {
237 key: String,
239 name: String,
241 zip: zip::ZipWriter<File>,
243 written: usize,
245}
246
247struct ArchiveStreamer<'a> {
256 out_dir: &'a PathBuf,
257 corpus: &'a Corpus,
258 service_name: &'a str,
259 group_by: GroupBy,
260 max_bytes: Option<u64>,
265 cur_base_key: Option<String>,
267 cur_chunk: u32,
269 cur_bytes: u64,
271 open: Option<OpenArchive>,
273 skipping: Option<String>,
275 archives: Vec<DatasetArchive>,
276 total: usize,
277 skipped: usize,
278}
279
280impl<'a> ArchiveStreamer<'a> {
281 fn new(
282 out_dir: &'a PathBuf,
283 corpus: &'a Corpus,
284 service_name: &'a str,
285 group_by: GroupBy,
286 max_bytes: Option<u64>,
287 ) -> Self {
288 ArchiveStreamer {
289 out_dir,
290 corpus,
291 service_name,
292 group_by,
293 max_bytes,
294 cur_base_key: None,
295 cur_chunk: 1,
296 cur_bytes: 0,
297 open: None,
298 skipping: None,
299 archives: Vec::new(),
300 total: 0,
301 skipped: 0,
302 }
303 }
304
305 fn chunk_key(&self, base_key: &str, chunk: u32) -> String {
308 match self.max_bytes {
309 Some(_) => format!("{base_key}-{chunk:03}"),
310 None => base_key.to_string(),
311 }
312 }
313
314 fn feed(
319 &mut self,
320 entry: &str,
321 key_override: Option<&str>,
322 progress: &mut impl FnMut(&str),
323 ) -> Result<(), String> {
324 let result_zip = match result_archive_path(entry, self.service_name, self.corpus.sandbox_id()) {
326 Some(path) => path,
327 None => {
328 self.skipped += 1;
329 return Ok(());
330 },
331 };
332 let entry_dir = match result_zip.parent() {
334 Some(dir) => dir,
335 None => {
336 self.skipped += 1;
337 return Ok(());
338 },
339 };
340 let paper = entry_dir.file_name().and_then(|s| s.to_str());
341 let yymm = entry_dir
342 .parent()
343 .and_then(|p| p.file_name())
344 .and_then(|s| s.to_str());
345 let (paper, yymm) = match (paper, yymm) {
346 (Some(p), Some(y)) => (p.to_string(), y.to_string()),
347 _ => {
348 self.skipped += 1;
349 return Ok(());
350 },
351 };
352 let base_key = match key_override {
353 Some(k) => k.to_string(),
354 None => yymm.clone(),
355 };
356 if self.cur_base_key.as_deref() != Some(base_key.as_str()) {
358 self.cur_base_key = Some(base_key.clone());
359 self.cur_chunk = 1;
360 self.cur_bytes = 0;
361 let first = self.chunk_key(&base_key, 1);
362 self.rotate_to(&first, progress)?;
363 }
364 let internal = match self.group_by {
367 GroupBy::Month => format!("{yymm}/{paper}.html"),
368 GroupBy::Severity => format!("{base_key}/{yymm}/{paper}.html"),
369 };
370 let html = match extract_main_html(&result_zip, &paper) {
371 Some(bytes) => bytes,
372 None => {
373 if self.open.is_some() {
376 self.skipped += 1;
377 }
378 return Ok(());
379 },
380 };
381 let size = html.len() as u64;
386 if let Some(cap) = self.max_bytes
387 && self.cur_bytes > 0
388 && self.cur_bytes + size > cap
389 {
390 self.cur_chunk += 1;
391 self.cur_bytes = 0;
392 let next = self.chunk_key(&base_key, self.cur_chunk);
393 self.rotate_to(&next, progress)?;
394 }
395 self.cur_bytes += size;
396 let Some(open) = self.open.as_mut() else {
397 return Ok(());
398 };
399 open
400 .zip
401 .start_file(internal, zip_options())
402 .map_err(|e| format!("zip start_file failed: {e}"))?;
403 open
404 .zip
405 .write_all(&html)
406 .map_err(|e| format!("zip write failed: {e}"))?;
407 open.written += 1;
408 self.total += 1;
409 Ok(())
410 }
411
412 fn rotate_to(&mut self, key: &str, progress: &mut impl FnMut(&str)) -> Result<(), String> {
415 let active = self
416 .open
417 .as_ref()
418 .map(|o| o.key.as_str())
419 .or(self.skipping.as_deref());
420 if active == Some(key) {
421 return Ok(());
422 }
423 self.close_open(progress)?;
424 self.skipping = None;
425 let name = format!("{}-{key}.zip", self.corpus.name);
426 let path = self.out_dir.join(&name);
427 if path.exists() {
428 progress(&format!(" {name} exists — skipping"));
430 self.skipping = Some(key.to_string());
431 } else {
432 let tmp_path = self.out_dir.join(format!("{name}.partial"));
438 let file = File::create(&tmp_path)
439 .map_err(|e| format!("cannot create {}: {e}", tmp_path.display()))?;
440 self.open = Some(OpenArchive {
441 key: key.to_string(),
442 name,
443 zip: zip::ZipWriter::new(file),
444 written: 0,
445 });
446 }
447 Ok(())
448 }
449
450 fn close_open(&mut self, progress: &mut impl FnMut(&str)) -> Result<(), String> {
452 if let Some(open) = self.open.take() {
453 let file = open
454 .zip
455 .finish()
456 .map_err(|e| format!("finalizing {} failed: {e}", open.name))?;
457 drop(file); let tmp_path = self.out_dir.join(format!("{}.partial", open.name));
461 let final_path = self.out_dir.join(&open.name);
462 fs::rename(&tmp_path, &final_path)
463 .map_err(|e| format!("publishing {} failed: {e}", open.name))?;
464 progress(&format!(" {}: {} document(s)", open.name, open.written));
465 self.archives.push(DatasetArchive {
466 name: open.name,
467 entries: open.written,
468 });
469 }
470 Ok(())
471 }
472
473 fn finish(
475 mut self,
476 progress: &mut impl FnMut(&str),
477 ) -> Result<(Vec<DatasetArchive>, usize, usize), String> {
478 self.close_open(progress)?;
479 Ok((self.archives, self.total, self.skipped))
480 }
481}
482
483fn extract_main_html(result_zip: &PathBuf, paper: &str) -> Option<Vec<u8>> {
488 let file = File::open(result_zip).ok()?;
489 let mut archive = zip::ZipArchive::new(file).ok()?;
490 let preferred = format!("{paper}.html");
492 let mut best: Option<usize> = None;
493 for i in 0..archive.len() {
494 let entry = archive.by_index(i).ok()?;
495 let name = entry.name();
496 if name.ends_with(".html") && !name.contains('/') {
498 if name == preferred {
499 best = Some(i);
500 break;
501 }
502 best.get_or_insert(i);
503 }
504 }
505 let index = best?;
506 let mut entry = archive.by_index(index).ok()?;
507 let mut bytes = Vec::new();
508 entry.read_to_end(&mut bytes).ok()?;
509 Some(bytes)
510}
511
512#[cfg(test)]
513mod tests {
514 use super::{GroupBy, extract_main_html};
515 use std::io::Write;
516
517 fn write_zip(path: &std::path::Path, files: &[(&str, &str)]) {
518 let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
519 let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
520 for (name, body) in files {
521 zip.start_file(*name, opts).unwrap();
522 zip.write_all(body.as_bytes()).unwrap();
523 }
524 zip.finish().unwrap();
525 }
526
527 #[test]
528 fn group_by_parses_the_two_modes() {
529 assert_eq!(GroupBy::from_key("month"), Some(GroupBy::Month));
530 assert_eq!(GroupBy::from_key("severity"), Some(GroupBy::Severity));
531 assert!(GroupBy::from_key("yearly").is_none());
532 }
533
534 #[test]
535 fn extract_main_html_prefers_paper_then_root_then_none() {
536 let dir = std::env::temp_dir().join("cortex_export_unit_test");
537 std::fs::create_dir_all(&dir).unwrap();
538
539 let z1 = dir.join("preferred.zip");
541 write_zip(
542 &z1,
543 &[
544 ("assets/nested.html", "NESTED"),
545 ("other.html", "OTHER"),
546 ("0801.1234.html", "MAIN"),
547 ("cortex.log", "Status:conversion:1"),
548 ],
549 );
550 assert_eq!(
551 extract_main_html(&z1, "0801.1234").as_deref(),
552 Some(b"MAIN".as_slice()),
553 "the paper-named root html wins"
554 );
555
556 let z2 = dir.join("fallback.zip");
558 write_zip(&z2, &[("index.html", "ROOT"), ("sub/deep.html", "DEEP")]);
559 assert_eq!(
560 extract_main_html(&z2, "9999.0001").as_deref(),
561 Some(b"ROOT".as_slice()),
562 "a root-level html is used when the paper-named one is absent"
563 );
564
565 let z3 = dir.join("htmlless.zip");
567 write_zip(&z3, &[("sub/only.html", "DEEP"), ("cortex.log", "x")]);
568 assert!(extract_main_html(&z3, "p").is_none());
569
570 assert!(extract_main_html(&dir.join("nope.zip"), "p").is_none());
572
573 std::fs::remove_dir_all(&dir).ok();
574 }
575}