use edtf_core::{Bound, Edtf, Relation};
use edtf_normalize::{Language, NoMatchReason, NumericOrder, Options, Outcome, normalize_with};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::wasm_bindgen;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Summary {
pub canonical: String,
pub level: u8,
pub kind: &'static str,
pub precision: Option<&'static str>,
pub earliest: Option<String>,
pub latest: Option<String>,
pub uncertain: bool,
pub approximate: bool,
pub unspecified: bool,
}
fn bound_json(b: Bound) -> Option<String> {
match b {
Bound::Date(d) => Some(d.to_string()),
Bound::NegativeInfinity => Some("-infinity".into()),
Bound::PositiveInfinity => Some("infinity".into()),
Bound::Unknown => None,
}
}
#[must_use]
pub fn summarize(input: &str) -> Option<Summary> {
let parsed = Edtf::parse(input).ok()?;
let bounds = parsed.bounds();
let (kind, precision) = match &parsed {
Edtf::Date(d) => ("date", Some(precision_str(d))),
Edtf::DateTime(dt) => ("datetime", Some(precision_str(&dt.date))),
Edtf::Interval(_) => ("interval", None),
Edtf::Set(_) => ("set", None),
};
Some(Summary {
canonical: parsed.to_string(),
level: parsed.level(),
kind,
precision,
earliest: bound_json(bounds.earliest),
latest: bound_json(bounds.latest),
uncertain: parsed.is_uncertain(),
approximate: parsed.is_approximate(),
unspecified: parsed.has_unspecified(),
})
}
fn precision_str(d: &edtf_core::Date) -> &'static str {
match d.precision() {
edtf_core::Precision::Year => "year",
edtf_core::Precision::Month => "month",
edtf_core::Precision::Season => "season",
edtf_core::Precision::Day => "day",
}
}
#[derive(Debug, Serialize)]
pub struct RelationSummary {
pub before: &'static str,
pub after: &'static str,
pub overlaps: &'static str,
pub contains: &'static str,
pub within: &'static str,
pub equal: &'static str,
}
#[must_use]
pub fn relate(a: &str, b: &str) -> Option<RelationSummary> {
let rel = Edtf::parse(a).ok()?.relation(&Edtf::parse(b).ok()?);
let m = |r: Relation| rel.modality(r).as_str();
Some(RelationSummary {
before: m(Relation::Before),
after: m(Relation::After),
overlaps: m(Relation::Overlaps),
contains: m(Relation::Contains),
within: m(Relation::Within),
equal: m(Relation::Equal),
})
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum NormalizeSummary {
#[serde(rename_all = "camelCase")]
Normalized {
edtf: String,
level: u8,
notes: Vec<NoteJson>,
},
#[serde(rename_all = "camelCase")]
Ambiguous {
interpretations: Vec<InterpretationJson>,
},
#[serde(rename_all = "camelCase")]
NoMatch {
reason: &'static str,
decision: &'static str,
},
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NoteJson {
pub decision: Option<&'static str>,
pub message: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InterpretationJson {
pub edtf: String,
pub level: u8,
pub reading: String,
pub notes: Vec<NoteJson>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
struct NormalizeOptionsJson {
language: Option<String>,
numeric_order: Option<String>,
default_century: Option<u16>,
}
fn parse_options(options: Option<&str>) -> Option<Options> {
let Some(raw) = options.filter(|s| !s.trim().is_empty()) else {
return Some(Options::default());
};
let parsed: NormalizeOptionsJson = serde_json::from_str(raw).ok()?;
let language = match parsed.language.as_deref() {
None | Some("en") => Language::English,
Some("ru") => Language::Russian,
Some(_) => return None,
};
let numeric_order = match parsed.numeric_order.as_deref() {
None => None,
Some("dayFirst") => Some(NumericOrder::DayFirst),
Some("monthFirst") => Some(NumericOrder::MonthFirst),
Some(_) => return None,
};
if parsed.default_century.is_some_and(|c| c > 9999) {
return None;
}
Some(Options {
language,
numeric_order,
default_century: parsed.default_century,
})
}
fn notes_json(notes: &[edtf_normalize::Note]) -> Vec<NoteJson> {
notes
.iter()
.map(|n| NoteJson {
decision: n.decision(),
message: n.message(),
})
.collect()
}
#[must_use]
pub fn normalize_summary(input: &str, options: Option<&str>) -> Option<NormalizeSummary> {
let opts = parse_options(options)?;
Some(match normalize_with(input, opts) {
Outcome::Normalized(n) => NormalizeSummary::Normalized {
level: n.value.level(),
notes: notes_json(&n.notes),
edtf: n.edtf,
},
Outcome::Ambiguous(a) => NormalizeSummary::Ambiguous {
interpretations: a
.interpretations
.into_iter()
.map(|i| InterpretationJson {
level: i.value.level(),
notes: notes_json(&i.notes),
edtf: i.edtf,
reading: i.reading,
})
.collect(),
},
Outcome::NoMatch { reason } => NormalizeSummary::NoMatch {
reason: match reason {
NoMatchReason::OutOfGrammar => "outOfGrammar",
NoMatchReason::ExplicitNoDate => "explicitNoDate",
NoMatchReason::ImpossibleDate => "impossibleDate",
},
decision: reason.decision(),
},
})
}
#[wasm_bindgen]
#[must_use]
#[allow(
clippy::needless_pass_by_value,
reason = "wasm-bindgen passes JS strings owned, not as Option<&str>"
)]
pub fn normalize(input: &str, options: Option<String>) -> Option<String> {
let summary = normalize_summary(input, options.as_deref())?;
serde_json::to_string(&summary).ok()
}
#[wasm_bindgen(js_name = isValid)]
#[must_use]
pub fn is_valid(input: &str) -> bool {
edtf_core::is_valid(input)
}
#[wasm_bindgen]
pub fn level(input: &str) -> i32 {
edtf_core::level(input).map_or(-1, i32::from)
}
#[wasm_bindgen]
#[must_use]
pub fn canonical(input: &str) -> Option<String> {
Some(Edtf::parse(input).ok()?.to_string())
}
#[wasm_bindgen]
#[must_use]
pub fn parse(input: &str) -> Option<String> {
let summary = summarize(input)?;
serde_json::to_string(&summary).ok()
}
#[wasm_bindgen]
#[must_use]
pub fn relation(a: &str, b: &str) -> Option<String> {
serde_json::to_string(&relate(a, b)?).ok()
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "test code: a panic here is the failure signal, not a crash path"
)]
use super::*;
#[test]
fn summary_shape() {
let s = summarize("2004-06~-11").unwrap();
assert_eq!(s.canonical, "2004-06~-11");
assert_eq!(s.level, 2);
assert_eq!(s.kind, "date");
assert_eq!(s.precision, Some("day"));
assert_eq!(s.earliest.as_deref(), Some("2004-06-11"));
assert_eq!(s.latest.as_deref(), Some("2004-06-11"));
assert!(s.approximate);
assert!(!s.uncertain);
assert!(!s.unspecified);
}
#[test]
fn open_interval_summary() {
let s = summarize("1985-04-12/..").unwrap();
assert_eq!(s.kind, "interval");
assert_eq!(s.earliest.as_deref(), Some("1985-04-12"));
assert_eq!(s.latest.as_deref(), Some("infinity"));
}
#[test]
fn unknown_bound_is_null() {
let s = summarize("1986-04/").unwrap();
assert_eq!(s.latest, None);
}
#[test]
fn invalid_yields_none() {
assert!(summarize("1985-02-30").is_none());
assert_eq!(level("1985-02-30"), -1);
assert!(is_valid("1985-04-12"));
}
#[test]
fn relation_shape() {
let r = relate("1985~", "199X").unwrap();
assert_eq!(r.before, "definite");
assert_eq!(r.after, "impossible");
assert_eq!(r.equal, "impossible");
let j = relation("198X", "1985").unwrap();
assert_eq!(
j,
"{\"before\":\"possible\",\"after\":\"possible\",\
\"overlaps\":\"possible\",\"contains\":\"possible\",\
\"within\":\"possible\",\"equal\":\"possible\"}"
);
assert!(relation("junk", "1985").is_none());
assert!(relation("1985", "junk").is_none());
}
#[test]
fn normalize_shapes() {
let j = normalize("circa 1920", None).unwrap();
assert!(j.contains("\"kind\":\"normalized\""), "{j}");
assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
assert!(j.contains("\"level\":1"), "{j}");
let j = normalize("12/04/1985", None).unwrap();
assert!(j.contains("\"kind\":\"ambiguous\""), "{j}");
assert!(
j.contains("\"1985-04-12\"") && j.contains("\"1985-12-04\""),
"{j}"
);
assert!(j.contains("\"decision\":\"N5\""), "{j}");
let j = normalize("no idea", None).unwrap();
assert_eq!(
j,
"{\"kind\":\"noMatch\",\"reason\":\"outOfGrammar\",\"decision\":\"N11\"}"
);
let j = normalize("unknown", None).unwrap();
assert!(j.contains("\"reason\":\"explicitNoDate\""), "{j}");
assert!(j.contains("\"decision\":\"N12\""), "{j}");
let j = normalize("30/02/1985", None).unwrap();
assert!(j.contains("\"reason\":\"impossibleDate\""), "{j}");
}
#[test]
fn normalize_options() {
let ru = Some(String::from("{\"language\":\"ru\"}"));
let j = normalize("около 1920 г.", ru).unwrap();
assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
let df = Some(String::from("{\"numericOrder\":\"dayFirst\"}"));
let j = normalize("12/04/1985", df).unwrap();
assert!(j.contains("\"kind\":\"normalized\""), "{j}");
assert!(j.contains("\"edtf\":\"1985-04-12\""), "{j}");
let dc = Some(String::from("{\"defaultCentury\":1900}"));
let j = normalize("the 80s", dc).unwrap();
assert!(j.contains("\"edtf\":\"198X\""), "{j}");
assert!(normalize("1985", Some(String::from("{\"language\":\"tlh\"}"))).is_none());
assert!(normalize("1985", Some(String::from("not json"))).is_none());
assert!(normalize("около 1920", Some(String::from("{\"lang\":\"ru\"}"))).is_none());
assert!(normalize("the 80s", Some(String::from("{\"defaultCentury\":12345}"))).is_none());
}
#[test]
fn json_is_camel_case() {
let j = parse("1985").unwrap();
assert!(j.contains("\"canonical\":\"1985\""));
assert!(!j.contains("\"level\":1"), "1985 is level 0: {j}");
assert!(j.contains("\"level\":0"));
}
}