use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde_json::{json, Value};
use time::format_description::well_known::Iso8601;
use time::OffsetDateTime;
use zip::write::FileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use crate::boundary::envelope::{DiagnosticCode, Envelope};
use crate::boundary::flat::FlatBundle;
use crate::{CURRENT_SPEC_VERSION, SUPPORTED_SPEC_VERSIONS};
pub const EMBEDDED_SCHEMA: &str = include_str!("../../schema/axgf-1.0.schema.json");
const ENTITY_DIRS: [(&str, &str); 8] = [
("persons", "persons"),
("families", "families"),
("events", "events"),
("links", "links"),
("occupations", "occupations"),
("sources", "sources"),
("places", "places"),
("documents", "documents"),
];
pub(crate) fn now_iso8601_utc() -> String {
OffsetDateTime::now_utc()
.format(&Iso8601::DEFAULT)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
pub(crate) fn check_manifest_version(manifest: &Value) -> Result<(), Envelope> {
let axgf = manifest.get("axgf").and_then(Value::as_str);
match axgf {
Some(v) if SUPPORTED_SPEC_VERSIONS.contains(&v) => Ok(()),
Some(v) => Err(Envelope::error(
DiagnosticCode::UnsupportedSpecVersion,
format!(
"unsupported AXGF spec version {v:?}; this build supports {SUPPORTED_SPEC_VERSIONS:?}"
),
)),
None => Err(Envelope::error(
DiagnosticCode::InvalidBundleStructure,
"manifest.axgf is missing or not a string",
)),
}
}
pub(crate) fn parse_flat(flat_json: &str) -> Result<FlatBundle, Envelope> {
serde_json::from_str::<FlatBundle>(flat_json).map_err(|e| {
Envelope::error(
DiagnosticCode::InvalidJson,
format!("cannot parse flat bundle: {e}"),
)
})
}
pub(crate) fn compute_stats(b: &FlatBundle) -> Value {
json!({
"persons": b.persons.len(),
"families": b.families.len(),
"events": b.events.len(),
"links": b.links.len(),
"occupations": b.occupations.len(),
"sources": b.sources.len(),
"places": b.places.len(),
"documents": b.documents.len(),
})
}
pub fn create_bundle(family_name: Option<&str>) -> Envelope {
let now = now_iso8601_utc();
let mut manifest = json!({
"axgf": CURRENT_SPEC_VERSION,
"created_at": now,
"updated_at": now,
"stats": {
"persons": 0, "families": 0, "events": 0, "links": 0,
"occupations": 0, "sources": 0, "places": 0, "documents": 0
}
});
if let Some(name) = family_name {
manifest["family"] = json!({ "name": name });
}
let bundle = FlatBundle {
manifest,
..Default::default()
};
let value = serde_json::to_value(&bundle).unwrap_or(Value::Null);
Envelope::ok(value)
}
pub fn inspect(flat_json: &str) -> Envelope {
let bundle = match parse_flat(flat_json) {
Ok(b) => b,
Err(env) => return env,
};
if let Err(env) = check_manifest_version(&bundle.manifest) {
return env;
}
let stats = compute_stats(&bundle);
Envelope::ok(json!({
"manifest": bundle.manifest,
"stats": stats,
}))
}
pub fn import_bundle(zip_bytes: &[u8]) -> Envelope {
let reader = Cursor::new(zip_bytes);
let mut archive = match ZipArchive::new(reader) {
Ok(a) => a,
Err(e) => {
return Envelope::error(
DiagnosticCode::ZipReadError,
format!("cannot open ZIP: {e}"),
);
}
};
let mut manifest: Value = Value::Null;
let mut persons = BTreeMap::new();
let mut families = BTreeMap::new();
let mut events = BTreeMap::new();
let mut links = BTreeMap::new();
let mut occupations = BTreeMap::new();
let mut sources = BTreeMap::new();
let mut places = BTreeMap::new();
let mut documents = BTreeMap::new();
let mut attachments = BTreeMap::new();
for i in 0..archive.len() {
let mut entry = match archive.by_index(i) {
Ok(e) => e,
Err(e) => {
return Envelope::error(
DiagnosticCode::ZipReadError,
format!("cannot read ZIP entry #{i}: {e}"),
);
}
};
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
if name == "manifest.json" {
match read_json(&mut entry) {
Ok(v) => manifest = v,
Err(e) => return e,
}
continue;
}
if name.starts_with("schema/") {
continue;
}
if let Some((collection, id)) = split_entity_path(&name) {
let target = match collection {
"persons" => &mut persons,
"families" => &mut families,
"events" => &mut events,
"links" => &mut links,
"occupations" => &mut occupations,
"sources" => &mut sources,
"places" => &mut places,
_ => unreachable!(),
};
match read_json(&mut entry) {
Ok(v) => {
target.insert(id.to_string(), v);
continue;
}
Err(e) => return e,
}
}
if name == "documents/index.json" {
match read_json(&mut entry) {
Ok(Value::Object(map)) => {
for (k, v) in map {
documents.insert(k, v);
}
continue;
}
Ok(_) => {
return Envelope::error(
DiagnosticCode::InvalidBundleStructure,
"documents/index.json is not a JSON object",
);
}
Err(e) => return e,
}
}
let mut bytes = Vec::with_capacity(entry.size() as usize);
if let Err(e) = entry.read_to_end(&mut bytes) {
return Envelope::error(
DiagnosticCode::ZipReadError,
format!("cannot read ZIP entry {name:?}: {e}"),
);
}
attachments.insert(name, BASE64.encode(&bytes));
}
if let Err(env) = check_manifest_version(&manifest) {
return env;
}
let bundle = FlatBundle {
manifest,
persons,
families,
events,
links,
occupations,
sources,
places,
documents,
attachments,
extra: BTreeMap::new(),
};
let value = serde_json::to_value(&bundle).unwrap_or(Value::Null);
Envelope::ok(value)
}
fn read_json<R: Read>(reader: &mut R) -> Result<Value, Envelope> {
let mut buf = String::new();
if let Err(e) = reader.read_to_string(&mut buf) {
return Err(Envelope::error(
DiagnosticCode::ZipReadError,
format!("entry is not UTF-8 text: {e}"),
));
}
serde_json::from_str(&buf)
.map_err(|e| Envelope::error(DiagnosticCode::InvalidJson, format!("invalid JSON: {e}")))
}
fn split_entity_path(name: &str) -> Option<(&str, &str)> {
for (collection, dir) in ENTITY_DIRS.iter().take(7) {
let prefix = format!("{dir}/");
if let Some(rest) = name.strip_prefix(&prefix) {
if let Some(id) = rest.strip_suffix(".json") {
if !id.is_empty() && !id.contains('/') {
return Some((collection, id));
}
}
}
}
None
}
pub fn export_bundle(flat_json: &str) -> Envelope {
let mut bundle = match parse_flat(flat_json) {
Ok(b) => b,
Err(env) => return env,
};
if let Err(env) = check_manifest_version(&bundle.manifest) {
return env;
}
let fresh_stats = compute_stats(&bundle);
let now = now_iso8601_utc();
if let Value::Object(ref mut m) = bundle.manifest {
m.insert("stats".into(), fresh_stats);
m.insert("updated_at".into(), Value::String(now));
}
let mut buf = Vec::with_capacity(16 * 1024);
let cursor = Cursor::new(&mut buf);
let mut zip = ZipWriter::new(cursor);
let opts = FileOptions::default().compression_method(CompressionMethod::Deflated);
let write_json_entry =
|zip: &mut ZipWriter<_>, path: &str, value: &Value| -> Result<(), String> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| format!("serialize {path}: {e}"))?;
zip.start_file(path, opts)
.map_err(|e| format!("zip start {path}: {e}"))?;
zip.write_all(text.as_bytes())
.map_err(|e| format!("zip write {path}: {e}"))?;
Ok(())
};
let write_bytes_entry =
|zip: &mut ZipWriter<_>, path: &str, bytes: &[u8]| -> Result<(), String> {
zip.start_file(path, opts)
.map_err(|e| format!("zip start {path}: {e}"))?;
zip.write_all(bytes)
.map_err(|e| format!("zip write {path}: {e}"))?;
Ok(())
};
if let Err(e) = write_json_entry(&mut zip, "manifest.json", &bundle.manifest) {
return Envelope::error(DiagnosticCode::ZipWriteError, e);
}
if let Err(e) = write_bytes_entry(
&mut zip,
"schema/axgf-1.0.schema.json",
EMBEDDED_SCHEMA.as_bytes(),
) {
return Envelope::error(DiagnosticCode::ZipWriteError, e);
}
let per_entity: [(&str, &BTreeMap<String, Value>); 7] = [
("persons", &bundle.persons),
("families", &bundle.families),
("events", &bundle.events),
("links", &bundle.links),
("occupations", &bundle.occupations),
("sources", &bundle.sources),
("places", &bundle.places),
];
for (dir, map) in per_entity {
for (id, value) in map {
let path = format!("{dir}/{id}.json");
if let Err(e) = write_json_entry(&mut zip, &path, value) {
return Envelope::error(DiagnosticCode::ZipWriteError, e);
}
}
}
let doc_index: Value = bundle
.documents
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<serde_json::Map<_, _>>()
.into();
if let Err(e) = write_json_entry(&mut zip, "documents/index.json", &doc_index) {
return Envelope::error(DiagnosticCode::ZipWriteError, e);
}
for (path, b64) in &bundle.attachments {
let bytes = match BASE64.decode(b64.as_bytes()) {
Ok(b) => b,
Err(e) => {
return Envelope::error(
DiagnosticCode::InvalidBundleStructure,
format!("attachment {path:?} is not valid base64: {e}"),
);
}
};
if let Err(e) = write_bytes_entry(&mut zip, path, &bytes) {
return Envelope::error(DiagnosticCode::ZipWriteError, e);
}
}
if let Err(e) = zip.finish() {
return Envelope::error(DiagnosticCode::ZipWriteError, format!("zip finish: {e}"));
}
drop(zip);
Envelope::ok(json!({
"zip_base64": BASE64.encode(&buf),
"size_bytes": buf.len(),
}))
}