• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nigelhorne / DateTime-Format-Genealogy / 13268391056

11 Feb 2025 05:04PM UTC coverage: 87.736% (-7.0%) from 94.737%
13268391056

push

github

nigelhorne
Added Coveralls support

93 of 106 relevant lines covered (87.74%)

17.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.74
/lib/DateTime/Format/Genealogy.pm
1
package DateTime::Format::Genealogy;
2

3
# Author Nigel Horne: njh@bandsman.co.uk
4
# Copyright (C) 2018-2024, Nigel Horne
5

6
# Usage is subject to licence terms.
7
# The licence terms of this software are as follows:
8
# Personal single user, single computer use: GPL2
9
# All other users (including Commercial, Charity, Educational, Government)
10
#        must apply in writing for a licence for use from Nigel Horne at the
11
#        above e-mail.
12

13
use strict;
5✔
14
use warnings;
5✔
15
# use diagnostics;
16
# use warnings::unused;
17
use 5.006_001;
5✔
18

19
use namespace::clean;
5✔
20
use Carp;
5✔
21
use DateTime::Format::Natural;
5✔
22
use Genealogy::Gedcom::Date 2.01;
5✔
23
use Scalar::Util;
5✔
24

25
our %months = (
26
        'January' => 'Jan',
27
        'February' => 'Feb',
28
        'March' => 'Mar',
29
        'April' => 'Apr',
30
        # 'May' => 'May',
31
        'June' => 'Jun',
32
        'July' => 'Jul',
33
        'August' => 'Aug',
34
        'September' => 'Sep',
35
        'Sept' => 'Sep',
36
        'Sept.' => 'Sep',
37
        'October' => 'Oct',
38
        'November' => 'Nov',
39
        'December' => 'Dec'
40
);
41

42
our @months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec');
43

44
=head1 NAME
45

46
DateTime::Format::Genealogy - Create a DateTime object from a Genealogy Date
47

48
=head1 VERSION
49

50
Version 0.10
51

52
=cut
53

54
our $VERSION = '0.10';
55

56
=head1 SYNOPSIS
57

58
C<DateTime::Format::Genealogy> is a Perl module designed to parse genealogy-style date formats and convert them into L<DateTime> objects.
59
It uses L<Genealogy::Gedcom::Date> to parse dates commonly found in genealogical records while also handling date ranges and approximate dates.
60

61
    use DateTime::Format::Genealogy;
62
    my $dtg = DateTime::Format::Genealogy->new();
63
    # ...
64

65
=head1 SUBROUTINES/METHODS
66

67
=head2 new
68

69
Creates a DateTime::Format::Genealogy object.
70

71
=cut
72

73
sub new
74
{
75
        my $class = shift;
23✔
76

77
        # Handle hash or hashref arguments
78
        my %args;
23✔
79
        if((@_ == 1) && (ref $_[0] eq 'HASH')) {
23✔
80
                # If the first argument is a hash reference, dereference it
81
                %args = %{$_[0]};
1✔
82
        } elsif((@_ % 2) == 0) {
83
                # If there is an even number of arguments, treat them as key-value pairs
84
                %args = @_;
22✔
85
        } else {
86
                # If there is an odd number of arguments, treat it as an error
87
                carp(__PACKAGE__, ': Invalid arguments passed to new()');
×
88
                return;
×
89
        }
90

91
        if(!defined($class)) {
23✔
92
                # FIXME: this only works when no arguments are given
93
                $class = __PACKAGE__;
1✔
94
        } elsif(Scalar::Util::blessed($class)) {
95
                # If $class is an object, clone it with new arguments
96
                return bless { %{$class}, %args }, ref($class);
2✔
97
        }
98
        # Return the blessed object
99
        return bless { %args }, $class;
21✔
100
}
101

102
=head2 parse_datetime($string)
103

104
Given a date,
105
runs it through L<Genealogy::Gedcom::Date> to create a L<DateTime> object.
106
If a date range is given, return a two-element array in array context, or undef in scalar context
107

108
Returns undef if the date can't be parsed,
109
is before AD100,
110
is just a year or if it is an approximate date starting with "c", "ca" or "abt".
111
Can be called as a class or object method.
112

113
    my $dt = DateTime::Format::Genealogy->new()->parse_datetime('25 Dec 2022');
114

115
Mandatory arguments:
116

117
=over 4
118

119
=item * C<date>
120

121
The date to be parsed.
122

123
=back
124

125
Optional arguments:
126

127
=over 4
128

129
=item * C<quiet>
130

131
Set to fail silently if there is an error with the date.
132

133
=item * C<strict>
134

135
More strictly enforce the Gedcom standard,
136
for example,
137
don't allow long month names.
138

139
=back
140

141
=cut
142

143
sub parse_datetime {
144
        my $self = shift;
66✔
145

146
        if(!ref($self)) {
66✔
147
                if(scalar(@_)) {
11✔
148
                        return(__PACKAGE__->new()->parse_datetime(@_));
7✔
149
                }
150
                return(__PACKAGE__->new()->parse_datetime($self));
4✔
151
        } elsif(ref($self) eq 'HASH') {
152
                return(__PACKAGE__->new()->parse_datetime($self));
1✔
153
        }
154

155
        my $params = $self->_get_params('date', @_);
54✔
156

157
        if((!ref($params->{'date'})) && (my $date = $params->{'date'})) {
52✔
158
                my $quiet = $params->{'quiet'};
49✔
159

160
                # TODO: Needs much more sanity checking
161
                if(($date =~ /^bef\s/i) || ($date =~ /^aft\s/i) || ($date =~ /^abt\s/i)) {
49✔
162
                        Carp::carp("$date is invalid, need an exact date to create a DateTime")
6✔
163
                                unless($quiet);
164
                        return;
6✔
165
                }
166
                if($date =~ /^31\s+Nov/) {
43✔
167
                        Carp::carp("$date is invalid, there are only 30 days in November");
2✔
168
                        return;
2✔
169
                }
170
                if($date =~ /^\s*(.+\d\d)\s*\-\s*(.+\d\d)\s*$/) {
41✔
171
                        if($date =~ /^(\d{4})\-(\d{2})\-(\d{2})$/) {
4✔
172
                                my $month = $months[$2 - 1];
2✔
173
                                Carp::carp("Changing date '$date' to '$3 $month $1'") unless($quiet);
2✔
174
                                $date = "$3 $month $1";
2✔
175
                        } else {
176
                                Carp::carp("Changing date '$date' to 'bet $1 and $2'") unless($quiet);
2✔
177
                                $date = "bet $1 and $2";
2✔
178
                        }
179
                }
180
                if($date =~ /^bet (.+) and (.+)/i) {
41✔
181
                        if(wantarray) {
4✔
182
                                return $self->parse_datetime($1), $self->parse_datetime($2);
2✔
183
                        }
184
                        return;
2✔
185
                }
186

187
                my $strict = $params->{'strict'};
37✔
188
                if((!$strict) && ($date =~ /^from (.+) to (.+)/i)) {
37✔
189
                        if(wantarray) {
×
190
                                return $self->parse_datetime($1), $self->parse_datetime($2);
×
191
                        }
192
                        return;
×
193
                }
194

195
                if($date !~ /^\d{3,4}$/) {
37✔
196
                        if($strict) {
36✔
197
                                if($date !~ /^(\d{1,2})\s+([A-Z]{3})\s+(\d{3,4})$/i) {
4✔
198
                                        Carp::carp("Unparseable date $date - often because the month name isn't 3 letters") unless($quiet);
4✔
199
                                        return;
4✔
200
                                }
201
                        } elsif($date =~ /^(\d{1,2})\s+([A-Z]{4,}+)\.?\s+(\d{3,4})$/i) {
202
                                if(my $abbrev = $months{ucfirst(lc($2))}) {
6✔
203
                                        $date = "$1 $abbrev $3";
3✔
204
                                } elsif($2 eq 'Janv') {
205
                                        # I've seen a tree that uses some French months
206
                                        $date = "$1 Jan $3";
1✔
207
                                } elsif($2 eq 'Juli') {
208
                                        $date = "$1 Jul $3";
1✔
209
                                } else {
210
                                        Carp::carp("Unparseable date $date - often because the month name isn't 3 letters") unless($quiet);
1✔
211
                                        return;
1✔
212
                                }
213
                        } elsif($date =~ /^(\d{1,2})\s+Mai\s+(\d{3,4})$/i) {
214
                                # I've seen a tree that uses some French months
215
                                $date = "$1 May $2";
1✔
216
                        } elsif($date =~ /^(\d{1,2})\s+Aoรปt\s+(\d{3,4})$/i) {
217
                                # I've seen a tree that uses some French months
218
                                $date = "$1 Aug $2";
×
219
                        } elsif($date =~ /^(\d{1,2})\-([A-Z]{3})\-(\d{3,4})$/i) {
220
                                # 29-Aug-1938
221
                                $date = "$1 $2 $3";
1✔
222
                        }
223

224
                        my $dfn = $self->{'dfn'};
31✔
225
                        if(!defined($dfn)) {
31✔
226
                                $self->{'dfn'} = $dfn = DateTime::Format::Natural->new();
15✔
227
                        }
228
                        if(($date =~ /^\d/) && (my $d = $self->_date_parser_cached($date))) {
31✔
229
                                # D:T:Natural doesn't seem to work before AD100
230
                                return if($date =~ /\s\d{1,2}$/);
27✔
231
                                return $dfn->parse_datetime($d->{'canonical'});
26✔
232
                        }
233
                        if(($date !~ /^(Abt|ca?)/i) && ($date =~ /^[\w\s,]+$/)) {
4✔
234
                                # ACOM exports full month names and non-standard format dates e.g. U.S. format MMM, DD YYYY
235
                                # TODO: allow that when not in strict mode
236
                                if(my $rc = $dfn->parse_datetime($date)) {
4✔
237
                                        if($dfn->success()) {
4✔
238
                                                return $rc;
1✔
239
                                        }
240
                                        Carp::carp($dfn->error()) unless($quiet);
3✔
241
                                } else {
242
                                        Carp::carp("Can't parse date '$date'") unless($quiet);
×
243
                                }
244
                        }
245
                }
246
                return;        # undef
4✔
247
        }
248
        Carp::croak('Usage: ', __PACKAGE__, '::parse_datetime(date => $date)');
3✔
249
}
250

251
# Parse Gedcom format dates
252
# Genealogy::Gedcom::Date is expensive, so cache results
253
sub _date_parser_cached
254
{
255
        my $self = shift;
27✔
256
        my $params = $self->_get_params('date', @_);
27✔
257
        my $date = $params->{'date'};
27✔
258

259
        Carp::croak('Usage: _date_parser_cached(date => $date)') unless defined $date;
27✔
260

261
        # Check and return if the date has already been parsed and cached
262
        return $self->{'all_dates'}{$date} if exists $self->{'all_dates'}{$date};
27✔
263

264
        # Initialize the date parser if not already set
265
        my $date_parser = $self->{'date_parser'} ||= Genealogy::Gedcom::Date->new();
24✔
266

267
        # Parse the date
268
        my $parsed_date;
24✔
269
        eval {
24✔
270
                $parsed_date = $date_parser->parse(date => $date);
24✔
271
        };
272

273
        # Check for errors
274
        if(my $error = $date_parser->error()) {
24✔
275
                Carp::carp("$date: '$error'") unless $self->{'quiet'};
×
276
                return;
×
277
        }
278

279
        # Cache and return the first parsed date if it's an array reference
280
        if((ref($parsed_date) eq 'ARRAY') && @{$parsed_date}) {
24✔
281
                return $self->{'all_dates'}{$date} = $parsed_date->[0];
24✔
282
        }
283

284
        return;
×
285
}
286

287
# Helper routine to parse the arguments given to a function.
288
# Processes arguments passed to methods and ensures they are in a usable format,
289
#        allowing the caller to call the function in anyway that they want
290
#        e.g. foo('bar'), foo(arg => 'bar'), foo({ arg => 'bar' }) all mean the same
291
#        when called _get_params('arg', @_);
292
sub _get_params
293
{
294
        shift;  # Discard the first argument (typically $self)
81✔
295
        my $default = shift;
81✔
296

297
        # Directly return hash reference if the first parameter is a hash reference
298
        return $_[0] if(ref($_[0]) eq 'HASH');
81✔
299

300
        my %rc;
64✔
301
        my $num_args = scalar @_;
64✔
302

303
        # Populate %rc based on the number and type of arguments
304
        if(($num_args == 1) && (defined $default)) {
64✔
305
                # %rc = ($default => shift);
306
                return { $default => shift };
50✔
307
        } elsif($num_args == 1) {
308
                Carp::croak('Usage: ', __PACKAGE__, '->', (caller(1))[3], '()');
×
309
        } elsif(($num_args == 0) && (defined($default))) {
310
                Carp::croak('Usage: ', __PACKAGE__, '->', (caller(1))[3], "($default => \$val)");
2✔
311
        } elsif(($num_args % 2) == 0) {
312
                %rc = @_;
12✔
313
        } elsif($num_args == 0) {
314
                return;
×
315
        } else {
316
                Carp::croak('Usage: ', __PACKAGE__, '->', (caller(1))[3], '()');
×
317
        }
318

319
        return \%rc;
12✔
320
}
321

322
1;
323

324
=head1 AUTHOR
325

326
Nigel Horne, C<< <njh at bandsman.co.uk> >>
327

328
=head1 BUGS
329

330
Please report any bugs or feature requests to the author.
331
This module is provided as-is without any warranty.
332

333
I can't get L<DateTime::Format::Natural> to work on dates before AD100,
334
so this module rejects dates that are that old.
335

336
=head1 SEE ALSO
337

338
L<Genealogy::Gedcom::Date> and
339
L<DateTime>
340

341
=head1 SUPPORT
342

343
You can find documentation for this module with the perldoc command.
344

345
    perldoc DateTime::Format::Genealogy
346

347
You can also look for information at:
348

349
=over 4
350

351
=item * RT: CPAN's request tracker
352

353
L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=DateTime-Format-Genealogy>
354

355
=back
356

357
=head1 LICENSE AND COPYRIGHT
358

359
Copyright 2018-2025 Nigel Horne.
360

361
This program is released under the following licence: GPL2
362

363
=cut
364

365
1;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc