| File: | blib/lib/DateTime/Format/Genealogy.pm |
| Coverage: | 95.8% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package DateTime::Format::Genealogy; | |||||
| 2 | ||||||
| 3 | # Author Nigel Horne: njh@nigelhorne.com | |||||
| 4 | # Copyright (C) 2018-2026, Nigel Horne | |||||
| 5 | ||||||
| 6 | # Usage is subject to licence terms. | |||||
| 7 | ||||||
| 8 | 11 11 11 | 925559 10 149 | use strict; | |||
| 9 | 11 11 11 | 16 10 211 | use warnings; | |||
| 10 | 11 11 11 | 1149 36904 24 | use autodie qw(:all); | |||
| 11 | 11 11 | 54796 34 | use 5.010; | |||
| 12 | ||||||
| 13 | # Sub::Private in 'enforce' mode makes calling private subs from outside the | |||||
| 14 | # package a runtime error. HARNESS_ACTIVE (set by prove/make test) or | |||||
| 15 | # $Sub::Private::BYPASS suppress enforcement so white-box tests can still | |||||
| 16 | # reach private helpers directly. | |||||
| 17 | 11 | 111 | BEGIN { $Sub::Private::config{mode} = 'enforce' } | |||
| 18 | 11 11 11 | 2295 292514 26 | use Sub::Private; | |||
| 19 | 11 11 11 | 3302 39810 32 | use Sub::Protected; | |||
| 20 | ||||||
| 21 | # Explicit () -- we use Carp::carp / Carp::croak throughout; importing the | |||||
| 22 | # short names into the namespace only to have namespace::clean remove them | |||||
| 23 | # is unnecessary noise. | |||||
| 24 | 11 11 11 | 921 8 92 | use Carp (); | |||
| 25 | 11 11 11 | 2587 2226482 356 | use DateTime::Format::Natural; | |||
| 26 | 11 11 11 | 2639 1392642 228 | use Genealogy::Gedcom::Date 2.01; | |||
| 27 | 11 11 11 | 3064 352442 215 | use Object::Configure 0.19; | |||
| 28 | 11 11 11 | 37 59 211 | use Params::Get 0.13; | |||
| 29 | 11 11 11 | 23 21 237 | use Params::Validate::Strict qw(validate_strict); | |||
| 30 | 11 11 11 | 25 16 211 | use Readonly; | |||
| 31 | 11 11 11 | 2043 6565 572 | use Readonly::Values::Months 0.02 qw(@short_month_names); | |||
| 32 | 11 11 11 | 35 14 94 | use Scalar::Util (); | |||
| 33 | ||||||
| 34 | # namespace::clean placed last so the import list above is self-documenting: | |||||
| 35 | # every 'use' that precedes it has its imported symbols removed from the | |||||
| 36 | # public namespace at the end of compilation. | |||||
| 37 | 11 11 11 | 20 9 47 | use namespace::clean; | |||
| 38 | ||||||
| 39 | # --------------------------------------------------------------------------- | |||||
| 40 | # Compile-time constants | |||||
| 41 | # --------------------------------------------------------------------------- | |||||
| 42 | ||||||
| 43 | # Map non-standard (long, abbreviated, or French/German) month names to the | |||||
| 44 | # 3-letter GEDCOM abbreviation. Not exported; callers must use parse_datetime. | |||||
| 45 | Readonly my %MONTH_ALIAS => ( | |||||
| 46 | 'January' => 'Jan', | |||||
| 47 | 'February' => 'Feb', | |||||
| 48 | 'March' => 'Mar', | |||||
| 49 | 'April' => 'Apr', | |||||
| 50 | 'June' => 'Jun', | |||||
| 51 | 'July' => 'Jul', | |||||
| 52 | 'August' => 'Aug', | |||||
| 53 | 'September' => 'Sep', | |||||
| 54 | 'October' => 'Oct', | |||||
| 55 | 'November' => 'Nov', | |||||
| 56 | 'December' => 'Dec', | |||||
| 57 | # Common non-standard abbreviations for September | |||||
| 58 | 'Sept' => 'Sep', | |||||
| 59 | # French and German month variants found in real genealogical trees. | |||||
| 60 | # 'Janv' = French abbreviation for janvier (January). | |||||
| 61 | # 'Juli' = German July / occasional French variant. | |||||
| 62 | # 'Mai' = French for May (3 letters, handled here to avoid a separate branch). | |||||
| 63 | 'Janv' => 'Jan', | |||||
| 64 | 'Juli' => 'Jul', | |||||
| 65 | 'Mai' => 'May', | |||||
| 66 | ); | |||||
| 67 | ||||||
| 68 | # Julian-to-Gregorian day offset tiers. Each pair is [boundary_year, offset]. | |||||
| 69 | # The gap widened by 1 day at each century mark after 1582: | |||||
| 70 | # 10 days: 5 Oct 1582 to 28 Feb 1700 | |||||
| 71 | # 11 days: 1 Mar 1700 to 28 Feb 1800 | |||||
| 72 | # 12 days: 1 Mar 1800 to 28 Feb 1900 | |||||
| 73 | # 13 days: 1 Mar 1900 onwards (catch-all, coded as literal 13 in the helper) | |||||
| 74 | Readonly my @JULIAN_OFFSET_TIERS => ([1700, 10], [1800, 11], [1900, 12]); | |||||
| 75 | ||||||
| 76 | # Maximum unique date strings cached per object before the cache is discarded. | |||||
| 77 | # Prevents adversarial callers from exhausting process memory by feeding an | |||||
| 78 | # unbounded stream of unique-but-invalid strings to parse_datetime. | |||||
| 79 | Readonly my $MAX_CACHE_SIZE => 10_000; | |||||
| 80 | ||||||
| 81 | # Legitimate recursion depth for parse_datetime is exactly 1: a range ("bet X | |||||
| 82 | # and Y") calls itself once for each half. Anything deeper means either a | |||||
| 83 | # mutant or a malformed string that tricks the range regex into re-matching its | |||||
| 84 | # own output. The limit is generous so valid edge cases never trip it. | |||||
| 85 | Readonly my $MAX_PARSE_DEPTH => 10; | |||||
| 86 | our $_parse_depth = 0; | |||||
| 87 | ||||||
| 88 | # Accepted parameter schema for parse_datetime, used by Params::Validate::Strict | |||||
| 89 | # to reject unknown keys (typos, stale callers) at runtime. | |||||
| 90 | Readonly my %PARSE_DATETIME_SCHEMA => ( | |||||
| 91 | date => { type => 'scalar', optional => 1 }, | |||||
| 92 | quiet => { type => 'scalar', optional => 1 }, | |||||
| 93 | strict => { type => 'scalar', optional => 1 }, | |||||
| 94 | ); | |||||
| 95 | ||||||
| 96 - 104 | =head1 NAME DateTime::Format::Genealogy - Create a DateTime object from a genealogy date string =head1 VERSION Version 0.12 =cut | |||||
| 105 | ||||||
| 106 | our $VERSION = '0.12'; | |||||
| 107 | ||||||
| 108 - 165 | =head1 SYNOPSIS
C<DateTime::Format::Genealogy> is a Perl module designed to parse genealogy-style
date strings (primarily GEDCOM format) and convert them into L<DateTime> objects.
It wraps L<Genealogy::Gedcom::Date> and L<DateTime::Format::Natural>, adds GEDCOM
calendar-escape handling, and accepts common non-standard month names found in
exported genealogical trees.
use DateTime::Format::Genealogy;
my $dtg = DateTime::Format::Genealogy->new();
my $dt = $dtg->parse_datetime('25 Dec 2022');
print $dt->dmy; # 25-12-2022
=head1 SUBROUTINES/METHODS
=head2 new
Creates or clones a C<DateTime::Format::Genealogy> object.
=head3 EXAMPLE
# Bare construction
my $dtg = DateTime::Format::Genealogy->new();
# Construction with flags stored on the object
my $dtg_quiet = DateTime::Format::Genealogy->new(quiet => 1, strict => 1);
# Clone with an override (inherits quiet => 1, overrides strict)
my $clone = $dtg_quiet->new(strict => 0);
=head3 API SPECIFICATION
# Input (hash or hashref, all keys optional):
{
quiet => $bool, # suppress carp in internal helpers
strict => $bool, # enforce 3-letter GEDCOM month abbreviations
# Any extra keys from Object::Configure are also accepted.
}
# Output: blessed DateTime::Format::Genealogy object
=head3 MESSAGES
This method does not emit any diagnostics directly.
=head3 PSEUDOCODE
FUNCTION new(class, *args):
params = get_params(undef, args)
IF class is not defined:
class = __PACKAGE__
ELSE IF class is already an object (blessed):
RETURN bless( merge(class.attrs, params), ref(class) )
params = configure(class, params) # merge any config-file settings
RETURN bless(params, class)
END FUNCTION
=cut | |||||
| 166 | ||||||
| 167 | sub new | |||||
| 168 | { | |||||
| 169 | 193 | 1521333 | my $class = shift; | |||
| 170 | ||||||
| 171 | 193 | 678 | my $params = Params::Get::get_params(undef, \@_); | |||
| 172 | ||||||
| 173 | 193 | 2782 | if(!defined($class)) { | |||
| 174 | # Called as DateTime::Format::Genealogy::new() with no invocant; | |||||
| 175 | # default to the package name so bless still works. | |||||
| 176 | 2 | 2 | $class = __PACKAGE__; | |||
| 177 | } elsif(Scalar::Util::blessed($class)) { | |||||
| 178 | # Invocant is an existing object: clone it and overlay any new params. | |||||
| 179 | 9 9 5 | 10 27 17 | return bless { %{$class}, ($params ? %{$params} : ()) }, ref($class); | |||
| 180 | } | |||||
| 181 | ||||||
| 182 | 184 | 512 | $params = Object::Configure::configure($class, $params); | |||
| 183 | 184 | 526130 | return bless $params, $class; | |||
| 184 | } | |||||
| 185 | ||||||
| 186 - 407 | =head2 parse_datetime
Parses a genealogy-style date string and returns a L<DateTime> object.
Recognises GEDCOM calendar escapes (C<@#DJULIAN@>, C<@#DHEBREW@>,
C<@#DFRENCH R@>) and converts them via the appropriate calendar module when
available.
Can be called as a class method, an object method, or a bare function.
Returns:
=over 4
=item *
A single L<DateTime> object for exact, parseable dates.
=item *
A two-element list of L<DateTime> objects in I<list> context when the date
string is a range (C<bet X and Y> / C<from X to Y>).
=item *
C<undef> (scalar) or the empty list (list context) when the date cannot be
parsed, is a year-only string, is prefixed with an approximation keyword
(C<bef>, C<aft>, C<abt>), or represents a date before AD 100.
=back
Mandatory argument:
=over 4
=item * C<date>
The date string to parse.
=back
Optional arguments (may be set at construction time and/or overridden
per-call; per-call values take precedence):
=over 4
=item * C<quiet>
Suppress L<Carp> warnings on unparseable or approximate dates.
=item * C<strict>
Enforce the GEDCOM standard: only 3-letter month abbreviations (C<Jan>,
C<Feb>, ...) are accepted. Long English names and French/German variants
are rejected.
=back
=head3 EXAMPLE
my $dtg = DateTime::Format::Genealogy->new();
# Simple exact date
my $dt = $dtg->parse_datetime('25 Dec 2022');
print $dt->dmy; # 25-12-2022
# Date range (list context)
my ($start, $end) = $dtg->parse_datetime('bet 1 Sep 1939 and 2 Sep 1945');
# GEDCOM calendar escape
my $julian = $dtg->parse_datetime('@#DJULIAN@ 15 Mar 1620');
# Class-method form (no constructor required)
my $dt2 = DateTime::Format::Genealogy->parse_datetime('1 Jan 2000');
# Long month name (non-strict only)
my $dt3 = $dtg->parse_datetime('12 June 2020');
# French month variant (non-strict only)
my $dt4 = $dtg->parse_datetime('21 Mai 1681');
=head3 API SPECIFICATION
# Input (hash or hashref):
{
date => $string, # required (non-ref, non-empty)
quiet => $bool, # optional; falls back to $self->{'quiet'}
strict => $bool, # optional; falls back to $self->{'strict'}
}
# Return values:
# DateTime exact parseable date
# (DateTime, DateTime) date range (list context only)
# undef / () unparseable, approximate, or year-only
=head3 MESSAGES
=over 4
=item C<< Usage: DateTime::Format::Genealogy::parse_datetime(date => $date) >>
Thrown (croak) when no arguments are supplied or when the C<date> value is
undef or a reference.
=item C<< Invalid parse_datetime parameters: ... >>
Thrown (croak) when an unknown parameter key is passed (e.g. a typo).
=item C<< $date is invalid, need an exact date to create a DateTime >>
Warned (carp) when the date begins with an approximation prefix (C<bef>,
C<aft>, C<abt>). Silenced by C<quiet>.
=item C<< $date is invalid, there are only 30 days in November >>
Warned (carp) for the impossible date C<31 Nov>. Always emitted; not
silenced by C<quiet>.
=item C<< Changing date '$original' to '$new' >>
Warned (carp) when a date is automatically rewritten (ISO dash format or
dash-separated range). Silenced by C<quiet>.
=item C<< Unparseable date $date - often because the month name isn't 3 letters >>
Warned (carp) in strict mode for non-3-letter months, or in non-strict mode
for unrecognised long month names. Silenced by C<quiet>.
=item C<< $dfn_error_string >>
Warned (carp) when L<DateTime::Format::Natural> rejects the date string.
Silenced by C<quiet>.
=item C<< Hebrew calendar conversion failed: ... >>
Warned (carp) when L<DateTime::Calendar::Hebrew> is unavailable or throws.
Silenced by C<quiet>.
=item C<< French Republican calendar conversion failed: ... >>
Warned (carp) when L<DateTime::Calendar::FrenchRevolutionary> is unavailable
or throws. Silenced by C<quiet>.
=item C<< Calendar type $type not supported >>
Warned (carp) for GEDCOM calendar escapes other than GREGORIAN, JULIAN,
HEBREW, and FRENCH R. Silenced by C<quiet>.
=back
=head3 PSEUDOCODE
FUNCTION parse_datetime(self, *args):
-- Dispatch class/function/hash-invocant calls to an object instance
IF self is not a reference:
RETURN new()->parse_datetime(args or self)
IF ref(self) == 'HASH':
RETURN new()->parse_datetime(self)
ABORT unless args non-empty
params = get_params('date', args)
ABORT on unknown keys (validate_strict)
date = params.date
quiet = params.quiet // self.quiet
strict = params.strict // self.strict
ABORT unless date is defined, non-empty, and not a reference
-- Strip GEDCOM calendar escape if present
IF date =~ s/^@#D([A-Z ]+?)@\s*//: calendar_type = 'D' + uc(match)
-- Reject approximate/relative dates
IF date =~ /^(bef|aft|abt)\s/i: CARP and RETURN undef
-- Reject calendar impossibilities
IF date =~ /^31\s+Nov/: CARP and RETURN undef
-- Rewrite dash-separated ranges and ISO dates
IF date =~ /X - Y/:
IF date =~ /YYYY-MM-DD/: REFORMAT to "DD Mon YYYY" (carp)
ELSE: REFORMAT to "bet X and Y" (carp)
-- Dispatch ranges to recursive calls
IF date =~ /^bet X and Y/i:
RETURN (parse_datetime(X), parse_datetime(Y)) IF wantarray
RETURN undef
IF !strict AND date =~ /^from X to Y/i:
RETURN (parse_datetime(X), parse_datetime(Y)) IF wantarray
RETURN undef
-- Normalise non-standard month names (non-strict mode only)
IF !strict:
IF date =~ DD + Aout + YYYY (French non-ASCII August):
REWRITE month to 'Aug'
ELSE IF date =~ /DD LONG_OR_VARIANT YYYY/:
lookup = MONTH_ALIAS{ucfirst(lc(month))}
IF lookup: REWRITE month to lookup
ELSE IF month is more than 3 letters: CARP and RETURN undef
-- 3-letter unknown months fall through unchanged to the parser
-- Parse with Genealogy::Gedcom::Date (cached) then DateTime::Format::Natural
IF date starts with digit:
d = _date_parser_cached(date)
IF d defined:
RETURN undef if date ends with year-only (< AD100 guard)
rc = DateTime::Format::Natural->parse_datetime(d.canonical)
IF calendar_type != DGREGORIAN:
rc = _convert_calendar(rc, calendar_type, quiet)
RETURN rc
-- Fallback: try DateTime::Format::Natural directly on the raw string
IF date not ~= /^(Abt|ca?)/i AND date =~ /^[\w\s,]+$/:
rc = DateTime::Format::Natural->parse_datetime(date)
IF rc AND success: RETURN rc
ELSE: CARP error
RETURN undef
END FUNCTION
=cut | |||||
| 408 | ||||||
| 409 | sub parse_datetime | |||||
| 410 | { | |||||
| 411 | # Guard against unbounded recursion at every entry point, including the | |||||
| 412 | # class-method normalisation branches below. 'local' restores the counter | |||||
| 413 | # automatically on any return path. Legitimate use bottoms out at depth 1 | |||||
| 414 | # (range-split recursive call); anything deeper is a mutant or a bug. | |||||
| 415 | 491 | 134739 | local $_parse_depth = $_parse_depth + 1; | |||
| 416 | 491 | 1169 | return if $_parse_depth > $MAX_PARSE_DEPTH; | |||
| 417 | ||||||
| 418 | 491 | 1585 | my $self = shift; | |||
| 419 | ||||||
| 420 | # Normalise class-method and bare-function call styles into an object call. | |||||
| 421 | 491 | 1119 | if(!ref($self)) { | |||
| 422 | 22 | 44 | if(@_) { | |||
| 423 | 14 | 29 | return(__PACKAGE__->new()->parse_datetime(@_)); | |||
| 424 | } | |||||
| 425 | 8 | 29 | return(__PACKAGE__->new()->parse_datetime($self)); | |||
| 426 | } elsif(ref($self) eq 'HASH') { | |||||
| 427 | 2 | 6 | return(__PACKAGE__->new()->parse_datetime($self)); | |||
| 428 | } | |||||
| 429 | ||||||
| 430 | # Guard before Params::Get so *our* croak message is what Test::Carp sees. | |||||
| 431 | 467 | 731 | Carp::croak('Usage: ', __PACKAGE__, '::parse_datetime(date => $date)') unless @_; | |||
| 432 | ||||||
| 433 | 463 | 950 | my $params = Params::Get::get_params('date', @_); | |||
| 434 | ||||||
| 435 | # Catch unknown parameter keys early so callers get a clear error rather | |||||
| 436 | # than silent misbehaviour from a typo like 'quet' instead of 'quiet'. | |||||
| 437 | # validate_strict uses a compile-time-imported croak, so we wrap in eval | |||||
| 438 | # and re-throw via Carp::croak (which IS interceptable by Test::Carp). | |||||
| 439 | # Capture $@ into a lexical immediately: a DESTROY method firing between | |||||
| 440 | # the eval and the check would otherwise silently clear the global $@. | |||||
| 441 | 463 | 6353 | my $validate_err; | |||
| 442 | 463 463 448 | 429 987 93770 | eval { validate_strict(schema => \%PARSE_DATETIME_SCHEMA, input => $params); 1 } | |||
| 443 | or $validate_err = $@; | |||||
| 444 | 463 | 5724 | Carp::croak("Invalid parse_datetime parameters: $validate_err") if $validate_err; | |||
| 445 | ||||||
| 446 | 448 | 1284 | if((!ref($params->{'date'})) && (my $date = $params->{'date'})) { | |||
| 447 | # Per-call flags shadow object-level defaults, enabling per-call overrides | |||||
| 448 | # without losing the convenience of constructor-level configuration. | |||||
| 449 | 442 | 1036 | my $quiet = $params->{'quiet'} // $self->{'quiet'}; | |||
| 450 | 442 | 838 | my $strict = $params->{'strict'} // $self->{'strict'}; | |||
| 451 | ||||||
| 452 | # Detect and strip any GEDCOM calendar escape at the front of the string. | |||||
| 453 | # Pattern: @#D<NAME>@ where NAME is uppercase letters/spaces (lazy match | |||||
| 454 | # so it stops at the first closing '@' rather than a later one). | |||||
| 455 | 442 | 370 | my $calendar_type = 'DGREGORIAN'; | |||
| 456 | 442 | 760 | if($date =~ s/^@#D([A-Z ]+?)@\s*//) { | |||
| 457 | 33 | 62 | $calendar_type = 'D' . uc($1); | |||
| 458 | } | |||||
| 459 | ||||||
| 460 | # Approximate-date prefixes (bef/aft/abt) signal "no exact date known", | |||||
| 461 | # so a DateTime object would be misleading. | |||||
| 462 | 442 | 932 | if($date =~ /^(?:bef|aft|abt)\s/i) { | |||
| 463 | 59 | 97 | Carp::carp(_safe_str($date) . ' is invalid, need an exact date to create a DateTime') | |||
| 464 | unless($quiet); | |||||
| 465 | 59 | 3715 | return; | |||
| 466 | } | |||||
| 467 | ||||||
| 468 | # 31 November does not exist; catch it before any parser attempt. | |||||
| 469 | 383 | 670 | if($date =~ /^31\s+Nov/) { | |||
| 470 | 11 | 29 | Carp::carp(_safe_str($date) . ' is invalid, there are only 30 days in November'); | |||
| 471 | 11 | 1899 | return; | |||
| 472 | } | |||||
| 473 | ||||||
| 474 | # Rewrite dash-separated constructs. | |||||
| 475 | # ISO "YYYY-MM-DD" is checked first -- the pattern is fully anchored with | |||||
| 476 | # fixed-width fields so there is zero backtracking. | |||||
| 477 | # A spaced range "X - Y" (spaces required on both sides of the hyphen) | |||||
| 478 | # is converted to "bet X and Y". The \s+ discriminator ensures that a | |||||
| 479 | # bare ISO date (no spaces around the hyphen) never matches this branch, | |||||
| 480 | # eliminating the need for a combined pre-filter. | |||||
| 481 | 372 | 806 | if($date =~ /^(\d{4})-(\d{2})-(\d{2})$/) { | |||
| 482 | 26 | 67 | my ($y, $m, $d) = ($1, $2, $3); | |||
| 483 | 26 | 40 | my $month_idx = $m - 1; | |||
| 484 | # @short_month_names is a 12-element array (indices 0-11). | |||||
| 485 | # Month 00 would yield index -1 (wrapping silently to Dec in Perl). | |||||
| 486 | # Month 13-99 yields undef and a runtime warning. Reject both. | |||||
| 487 | 26 | 73 | if($month_idx < 0 || $month_idx > 11) { | |||
| 488 | 0 | 0 | Carp::carp("Invalid month '$m' in date '$date'") unless $quiet; | |||
| 489 | 0 | 0 | return; | |||
| 490 | } | |||||
| 491 | 26 | 83 | my $month = ucfirst($short_month_names[$month_idx]); | |||
| 492 | 26 | 126 | my $rewritten = "$d $month $y"; | |||
| 493 | 26 | 50 | Carp::carp("Changing date '" . _safe_str($date) . "' to '$rewritten'") unless($quiet); | |||
| 494 | 26 | 863 | $date = $rewritten; | |||
| 495 | } elsif($date =~ /^(.+\d)\s+-\s+(.+\d)$/) { | |||||
| 496 | 9 | 34 | my ($lhs, $rhs) = ($1, $2); | |||
| 497 | 9 | 23 | Carp::carp("Changing date '" . _safe_str($date) . "' to 'bet $lhs and $rhs'") unless($quiet); | |||
| 498 | 9 | 668 | $date = "bet $lhs and $rhs"; | |||
| 499 | } | |||||
| 500 | ||||||
| 501 | # Date ranges return two DateTimes in list context; undef in scalar. | |||||
| 502 | # Lazy .+? on the first group ensures we split at the FIRST "and"/"to" | |||||
| 503 | # keyword, not the last, which matters when a sub-date contains the word. | |||||
| 504 | 372 | 568 | if($date =~ /^bet (.+?) and (.+)/i) { | |||
| 505 | 25 | 41 | if(wantarray) { | |||
| 506 | 11 | 49 | return $self->parse_datetime($1), $self->parse_datetime($2); | |||
| 507 | } | |||||
| 508 | 14 | 32 | return; | |||
| 509 | } | |||||
| 510 | ||||||
| 511 | 347 | 806 | if((!$strict) && ($date =~ /^from (.+?) to (.+)/i)) { | |||
| 512 | 7 | 13 | if(wantarray) { | |||
| 513 | 4 | 8 | return $self->parse_datetime($1), $self->parse_datetime($2); | |||
| 514 | } | |||||
| 515 | 3 | 6 | return; | |||
| 516 | } | |||||
| 517 | ||||||
| 518 | 340 | 713 | if($date !~ /^\d{3,4}$/) { | |||
| 519 | # Strict mode: only 3-letter GEDCOM abbreviations are valid. | |||||
| 520 | 333 | 324 | if($strict) { | |||
| 521 | 30 | 80 | if($date !~ /^\d{1,2}\s+[A-Z]{3}\s+\d{3,4}$/i) { | |||
| 522 | 24 | 44 | Carp::carp('Unparseable date ' . _safe_str($date) . " - often because the month name isn't 3 letters") unless($quiet); | |||
| 523 | 24 | 1429 | return; | |||
| 524 | } | |||||
| 525 | } else { | |||||
| 526 | # Aout with circumflex-u (Ao\x{FB}t) must be matched explicitly | |||||
| 527 | # because the standard \w character class is ASCII-only and will | |||||
| 528 | # not match the non-ASCII 'u with circumflex'. | |||||
| 529 | 303 | 1481 | if($date =~ /^(\d{1,2})\s+Ao\x{FB}t\s+(\d{3,4})$/i) { | |||
| 530 | 7 | 15 | $date = "$1 Aug $2"; | |||
| 531 | } elsif($date =~ /^(\d{1,2})\s+([A-Z]{3,}+)\.?\s+(\d{3,4})$/i) { | |||||
| 532 | # Look up the month in the alias table. | |||||
| 533 | # Unknown 3-letter tokens are passed through unchanged so that | |||||
| 534 | # standard GEDCOM abbreviations (Sep, Dec, ...) that did not | |||||
| 535 | # trigger the earlier cache path still reach the parser. | |||||
| 536 | 218 | 582 | if(my $abbrev = $MONTH_ALIAS{ucfirst(lc($2))}) { | |||
| 537 | 49 | 246 | $date = "$1 $abbrev $3"; | |||
| 538 | } elsif(length($2) > 3) { | |||||
| 539 | # Longer-than-3-letter names not in the alias table are | |||||
| 540 | # truly unrecognised; there is nothing useful we can do. | |||||
| 541 | 3 | 19 | Carp::carp('Unparseable date ' . _safe_str($date) . " - often because the month name isn't 3 letters") unless($quiet); | |||
| 542 | 3 | 384 | return; | |||
| 543 | } | |||||
| 544 | } elsif($date =~ /^(\d{1,2})\-([A-Z]{3})\-(\d{3,4})$/i) { | |||||
| 545 | # Accept the 29-Aug-1938 dash-separated single-date format. | |||||
| 546 | 4 | 17 | $date = "$1 $2 $3"; | |||
| 547 | } | |||||
| 548 | } | |||||
| 549 | ||||||
| 550 | # Lazily initialise DateTime::Format::Natural on first use. | |||||
| 551 | 306 | 1402 | my $dfn = $self->{'dfn'} //= DateTime::Format::Natural->new(); | |||
| 552 | ||||||
| 553 | 306 | 128287 | if(($date =~ /^\d/) && (my $d = $self->_date_parser_cached($date))) { | |||
| 554 | # DateTime::Format::Natural cannot handle dates before AD 100; | |||||
| 555 | # a date ending in a 1-or-2-digit year is that old. | |||||
| 556 | 234 | 522 | return if($date =~ /\s\d{1,2}$/); | |||
| 557 | ||||||
| 558 | # Guard against a malformed GGD result with an undef canonical | |||||
| 559 | # field; passing undef to DFN causes a Params::Validate croak. | |||||
| 560 | 231 | 362 | return unless defined $d->{'canonical'}; | |||
| 561 | ||||||
| 562 | 230 | 640 | my $rc = $dfn->parse_datetime($d->{'canonical'}); | |||
| 563 | ||||||
| 564 | # DFN silently returns today's date when it cannot parse the | |||||
| 565 | # canonical string (e.g. 3-digit years 100-999, very large | |||||
| 566 | # years). We must check success() here just as we do in the | |||||
| 567 | # DFN fallback path below, otherwise the caller receives a | |||||
| 568 | # completely wrong DateTime. | |||||
| 569 | 229 | 1334673 | unless($dfn->success) { | |||
| 570 | 15 | 169 | Carp::carp($dfn->error) unless $quiet; | |||
| 571 | 15 | 382 | return; | |||
| 572 | } | |||||
| 573 | ||||||
| 574 | 214 | 3557 | if($rc && $calendar_type ne 'DGREGORIAN') { | |||
| 575 | 31 | 186 | return _convert_calendar($rc, $calendar_type, $quiet); | |||
| 576 | } | |||||
| 577 | ||||||
| 578 | 183 | 5114 | return $rc; | |||
| 579 | } | |||||
| 580 | ||||||
| 581 | # Last resort: try DateTime::Format::Natural on the raw string. | |||||
| 582 | # Approximate-prefix forms are excluded here because they already | |||||
| 583 | # returned undef above; the pattern here guards against 'Abt'/'ca' | |||||
| 584 | # leaking through when quiet is enabled (e.g. "Abt1Jan2000" has no | |||||
| 585 | # space so it was not caught by the /^abt\s/i check above). | |||||
| 586 | 72 | 338 | if(($date !~ /^(?:Abt|ca?)/i) && ($date =~ /^[\w\s,]+$/)) { | |||
| 587 | 35 | 81 | if(my $rc = $dfn->parse_datetime($date)) { | |||
| 588 | 35 | 4259930 | if($dfn->success()) { | |||
| 589 | 8 | 143 | return $rc; | |||
| 590 | } | |||||
| 591 | 27 | 327 | Carp::carp($dfn->error()) unless($quiet); | |||
| 592 | } | |||||
| 593 | # NOTE: DateTime::Format::Natural->parse_datetime always returns | |||||
| 594 | # a DateTime object (today's date on failure), never undef. The | |||||
| 595 | # else branch that would carp "Can't parse date" is therefore | |||||
| 596 | # dead code and has been removed. If a future DFN version can | |||||
| 597 | # return undef, this else must be reinstated: | |||||
| 598 | # else { Carp::carp("Can't parse date '$date'") unless $quiet } | |||||
| 599 | } | |||||
| 600 | } | |||||
| 601 | 71 | 1493 | return; | |||
| 602 | } | |||||
| 603 | ||||||
| 604 | 6 | 54 | Carp::croak('Usage: ', __PACKAGE__, '::parse_datetime(date => $date)'); | |||
| 605 | } | |||||
| 606 | ||||||
| 607 | # --------------------------------------------------------------------------- | |||||
| 608 | # _date_parser_cached | |||||
| 609 | # | |||||
| 610 | # Purpose: Wrap Genealogy::Gedcom::Date->parse() with a per-object | |||||
| 611 | # memoisation layer to avoid re-parsing identical strings. | |||||
| 612 | # Entry: $self - blessed object (must carry ->{'date_parser'} slot) | |||||
| 613 | # $date - non-empty, non-undef date string (positional) | |||||
| 614 | # Exit: Hashref on success ({canonical, day, month, year, ...}), | |||||
| 615 | # undef on parse failure or error. | |||||
| 616 | # Side Effects: Populates $self->{'all_dates'}{$date} on BOTH success and | |||||
| 617 | # failure. Failure is stored as undef so repeated lookups of | |||||
| 618 | # the same invalid string are O(1) and carp fires only once. | |||||
| 619 | # Carps on parser error unless $self->{'quiet'} is set. | |||||
| 620 | # --------------------------------------------------------------------------- | |||||
| 621 | ||||||
| 622 | sub _date_parser_cached :Protected | |||||
| 623 | { | |||||
| 624 | # Accept the date as a plain positional arg -- this is a hot-path internal | |||||
| 625 | # method called only from parse_datetime. Routing through Params::Get adds | |||||
| 626 | # measurable dispatch overhead for no external-API benefit. | |||||
| 627 | my ($self, $date) = @_; | |||||
| 628 | ||||||
| 629 | Carp::croak('Usage: _date_parser_cached($date)') unless defined $date; | |||||
| 630 | ||||||
| 631 | # Evict the whole cache when it reaches the size limit. A full clear is | |||||
| 632 | # used instead of LRU because genealogy datasets have bounded unique-date | |||||
| 633 | # counts in normal use; LRU overhead is not justified. | |||||
| 634 | if(defined($self->{'all_dates'}) && scalar(keys %{$self->{'all_dates'}}) >= $MAX_CACHE_SIZE) { | |||||
| 635 | $self->{'all_dates'} = {}; | |||||
| 636 | } | |||||
| 637 | ||||||
| 638 | # Short-circuit on any prior result (success OR cached failure). | |||||
| 639 | # 'exists' correctly handles undef values stored for invalid dates. | |||||
| 640 | return $self->{'all_dates'}{$date} if exists $self->{'all_dates'}{$date}; | |||||
| 641 | ||||||
| 642 | my $date_parser = $self->{'date_parser'} //= Genealogy::Gedcom::Date->new(); | |||||
| 643 | ||||||
| 644 | my $parsed_date; | |||||
| 645 | eval { | |||||
| 646 | $parsed_date = $date_parser->parse(date => $date); | |||||
| 647 | }; | |||||
| 648 | ||||||
| 649 | if(my $error = $date_parser->error()) { | |||||
| 650 | Carp::carp(_safe_str($date) . ": '$error'") unless $self->{'quiet'}; | |||||
| 651 | # Cache the failure so subsequent calls for the same string skip GGD | |||||
| 652 | # entirely and do not carp again. | |||||
| 653 | return ($self->{'all_dates'}{$date} = undef); | |||||
| 654 | } | |||||
| 655 | ||||||
| 656 | if((ref($parsed_date) eq 'ARRAY') && @{$parsed_date}) { | |||||
| 657 | return $self->{'all_dates'}{$date} = $parsed_date->[0]; | |||||
| 658 | } | |||||
| 659 | ||||||
| 660 | # Empty or unexpected result -- also cache to prevent repeated GGD calls. | |||||
| 661 | return ($self->{'all_dates'}{$date} = undef); | |||||
| 662 | 11 11 11 | 15245 12 131 | } | |||
| 663 | ||||||
| 664 | # --------------------------------------------------------------------------- | |||||
| 665 | # _convert_calendar | |||||
| 666 | # | |||||
| 667 | # Purpose: Convert a Gregorian DateTime produced by | |||||
| 668 | # Genealogy::Gedcom::Date/DateTime::Format::Natural to the | |||||
| 669 | # calendar indicated by the GEDCOM escape that preceded the date. | |||||
| 670 | # Entry: $dt - DateTime object in Gregorian coordinates | |||||
| 671 | # $calendar_type - normalised escape string (e.g. 'DJULIAN') | |||||
| 672 | # $quiet - truthy to suppress carp on failure | |||||
| 673 | # Exit: Converted DateTime, or the original $dt for unknown types. | |||||
| 674 | # Side Effects: May carp on conversion failure (unless $quiet). | |||||
| 675 | # --------------------------------------------------------------------------- | |||||
| 676 | ||||||
| 677 | sub _convert_calendar :Private | |||||
| 678 | { | |||||
| 679 | my ($dt, $calendar_type, $quiet) = @_; | |||||
| 680 | ||||||
| 681 | if($calendar_type eq 'DJULIAN') { | |||||
| 682 | # Add the historical Julian-to-Gregorian day offset. | |||||
| 683 | my $offset_days = _julian_to_gregorian_offset($dt->year); | |||||
| 684 | return $dt->clone->add(days => $offset_days); | |||||
| 685 | } elsif($calendar_type eq 'DHEBREW') { | |||||
| 686 | # "return" inside eval{} exits the eval block, NOT the enclosing sub, | |||||
| 687 | # so we capture the result in $result and return it afterwards. | |||||
| 688 | # $@ is captured into a lexical immediately after the eval to prevent | |||||
| 689 | # a DESTROY method from clearing the global before we can read it. | |||||
| 690 | my $result; | |||||
| 691 | my $convert_err; | |||||
| 692 | eval { | |||||
| 693 | require DateTime::Calendar::Hebrew; | |||||
| 694 | my $h = DateTime::Calendar::Hebrew->new( | |||||
| 695 | year => $dt->year, | |||||
| 696 | month => $dt->month, | |||||
| 697 | day => $dt->day | |||||
| 698 | ); | |||||
| 699 | $result = DateTime->from_object(object => $h); | |||||
| 700 | 1; | |||||
| 701 | } or $convert_err = $@; | |||||
| 702 | Carp::carp("Hebrew calendar conversion failed: $convert_err") | |||||
| 703 | if $convert_err && !$quiet; | |||||
| 704 | # Return the converted DateTime on success, undef on failure. | |||||
| 705 | # The POD (LIMITATIONS) documents that undef is returned when the | |||||
| 706 | # optional module is unavailable rather than passing back the | |||||
| 707 | # unconverted Gregorian DateTime. | |||||
| 708 | return $result; | |||||
| 709 | } elsif($calendar_type eq 'DFRENCH R') { | |||||
| 710 | my $result; | |||||
| 711 | my $convert_err; | |||||
| 712 | eval { | |||||
| 713 | require DateTime::Calendar::FrenchRevolutionary; | |||||
| 714 | my $f = DateTime::Calendar::FrenchRevolutionary->new( | |||||
| 715 | year => $dt->year, | |||||
| 716 | month => $dt->month, | |||||
| 717 | day => $dt->day | |||||
| 718 | ); | |||||
| 719 | $result = DateTime->from_object(object => $f); | |||||
| 720 | 1; | |||||
| 721 | } or $convert_err = $@; | |||||
| 722 | Carp::carp("French Republican calendar conversion failed: $convert_err") | |||||
| 723 | if $convert_err && !$quiet; | |||||
| 724 | return $result; | |||||
| 725 | } else { | |||||
| 726 | # DROMAN and any other future escape types are not yet supported. | |||||
| 727 | Carp::carp("Calendar type $calendar_type not supported") unless $quiet; | |||||
| 728 | } | |||||
| 729 | ||||||
| 730 | return $dt; | |||||
| 731 | 11 11 11 | 2795 12 94 | } | |||
| 732 | ||||||
| 733 | # --------------------------------------------------------------------------- | |||||
| 734 | # _safe_str | |||||
| 735 | # | |||||
| 736 | # Purpose: Sanitise a user-supplied string for inclusion in diagnostic | |||||
| 737 | # messages (Carp::carp/croak). Removes ASCII control characters | |||||
| 738 | # that could be used for log injection (fake log lines, ANSI | |||||
| 739 | # escape sequences) and truncates to a safe display length. | |||||
| 740 | # Entry: $s - string to sanitise (may be undef) | |||||
| 741 | # $max - optional maximum length (default 120) | |||||
| 742 | # Exit: Sanitised, length-bounded string safe for embedding in messages. | |||||
| 743 | # --------------------------------------------------------------------------- | |||||
| 744 | ||||||
| 745 | sub _safe_str :Private | |||||
| 746 | { | |||||
| 747 | my ($s, $max) = @_; | |||||
| 748 | return '(undef)' unless defined $s; | |||||
| 749 | $max //= 120; | |||||
| 750 | # Replace every ASCII control character (0x00-0x1F, 0x7F) with '?'. | |||||
| 751 | # This neutralises CR/LF log injection and ANSI escape sequences. | |||||
| 752 | (my $clean = $s) =~ s/[[:cntrl:]]/?/g; | |||||
| 753 | return length($clean) <= $max ? $clean : substr($clean, 0, $max - 3) . '...'; | |||||
| 754 | 11 11 11 | 1598 8 90 | } | |||
| 755 | ||||||
| 756 | # --------------------------------------------------------------------------- | |||||
| 757 | # _julian_to_gregorian_offset | |||||
| 758 | # | |||||
| 759 | # Purpose: Return the number of days to add to a Julian date to obtain | |||||
| 760 | # the Gregorian equivalent, based on the year. | |||||
| 761 | # Entry: $year - integer calendar year | |||||
| 762 | # Exit: Integer day offset (10, 11, 12, or 13) | |||||
| 763 | # --------------------------------------------------------------------------- | |||||
| 764 | ||||||
| 765 | sub _julian_to_gregorian_offset :Private | |||||
| 766 | { | |||||
| 767 | my $year = $_[0]; | |||||
| 768 | ||||||
| 769 | # Iterate once with early exit; avoids the temporary list that grep would | |||||
| 770 | # build before we discard all but the first match. | |||||
| 771 | # @JULIAN_OFFSET_TIERS is ordered ascending, so the first hit is correct. | |||||
| 772 | for my $tier (@JULIAN_OFFSET_TIERS) { | |||||
| 773 | return $tier->[1] if $year < $tier->[0]; | |||||
| 774 | } | |||||
| 775 | return 13; # catch-all: 1 Mar 1900 onwards | |||||
| 776 | 11 11 11 | 1328 9 83 | } | |||
| 777 | ||||||
| 778 - 902 | =head1 LIMITATIONS
=over 4
=item *
Dates before AD 100 are rejected because L<DateTime::Format::Natural> cannot
parse them reliably (it returns today's date instead of an error).
=item *
The C<Aout> (French August with circumflex-u) entry in the month-alias table
uses a non-ASCII Unicode escape (C<\x{FB}>). The module file must be read as
UTF-8; this is satisfied by the standard C<perl -Ilib> invocation but may
require C<use utf8> or C<open ':encoding(UTF-8)'> in unusual environments.
=item *
Hebrew and French Republican calendar conversions require
L<DateTime::Calendar::Hebrew> and L<DateTime::Calendar::FrenchRevolutionary>
respectively. These are optional and not listed as hard dependencies. When
absent, the GEDCOM escape is silently discarded and undef is returned unless
the C<quiet> flag is off, in which case a carp is emitted.
=item *
L<Genealogy::Gedcom::Date> cannot parse native Hebrew or French Revolutionary
month names (e.g. C<Tishri>, C<Vendemiaire>). Only dates written in
Gregorian form with the C<@#DHEBREW@> escape are converted.
=item *
The C<quiet> and C<strict> flags may be set at construction time
(C<< ->new(quiet => 1) >>) and will be respected by all subsequent calls to
C<parse_datetime> unless overridden on a per-call basis. The per-call value
always takes precedence.
=back
=head1 AUTHOR
Nigel Horne, C<< <njh at nigelhorne.com> >>
=head1 BUGS
Please report any bugs or feature requests to the author.
This module is provided as-is without any warranty.
=head1 SEE ALSO
=over 4
=item * L<Genealogy::Gedcom::Date>
=item * L<DateTime>
=item * L<DateTime::Format::Natural>
=item * L<Configure an Object at Runtime|Object::Configure>
=item * L<Test Dashboard|https://nigelhorne.github.io/DateTime-Format-Genealogy/coverage/>
=back
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
perldoc DateTime::Format::Genealogy
=over 4
=item * RT: CPAN's request tracker
L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=DateTime-Format-Genealogy>
=item * GitHub Issues
L<https://github.com/nigelhorne/DateTime-Format-Genealogy/issues>
=back
=head1 FORMAL SPECIFICATION
=head2 new
State S ::= [ attrs : Name -> Value ]
new(class, params) = bless(params U configure(class)) when !blessed(class)
new(obj, params) = bless(obj.attrs (+) params) when blessed(obj)
where (+) denotes right-biased union (params values take precedence).
=head2 parse_datetime
Let D be the set of genealogical date strings and DT the co-domain of
L<DateTime> objects.
parse_datetime : D -> DT | bot | (DT x DT)
YearOnly ::= { s in D | s ~ /^\d{3,4}$/ }
Approx ::= { s in D | s ~ /^(bef|aft|abt)\s/i }
Ranges ::= { s in D | s ~ /^bet\s.+\sand\s.+/i
| s ~ /^from\s.+\sto\s.+/i }
Pre: date != bot
Post:
date in YearOnly => result = bot
date in Approx => result = bot (carp unless quiet)
date in Ranges /\ wantarray => result in DT x DT
date in Ranges /\ !wantarray => result = bot
date in Parseable \
(YearOnly u Approx u Ranges) => result in DT
date not in Parseable => result = bot (carp unless quiet)
=head1 LICENSE AND COPYRIGHT
Copyright 2018-2026 Nigel Horne.
Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.
=cut | |||||
| 903 | ||||||
| 904 | 1; | |||||