///|
pub(all) enum Calendar {
Gregorian
Julian
Hebrew
FrenchRepublican
Extension(String)
} derive(Eq, @debug.Debug)
///|
pub extend Calendar with Eq::{equal, not_equal}
///|
pub extend Calendar with @debug.Debug::{to_repr}
///|
pub(all) enum DateQualifier {
Exact
Before
After
Approximate
Calculated
Estimated
Between
Period
} derive(Eq, @debug.Debug)
///|
pub extend DateQualifier with Eq::{equal, not_equal}
///|
pub extend DateQualifier with @debug.Debug::{to_repr}
///|
pub struct DatePoint {
calendar : Calendar
year : Int32
month : Int32?
day : Int32?
bce : Bool
month_tag : String?
epoch : String?
}
///|
pub struct DateValue {
qualifier : DateQualifier
first : DatePoint?
second : DatePoint?
}
///|
/// Inclusive Julian-day bounds. None is unbounded, never filled in by guessing.
pub struct DateWindow {
earliest : Int64?
latest : Int64?
}
///|
fn leap(year : Int32, calendar : Calendar) -> Bool {
year % 4 == 0 && (calendar == Julian || year % 100 != 0 || year % 400 == 0)
}
///|
fn month_days(year : Int32, month : Int32, calendar : Calendar) -> Int32 {
if calendar == Hebrew {
let length = hebrew_new_year(year + 1) - hebrew_new_year(year)
return match month {
1 | 5 | 6 | 8 | 10 | 12 => 30
2 => if length % 10L == 5L { 30 } else { 29 }
3 => if length % 10L == 3L { 29 } else { 30 }
_ => 29
}
}
if calendar == FrenchRepublican {
if month < 13 {
return 30
}
return if [3, 7, 11].contains(year) || year > 14 { 6 } else { 5 }
}
match month {
2 => if leap(year, calendar) { 29 } else { 28 }
4 | 6 | 9 | 11 => 30
_ => 31
}
}
///|
fn date_number(text : StringView) -> Int32 raise GedError {
if text.is_empty() || !text.code_units().all(fn(c) { c >= 48 && c <= 57 }) {
raise Query("date requires ASCII positive integers")
}
let n = @string.parse_int(text) catch {
_ => raise Query("date number is out of range")
}
if n < 1 || n > 9999999 {
raise Query("date number outside supported range 1..9999999")
}
n
}
///|
fn parse_point(input : String) -> DatePoint raise GedError {
let tokens = input.split(" ").map(fn(s) { s.to_owned() }).to_array()
if tokens.any(fn(s) { s.is_empty() }) {
raise Query("date components require single spaces")
}
let mut calendar = Gregorian
if tokens.get(0) == Some("GREGORIAN") || tokens.get(0) == Some("JULIAN") {
calendar = if tokens[0] == "JULIAN" { Julian } else { Gregorian }
tokens.remove(0) |> ignore
} else if tokens.get(0) == Some("HEBREW") || tokens.get(0) == Some("FRENCH_R") {
calendar = if tokens[0] == "HEBREW" { Hebrew } else { FrenchRepublican }
tokens.remove(0) |> ignore
} else if tokens.get(0) is Some(t) && t.has_prefix("_") {
if !valid_tag(t) {
raise Query("invalid extension calendar")
}
calendar = Extension(t)
tokens.remove(0) |> ignore
}
let last = tokens.get(tokens.length() - 1).unwrap_or("")
let epoch = if last == "BCE" || last.has_prefix("_") {
Some(last)
} else {
None
}
let bce = epoch == Some("BCE")
if epoch != None {
tokens.remove(tokens.length() - 1) |> ignore
}
if (calendar == Gregorian || calendar == Julian) && epoch != None && !bce {
raise Query("invalid epoch for Gregorian/Julian calendar")
}
if (calendar == Hebrew || calendar == FrenchRepublican) && epoch != None {
raise Query("calendar does not permit an epoch")
}
if calendar is Extension(_) &&
epoch is Some(e) &&
(!e.has_prefix("_") || !valid_tag(e)) {
raise Query("extension epoch requires extension tag")
}
if tokens.length() < 1 || tokens.length() > 3 {
raise Query("expected year, month year, or day month year")
}
let year = date_number(tokens[tokens.length() - 1])
let month = if tokens.length() >= 2 {
let name = tokens[tokens.length() - 2]
let names = match calendar {
Hebrew =>
[
"TSH", "CSH", "KSL", "TVT", "SHV", "ADR", "ADS", "NSN", "IYR", "SVN", "TMZ",
"AAV", "ELL",
]
FrenchRepublican =>
[
"VEND", "BRUM", "FRIM", "NIVO", "PLUV", "VENT", "GERM", "FLOR", "PRAI",
"MESS", "THER", "FRUC", "COMP",
]
Extension(_) => []
_ =>
[
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV",
"DEC",
]
}
let mut n = 0
for i, value in names {
if name == value {
n = i + 1
}
}
if calendar is Extension(_) {
if !name.has_prefix("_") || !valid_tag(name) {
raise Query("extension calendar requires extension month tags")
}
} else if n == 0 {
raise Query("invalid month for calendar")
}
// Common-year ADR is a documented legacy spelling of ADS (æAppendix A).
Some(if calendar == Hebrew && n == 6 && !hebrew_leap(year) { 7 } else { n })
} else {
None
}
let day = if tokens.length() == 3 {
Some(date_number(tokens[0]))
} else {
None
}
let astro_year = if bce { 1 - year } else { year }
if day is Some(d) &&
month is Some(m) &&
!(calendar is Extension(_)) &&
d > month_days(astro_year, m, calendar) {
raise Query("day is outside the calendar month")
}
{
calendar,
year,
month,
day,
bce,
month_tag: if tokens.length() >= 2 {
Some(tokens[tokens.length() - 2])
} else {
None
},
epoch,
}
}
///|
pub fn parse_date(text : String) -> DateValue raise GedError {
if text == "" {
return { qualifier: Exact, first: None, second: None, }
}
for
(prefix, qualifier) in [
("BEF ", Before),
("AFT ", After),
("ABT ", Approximate),
("CAL ", Calculated),
("EST ", Estimated),
] {
if text.has_prefix(prefix) {
return {
qualifier,
first: Some(parse_point(text[prefix.length():].to_owned())),
second: None,
}
}
}
if text.has_prefix("BET ") {
let parts = text[4:].split(" AND ").map(fn(s) { s.to_owned() }).to_array()
if parts.length() != 2 {
raise Query("BET requires AND and two dates")
}
let date = {
qualifier: Between,
first: Some(parse_point(parts[0])),
second: Some(parse_point(parts[1])),
}
let window = date.window()
if window.earliest is Some(a) && window.latest is Some(b) && a > b {
raise Query("reversed date range")
}
return date
}
if text.has_prefix("FROM ") {
let parts = text[5:].split(" TO ").map(fn(s) { s.to_owned() }).to_array()
if parts.length() > 2 {
raise Query("FROM has more than one TO")
}
let second = if parts.length() == 2 {
Some(parse_point(parts[1]))
} else {
None
}
let date = {
qualifier: Period,
first: Some(parse_point(parts[0])),
second,
}
let window = date.window()
if window.earliest is Some(a) && window.latest is Some(b) && a > b {
raise Query("reversed date period")
}
return date
}
if text.has_prefix("TO ") {
return {
qualifier: Period,
first: None,
second: Some(parse_point(text[3:].to_owned())),
}
}
{ qualifier: Exact, first: Some(parse_point(text)), second: None, }
}
///|
fn floor_div(n : Int64, d : Int64) -> Int64 {
let q = n / d
if n % d < 0 {
q - 1L
} else {
q
}
}
///|
fn julian_day(point : DatePoint, month : Int32, day : Int32) -> Int64 {
if point.calendar == Hebrew {
let mut value = hebrew_new_year(point.year)
for m = 1; m < month; m = m + 1 {
if m != 6 || hebrew_leap(point.year) {
value = value + month_days(point.year, m, Hebrew).to_int64()
}
}
return value + day.to_int64() - 1L
}
if point.calendar == FrenchRepublican {
let mut n = 0
for y = 1; y < point.year; y = y + 1 {
if [3, 7, 11].contains(y) {
n = n + 1
}
}
return 2375840L +
365L * (point.year.to_int64() - 1L) +
n.to_int64() +
30L * (month.to_int64() - 1L) +
day.to_int64() -
1L
}
let year = if point.bce { 1 - point.year } else { point.year }
let a = (14 - month) / 12
let y = year.to_int64() + 4800L - a.to_int64()
let m = (month + 12 * a - 3).to_int64()
let base = day.to_int64() + (153L * m + 2L) / 5L + 365L * y + floor_div(y, 4L)
if point.calendar == Gregorian {
base - floor_div(y, 100L) + floor_div(y, 400L) - 32045L
} else {
base - 32083L
}
}
///|
fn point_bounds(point : DatePoint) -> (Int64, Int64) {
let first_month = point.month.unwrap_or(1)
let last_month = point.month.unwrap_or(
if point.calendar == Hebrew || point.calendar == FrenchRepublican {
13
} else {
12
},
)
let astro_year = if point.bce { 1 - point.year } else { point.year }
(
julian_day(point, first_month, point.day.unwrap_or(1)),
julian_day(
point,
last_month,
point.day.unwrap_or(month_days(astro_year, last_month, point.calendar)),
),
)
}
///|
pub fn DateValue::window(self : DateValue) -> DateWindow {
if [self.first, self.second].any(fn(p) {
match p {
Some(p) =>
p.calendar is Extension(_) ||
(p.calendar == FrenchRepublican && p.year > 14)
None => false
}
}) {
return { earliest: None, latest: None, }
}
let first = self.first.map(point_bounds)
let second = self.second.map(point_bounds)
match self.qualifier {
Approximate | Estimated => { earliest: None, latest: None, }
Before => { earliest: None, latest: first.map(fn(pair) { pair.1 }), }
After => { earliest: first.map(fn(pair) { pair.0 }), latest: None, }
Between | Period =>
{
earliest: first.map(fn(pair) { pair.0 }),
latest: second.map(fn(pair) { pair.1 }),
}
Exact | Calculated =>
{
earliest: first.map(fn(pair) { pair.0 }),
latest: first.map(fn(pair) { pair.1 }),
}
}
}
///|
fn hebrew_leap(year : Int32) -> Bool {
(7 * year + 1) % 19 < 7
}
///|
// Molad and postponement rules in integer arithmetic; Julian day at civil noon.
fn hebrew_delay(year : Int32) -> Int64 {
let y = year.to_int64()
let months = (235L * y - 234L) / 19L
let parts = 12084L + 13753L * months
let day = 29L * months + parts / 25920L
if 3L * (day + 1L) % 7L < 3L {
day + 1L
} else {
day
}
}
///|
fn hebrew_new_year(year : Int32) -> Int64 {
let current = hebrew_delay(year)
let adjust = if hebrew_delay(year + 1) - current == 356L {
2L
} else if current - hebrew_delay(year - 1) == 382L {
1L
} else {
0L
}
347998L + current + adjust
}
///|
/// Certain ordering only. Partial/approximate dates never become fabricated days.
pub fn certainly_before(a : DateValue, b : DateValue) -> Bool {
match (a.window().latest, b.window().earliest) {
(Some(x), Some(y)) => x < y
_ => false
}
}
///|
pub fn audit_dates(doc : Document) -> Array[Diagnostic] {
let out = []
let ctx = schema_context(doc)
for i, node in doc.nodes {
let t = ctx.types[i].unwrap_or("?")
let (kind, _) = standard_payloads.get_or_default(t, ("", []))
if !kind.has_prefix("type-Date") {
continue
}
let text = node.payload.text().unwrap_or("")
if !check_payload(kind, [], node.payload, ctx) {
out.push(
diagnostic(
"invalid-date",
node.line,
"date does not match its contextual GEDCOM date type",
),
)
continue
}
try parse_date(text) catch {
Query(message) =>
out.push(
diagnostic(
if message.has_prefix("reversed") {
"date-order"
} else {
"date-arithmetic-limit"
},
node.line,
message,
severity=if message.has_prefix("reversed") {
Error
} else {
Warning
},
),
)
_ =>
out.push(
diagnostic(
"date-arithmetic-limit",
node.line,
"valid date is outside arithmetic resource policy",
severity=Warning,
),
)
} noraise {
value =>
if [value.first, value.second].any(fn(p) {
match p {
Some(p) =>
p.calendar is Extension(_) ||
(p.calendar == FrenchRepublican && p.year > 14)
None => false
}
}) {
out.push(
diagnostic(
"date-arithmetic-unassessed",
node.line,
"calendar value retained without inventing a proleptic conversion",
severity=Warning,
),
)
}
}
}
for p in people(doc) {
let es = events(doc, p.record)
let births = es.filter(fn(e) { e.tag == "BIRT" })
let deaths = es.filter(fn(e) { e.tag == "DEAT" })
if births.get(0) is Some(b) &&
deaths.get(0) is Some(d) &&
b.date is Some(bt) &&
d.date is Some(dt) {
try {
if certainly_before(parse_date(dt), parse_date(bt)) {
out.push(
diagnostic(
"birth-after-death",
doc.nodes[b.node].line,
"recorded birth range is certainly later than death range; review claims",
severity=Warning,
related_line=doc.nodes[d.node].line,
),
)
}
} catch {
_ => ()
}
}
}
out
}