File Coverage

File:blib/lib/DateTime/Format/Genealogy.pm
Coverage:95.8%

linestmtbrancondsubtimecode
1package 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.
45Readonly 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)
74Readonly 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.
79Readonly 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.
85Readonly my $MAX_PARSE_DEPTH => 10;
86our $_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.
90Readonly 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
106our $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
167sub 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
409sub 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
622sub _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
677sub _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
745sub _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
765sub _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
9041;