1use edtf_core::{Bound, Edtf, Relation};
17use edtf_normalize::{Language, NoMatchReason, NumericOrder, Options, Outcome, normalize_with};
18use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::wasm_bindgen;
20
21#[derive(Debug, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Summary {
25 pub canonical: String,
27 pub level: u8,
29 pub kind: &'static str,
31 pub precision: Option<&'static str>,
33 pub earliest: Option<String>,
35 pub latest: Option<String>,
37 pub uncertain: bool,
39 pub approximate: bool,
41 pub unspecified: bool,
43}
44
45fn bound_json(b: Bound) -> Option<String> {
46 match b {
47 Bound::Date(d) => Some(d.to_string()),
48 Bound::NegativeInfinity => Some("-infinity".into()),
49 Bound::PositiveInfinity => Some("infinity".into()),
50 Bound::Unknown => None,
51 }
52}
53
54#[must_use]
56pub fn summarize(input: &str) -> Option<Summary> {
57 let parsed = Edtf::parse(input).ok()?;
58 let bounds = parsed.bounds();
59 let (kind, precision) = match &parsed {
60 Edtf::Date(d) => ("date", Some(precision_str(d))),
61 Edtf::DateTime(dt) => ("datetime", Some(precision_str(&dt.date))),
62 Edtf::Interval(_) => ("interval", None),
63 Edtf::Set(_) => ("set", None),
64 };
65 Some(Summary {
66 canonical: parsed.to_string(),
67 level: parsed.level(),
68 kind,
69 precision,
70 earliest: bound_json(bounds.earliest),
71 latest: bound_json(bounds.latest),
72 uncertain: parsed.is_uncertain(),
73 approximate: parsed.is_approximate(),
74 unspecified: parsed.has_unspecified(),
75 })
76}
77
78fn precision_str(d: &edtf_core::Date) -> &'static str {
79 match d.precision() {
80 edtf_core::Precision::Year => "year",
81 edtf_core::Precision::Month => "month",
82 edtf_core::Precision::Season => "season",
83 edtf_core::Precision::Day => "day",
84 }
85}
86
87#[derive(Debug, Serialize)]
94pub struct RelationSummary {
95 pub before: &'static str,
97 pub after: &'static str,
99 pub overlaps: &'static str,
101 pub contains: &'static str,
103 pub within: &'static str,
105 pub equal: &'static str,
107}
108
109#[must_use]
111pub fn relate(a: &str, b: &str) -> Option<RelationSummary> {
112 let rel = Edtf::parse(a).ok()?.relation(&Edtf::parse(b).ok()?);
113 let m = |r: Relation| rel.modality(r).as_str();
114 Some(RelationSummary {
115 before: m(Relation::Before),
116 after: m(Relation::After),
117 overlaps: m(Relation::Overlaps),
118 contains: m(Relation::Contains),
119 within: m(Relation::Within),
120 equal: m(Relation::Equal),
121 })
122}
123
124#[derive(Debug, Serialize)]
128#[serde(tag = "kind", rename_all = "camelCase")]
129pub enum NormalizeSummary {
130 #[serde(rename_all = "camelCase")]
132 Normalized {
133 edtf: String,
135 level: u8,
137 notes: Vec<NoteJson>,
139 },
140 #[serde(rename_all = "camelCase")]
142 Ambiguous {
143 interpretations: Vec<InterpretationJson>,
145 },
146 #[serde(rename_all = "camelCase")]
150 NoMatch {
151 reason: &'static str,
153 decision: &'static str,
155 },
156}
157
158#[derive(Debug, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct NoteJson {
162 pub decision: Option<&'static str>,
164 pub message: &'static str,
166}
167
168#[derive(Debug, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct InterpretationJson {
172 pub edtf: String,
174 pub level: u8,
176 pub reading: String,
178 pub notes: Vec<NoteJson>,
180}
181
182#[derive(Debug, Default, Deserialize)]
186#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
187struct NormalizeOptionsJson {
188 language: Option<String>,
189 numeric_order: Option<String>,
190 default_century: Option<u16>,
191}
192
193fn parse_options(options: Option<&str>) -> Option<Options> {
194 let Some(raw) = options.filter(|s| !s.trim().is_empty()) else {
195 return Some(Options::default());
196 };
197 let parsed: NormalizeOptionsJson = serde_json::from_str(raw).ok()?;
198 let language = match parsed.language.as_deref() {
199 None | Some("en") => Language::English,
200 Some("ru") => Language::Russian,
201 Some(_) => return None,
202 };
203 let numeric_order = match parsed.numeric_order.as_deref() {
204 None => None,
205 Some("dayFirst") => Some(NumericOrder::DayFirst),
206 Some("monthFirst") => Some(NumericOrder::MonthFirst),
207 Some(_) => return None,
208 };
209 if parsed.default_century.is_some_and(|c| c > 9999) {
212 return None;
213 }
214 Some(Options {
215 language,
216 numeric_order,
217 default_century: parsed.default_century,
218 })
219}
220
221fn notes_json(notes: &[edtf_normalize::Note]) -> Vec<NoteJson> {
222 notes
223 .iter()
224 .map(|n| NoteJson {
225 decision: n.decision(),
226 message: n.message(),
227 })
228 .collect()
229}
230
231#[must_use]
234pub fn normalize_summary(input: &str, options: Option<&str>) -> Option<NormalizeSummary> {
235 let opts = parse_options(options)?;
236 Some(match normalize_with(input, opts) {
237 Outcome::Normalized(n) => NormalizeSummary::Normalized {
238 level: n.value.level(),
239 notes: notes_json(&n.notes),
240 edtf: n.edtf,
241 },
242 Outcome::Ambiguous(a) => NormalizeSummary::Ambiguous {
243 interpretations: a
244 .interpretations
245 .into_iter()
246 .map(|i| InterpretationJson {
247 level: i.value.level(),
248 notes: notes_json(&i.notes),
249 edtf: i.edtf,
250 reading: i.reading,
251 })
252 .collect(),
253 },
254 Outcome::NoMatch { reason } => NormalizeSummary::NoMatch {
255 reason: match reason {
256 NoMatchReason::OutOfGrammar => "outOfGrammar",
257 NoMatchReason::ExplicitNoDate => "explicitNoDate",
258 NoMatchReason::ImpossibleDate => "impossibleDate",
259 },
260 decision: reason.decision(),
261 },
262 })
263}
264
265#[wasm_bindgen]
273#[must_use]
274#[allow(
275 clippy::needless_pass_by_value,
276 reason = "wasm-bindgen passes JS strings owned, not as Option<&str>"
277)]
278pub fn normalize(input: &str, options: Option<String>) -> Option<String> {
279 let summary = normalize_summary(input, options.as_deref())?;
280 serde_json::to_string(&summary).ok()
281}
282
283#[wasm_bindgen(js_name = isValid)]
285#[must_use]
286pub fn is_valid(input: &str) -> bool {
287 edtf_core::is_valid(input)
288}
289
290#[wasm_bindgen]
292pub fn level(input: &str) -> i32 {
293 edtf_core::level(input).map_or(-1, i32::from)
294}
295
296#[wasm_bindgen]
298#[must_use]
299pub fn canonical(input: &str) -> Option<String> {
300 Some(Edtf::parse(input).ok()?.to_string())
301}
302
303#[wasm_bindgen]
306#[must_use]
307pub fn parse(input: &str) -> Option<String> {
308 let summary = summarize(input)?;
309 serde_json::to_string(&summary).ok()
310}
311
312#[wasm_bindgen]
316#[must_use]
317pub fn relation(a: &str, b: &str) -> Option<String> {
318 serde_json::to_string(&relate(a, b)?).ok()
319}
320
321#[cfg(test)]
322mod tests {
323 #![allow(
324 clippy::unwrap_used,
325 reason = "test code: a panic here is the failure signal, not a crash path"
326 )]
327
328 use super::*;
329
330 #[test]
331 fn summary_shape() {
332 let s = summarize("2004-06~-11").unwrap();
333 assert_eq!(s.canonical, "2004-06~-11");
334 assert_eq!(s.level, 2);
335 assert_eq!(s.kind, "date");
336 assert_eq!(s.precision, Some("day"));
337 assert_eq!(s.earliest.as_deref(), Some("2004-06-11"));
338 assert_eq!(s.latest.as_deref(), Some("2004-06-11"));
339 assert!(s.approximate);
340 assert!(!s.uncertain);
341 assert!(!s.unspecified);
342 }
343
344 #[test]
345 fn open_interval_summary() {
346 let s = summarize("1985-04-12/..").unwrap();
347 assert_eq!(s.kind, "interval");
348 assert_eq!(s.earliest.as_deref(), Some("1985-04-12"));
349 assert_eq!(s.latest.as_deref(), Some("infinity"));
350 }
351
352 #[test]
353 fn unknown_bound_is_null() {
354 let s = summarize("1986-04/").unwrap();
355 assert_eq!(s.latest, None);
356 }
357
358 #[test]
359 fn invalid_yields_none() {
360 assert!(summarize("1985-02-30").is_none());
361 assert_eq!(level("1985-02-30"), -1);
362 assert!(is_valid("1985-04-12"));
363 }
364
365 #[test]
366 fn relation_shape() {
367 let r = relate("1985~", "199X").unwrap();
368 assert_eq!(r.before, "definite");
369 assert_eq!(r.after, "impossible");
370 assert_eq!(r.equal, "impossible");
371 let j = relation("198X", "1985").unwrap();
372 assert_eq!(
373 j,
374 "{\"before\":\"possible\",\"after\":\"possible\",\
375 \"overlaps\":\"possible\",\"contains\":\"possible\",\
376 \"within\":\"possible\",\"equal\":\"possible\"}"
377 );
378 assert!(relation("junk", "1985").is_none());
379 assert!(relation("1985", "junk").is_none());
380 }
381
382 #[test]
383 fn normalize_shapes() {
384 let j = normalize("circa 1920", None).unwrap();
385 assert!(j.contains("\"kind\":\"normalized\""), "{j}");
386 assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
387 assert!(j.contains("\"level\":1"), "{j}");
388
389 let j = normalize("12/04/1985", None).unwrap();
390 assert!(j.contains("\"kind\":\"ambiguous\""), "{j}");
391 assert!(
392 j.contains("\"1985-04-12\"") && j.contains("\"1985-12-04\""),
393 "{j}"
394 );
395 assert!(j.contains("\"decision\":\"N5\""), "{j}");
396
397 let j = normalize("no idea", None).unwrap();
398 assert_eq!(
399 j,
400 "{\"kind\":\"noMatch\",\"reason\":\"outOfGrammar\",\"decision\":\"N11\"}"
401 );
402 let j = normalize("unknown", None).unwrap();
403 assert!(j.contains("\"reason\":\"explicitNoDate\""), "{j}");
404 assert!(j.contains("\"decision\":\"N12\""), "{j}");
405 let j = normalize("30/02/1985", None).unwrap();
406 assert!(j.contains("\"reason\":\"impossibleDate\""), "{j}");
407 }
408
409 #[test]
410 fn normalize_options() {
411 let ru = Some(String::from("{\"language\":\"ru\"}"));
412 let j = normalize("около 1920 г.", ru).unwrap();
413 assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
414
415 let df = Some(String::from("{\"numericOrder\":\"dayFirst\"}"));
416 let j = normalize("12/04/1985", df).unwrap();
417 assert!(j.contains("\"kind\":\"normalized\""), "{j}");
418 assert!(j.contains("\"edtf\":\"1985-04-12\""), "{j}");
419
420 let dc = Some(String::from("{\"defaultCentury\":1900}"));
421 let j = normalize("the 80s", dc).unwrap();
422 assert!(j.contains("\"edtf\":\"198X\""), "{j}");
423
424 assert!(normalize("1985", Some(String::from("{\"language\":\"tlh\"}"))).is_none());
425 assert!(normalize("1985", Some(String::from("not json"))).is_none());
426 assert!(normalize("около 1920", Some(String::from("{\"lang\":\"ru\"}"))).is_none());
429 assert!(normalize("the 80s", Some(String::from("{\"defaultCentury\":12345}"))).is_none());
431 }
432
433 #[test]
434 fn json_is_camel_case() {
435 let j = parse("1985").unwrap();
436 assert!(j.contains("\"canonical\":\"1985\""));
437 assert!(!j.contains("\"level\":1"), "1985 is level 0: {j}");
438 assert!(j.contains("\"level\":0"));
439 }
440}