File Coverage

blib/lib/Date/Extract/DE.pm
Criterion Covered Total %
statement 136 143 95.1
branch 46 56 82.1
condition 13 18 72.2
subroutine 19 19 100.0
pod 2 2 100.0
total 216 238 90.7


line stmt bran cond sub pod time code
1             package Date::Extract::DE;
2              
3 1     1   1502 use Moose;
  1         483161  
  1         5  
4              
5 1     1   8360 use version; our $VERSION = qv('0.0.6');
  1         2159  
  1         6  
6              
7 1     1   627 use Date::Range;
  1         988  
  1         34  
8 1     1   8 use Date::Simple ( 'date', 'today' );
  1         2  
  1         73  
9 1     1   1030 use DateTime;
  1         503595  
  1         52  
10 1     1   775 use DateTime::Incomplete;
  1         68005  
  1         47  
11 1     1   986 use Regexp::Assemble;
  1         16785  
  1         41  
12              
13 1     1   541 use MooseX::ClassAttribute;
  1         89822  
  1         4  
14              
15 1     1   322464 use utf8;
  1         3  
  1         10  
16              
17 1     1   31 use namespace::autoclean;
  1         3  
  1         10  
18              
19             has 'reference_date',
20             is => 'ro',
21             isa => 'Date::Simple',
22             default => sub { today() };
23             has 'lookback_days',
24             is => 'ro',
25             isa => 'Int';
26              
27             has '_reference_dt',
28             is => 'ro',
29             isa => 'DateTime',
30             lazy => 1,
31             builder => '_build__reference_dt';
32              
33             class_has '_months',
34             is => 'ro',
35             isa => 'HashRef[Int]',
36             traits => [qw/Hash/],
37             handles => { all_months => 'keys', month_nr => 'get' },
38             default => sub {
39             my %months = (
40             'Jänner' => 1,
41             'Jaenner' => 1,
42             'Januar' => 1,
43             'Feber' => 2,
44             'Februar' => 2,
45             'März' => 3,
46             'Maerz' => 3,
47             'April' => 4,
48             'Mai' => 5,
49             'Juni' => 6,
50             'Juno' => 6,
51             'Juli' => 7,
52             'August' => 8,
53             'September' => 9,
54             'Oktober' => 10,
55             'November' => 11,
56             'Dezember' => 12,
57             );
58              
59             for my $m ( keys %months ) {
60             $months{ substr( $m, 0, 3 ) } = $months{$m};
61             }
62             return \%months;
63             };
64              
65             class_has '_days',
66             is => 'ro',
67             isa => 'HashRef[Int]',
68             traits => [qw/Hash/],
69             handles => { all_days => 'keys', day_nr => 'get' },
70             default => sub {
71              
72             my %days = (
73             'ers' => 1,
74             'zwei' => 2,
75             'drit' => 3,
76             'vier' => 4,
77             'fünf' => 5,
78             'fuenf' => 5,
79             'sechs' => 6,
80             'sieb' => 7,
81             'sieben' => 7,
82             'ach' => 8,
83             'neun' => 9,
84             'zehn' => 10,
85             'elf' => 11,
86             'zwölf' => 12,
87             'zwoelf' => 12,
88             );
89             my %prefixes_10 = (
90             'drei' => 3,
91             'vier' => 4,
92             'fünf' => 5,
93             'fuenf' => 5,
94             'sech' => 6,
95             'sieb' => 7,
96             'acht' => 8,
97             'neun' => 9
98             );
99             my %prefixes_20 = (
100             'ein' => 1,
101             'zwei' => 2,
102             'drei' => 3,
103             'vier' => 4,
104             'fünf' => 5,
105             'fuenf' => 5,
106             'sechs' => 6,
107             'sieben' => 7,
108             'acht' => 8,
109             'neun' => 9
110             );
111              
112             for my $p ( keys %prefixes_10 ) {
113             $days{ $p . 'zehn' } = $prefixes_10{$p} + 10;
114             }
115             $days{zwanzigs} = 20;
116             for my $p ( keys %prefixes_20 ) {
117             $days{ $p . 'undzwanzigs' } = $prefixes_20{$p} + 20;
118             }
119              
120             $days{'dreißigs'} = $days{dreissigs} = 30;
121             $days{'einunddreißigs'} = $days{einunddreissigs} = 31;
122              
123             my %result;
124              
125             for my $d ( keys %days ) {
126             $result{ $d . 'te' } = $result{ $d . 'ten' } =
127             $result{ $d . 'ter' } = $days{$d};
128             }
129             return \%result;
130              
131             };
132              
133             sub _build__reference_dt {
134 1     1   4 my ($self) = @_;
135              
136 1         31 return DateTime->new(
137             year => $self->reference_date->year,
138             month => $self->reference_date->month,
139             day => $self->reference_date->day,
140             hour => 0,
141             minute => 0,
142             second => 0,
143             nanosecond => 0,
144             time_zone => 'Europe/Vienna',
145             );
146             }
147              
148             sub _translate_month {
149 56     56   143 my ( $self, $month ) = @_;
150 56         165 $month =~ s/\W//g;
151 56         212 $month = ucfirst( lc $month ); # TODO: explore casefold here
152 56 100       2047 return $self->month_nr($month) if $self->month_nr($month);
153 8         33 return int $month;
154             }
155              
156             sub _translate_day {
157 72     72   201 my ( $self, $day ) = @_;
158 72         343 $day =~ s/\W//g;
159 72 100       3354 return $self->day_nr( lc $day ) if $self->day_nr( lc $day );
160 64         336 return int $day;
161             }
162              
163             sub _translate_year {
164 14     14   35 my ( $self, $year ) = @_;
165 14         39 $year =~ s/\W//g;
166 14         38 $year = int $year;
167 14 100       44 if ( $year < 30 ) {
    100          
168 2         3 $year += 2000;
169             }
170             elsif ( $year < 100 ) {
171 2         13 $year += 1900;
172             }
173 14         37 return $year;
174             }
175              
176             sub _guess_full_date {
177 38     38   104 my ( $self, $dt ) = @_;
178              
179 38         1301 my $cand = $dt->closest( $self->_reference_dt );
180              
181 38         583204 my $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
182 38 100 66     2345 if ( ( defined $self->lookback_days )
      100        
183             && ( $result < $self->reference_date )
184             && ( ( $self->reference_date - $result ) > $self->lookback_days ) ) {
185 6         168 $cand = $dt->next( $self->_reference_dt );
186 6         19157 $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
187             }
188 38         516 return $result;
189             }
190              
191             sub _process_date {
192 50     50   132 my ( $self, $date ) = @_;
193 50         105 my @dates;
194              
195 50 100       122 if ( exists $date->{conjugator} ) {
196 18 100       87 $date->{month2} = $date->{month1} unless $date->{month2};
197 18 100 66     131 if ( !$date->{year1} && !$date->{year2} ) {
    100 66        
    50 33        
198             my $dti1 = DateTime::Incomplete->new(
199             month => $date->{month1},
200             day => $date->{day1},
201 14         98 );
202 14         443 $date->{year1} = $self->_guess_full_date($dti1)->year();
203 14         56 $dti1->set( year => $date->{year1} );
204             my $dti2 = DateTime::Incomplete->new(
205             year => $date->{year1},
206             month => $date->{month2},
207             day => $date->{day2},
208 14         401 );
209 14 100       350 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
210 2         4471 $date->{year2} = 1 + $date->{year1};
211             }
212             else {
213 12         26428 $date->{year2} = $date->{year1};
214             }
215             }
216             elsif ( $date->{year1} && !$date->{year2} ) {
217             my $dti1 = DateTime::Incomplete->new(
218             year => $date->{year1},
219             month => $date->{month1},
220             day => $date->{day1},
221 2         15 );
222             my $dti2 = DateTime::Incomplete->new(
223             year => $date->{year1},
224             month => $date->{month2},
225             day => $date->{day2},
226 2         60 );
227 2 50       42 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
228 0         0 $date->{year2} = 1 + $date->{year1};
229             }
230             else {
231 2         4589 $date->{year2} = $date->{year1};
232             }
233             }
234             elsif ( !$date->{year1} && $date->{year2} ) {
235             my $dti1 = DateTime::Incomplete->new(
236             year => $date->{year2},
237             month => $date->{month1},
238             day => $date->{day1},
239 0         0 );
240             my $dti2 = DateTime::Incomplete->new(
241             year => $date->{year2},
242             month => $date->{month2},
243             day => $date->{day2},
244 0         0 );
245 0 0       0 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
246 0         0 $date->{year1} = $date->{year2} - 1;
247             }
248             else {
249 0         0 $date->{year1} = $date->{year2};
250             }
251             }
252             else {
253             }
254 18 100       91 if ( $date->{conjugator} eq 'range' ) {
    50          
255             my $range = Date::Range->new(
256             Date::Simple::ymd(
257             $date->{year1}, $date->{month1}, $date->{day1}
258             ),
259             Date::Simple::ymd(
260             $date->{year2}, $date->{month2}, $date->{day2}
261             )
262 8         42 );
263             push @dates,
264 8         513 map { { date => $_, context => $date->{date} } }
  32         1114  
265             $range->dates;
266             }
267             elsif ( $date->{conjugator} eq 'enum' ) {
268             push @dates,
269             {
270             date => Date::Simple::ymd(
271             $date->{year1}, $date->{month1}, $date->{day1}
272             ),
273             context => $date->{date}
274             },
275             {
276             date => Date::Simple::ymd(
277             $date->{year2}, $date->{month2}, $date->{day2}
278             ),
279             context => $date->{date}
280 10         49 };
281             }
282 18 100 100     285 if ( ( $date->{conjugator} ne 'range' ) && ( $date->{days0} ) ) {
283 2         18 for my $d ( reverse split /[^\d]+/, $date->{days0} ) {
284             unshift @dates,
285             {
286             date => Date::Simple::ymd(
287             $date->{year2}, $date->{month2},
288             $self->_translate_day($d)
289             ),
290             context => $date->{date}
291 4         37 };
292             }
293             }
294             }
295             else {
296 32 100       88 if ( !$date->{year1} ) {
297              
298             # guesswork
299             my $dti = DateTime::Incomplete->new(
300             month => $date->{month1},
301             day => $date->{day1}
302 24         156 );
303             push @dates,
304             {
305             date => $self->_guess_full_date($dti),
306             context => $date->{date}
307 24         735 };
308             }
309             else {
310             push @dates,
311             {
312             date => Date::Simple::ymd(
313             $date->{year1}, $date->{month1}, $date->{day1}
314             ),
315             context => $date->{date}
316 8         31 };
317             }
318             }
319 50         293 return @dates;
320             }
321              
322             sub extract_with_context {
323 38     38 1 15917 my ( $self, $text ) = @_;
324 38         69 my @found_dates;
325              
326 38         119 my @enum = ( '\+', ',', 'oder', 'o\.' );
327 38         82 my @and = ( 'und', 'u\.' );
328 38         70 my @range = ( '-', 'bis(?:\s*zum)?', );
329 38         1693 my @months = $self->all_months;
330 38         1373 my @days = $self->all_days;
331 38         254 my $monthlist = Regexp::Assemble->new();
332 38         2545 $monthlist->add(@months);
333 38         100480 $monthlist->add('(?:(?:0?[1-9])|(?:1[0-2]))\.');
334 38         9327 my $month_regex = qr/$monthlist/i;
335              
336 38         163444 my $daylist = Regexp::Assemble->new();
337 38         2050 $daylist->add(@days);
338              
339             # once turned into a regex it no longer honors the i switch set on a
340             # containing regex.
341 38         612165 my $between_regex = qr/[Zz]wischen/;
342 38         277 my $day_regex =
343             qr/(?:$daylist)|(?:(?:(?:0?[1-9])|(?:[1-2][0-9])|(?:3[0-1]))\.)/i;
344 38         597228 my $year_regex = qr/(?:\d\d?|\')?\d{2}/;
345 38         210 my $conjugator_enum = join '|', @enum;
346 38         109 my $conjugator_and = join '|', @and;
347 38         120 my $conjugator_range = join '|', @range;
348 38         344 my $conjugator_regex = qr/(?:
349             (?<enum>$conjugator_enum)
350             |
351             (?<and>$conjugator_and)
352             |
353             (?<range>$conjugator_range)
354             )/ix;
355              
356 38         2393 my $date_regex = qr/\b(?:
357             (?<between>$between_regex)?\s*
358             (?<day1>$day_regex)\s*
359             (?<month1>$month_regex)\s*
360             (?<year1>$year_regex)?\s*
361             $conjugator_regex\s*
362             (?<day2>$day_regex)\s*
363             (?<month2>$month_regex)\s*
364             (?<year2>$year_regex)?\s*
365             |
366             (?<between>$between_regex)?\s*
367             (?<days0>($day_regex(?:\s*\,\s*))*)
368             (?<day1>$day_regex)\s*
369             (?:
370             $conjugator_regex\s*
371             (?<day2>$day_regex)\s*
372             )?
373             (?<month1>$month_regex)\s*
374             (?<year1>$year_regex)?
375             )/x;
376              
377 38         6053 while ( $text =~ m/(?<date>$date_regex)/g ) {
378 1     1   2906 my $date = {%+};
  1         485  
  1         471  
  50         1549  
379             eval {
380 50         127 for my $c (qw(enum range and)) {
381 150 100       374 if ( exists $date->{$c} ) {
382 18 100       78 if ( $c eq 'and' ) {
383 12 100       39 if ( exists $date->{between} ) {
384 2         9 $date->{conjugator} = 'range';
385             }
386             else {
387 10         34 $date->{conjugator} = 'enum';
388             }
389             }
390             else {
391 6         21 $date->{conjugator} = $c;
392             }
393             }
394             }
395             $date->{day1} = $self->_translate_day( $date->{day1} )
396 50 50       290 if $date->{day1};
397             $date->{day2} = $self->_translate_day( $date->{day2} )
398 50 100       189 if $date->{day2};
399             $date->{month1} = $self->_translate_month( $date->{month1} )
400 50 50       252 if $date->{month1};
401             $date->{month2} = $self->_translate_month( $date->{month2} )
402 50 100       158 if $date->{month2};
403             $date->{year1} = $self->_translate_year( $date->{year1} )
404 50 100       168 if $date->{year1};
405             $date->{year2} = $self->_translate_year( $date->{year2} )
406 50 100       154 if $date->{year2};
407 50         122 push @found_dates, $date;
408 50         3097 1;
409 50 50       254 } or do {
410 0 0       0 warn "$text\n\n$@" if $@;
411             };
412              
413             }
414              
415 38         118 my @adjusted_dates;
416 38         123 foreach (@found_dates) {
417 50         413 $_->{date} =~ s/(?:^\s+)|(?:\s+$)//g;
418 50         261 push @adjusted_dates, $self->_process_date($_);
419             }
420 38         1266 return \@adjusted_dates;
421             }
422              
423             sub extract {
424 19     19 1 27690 my ( $self, $text ) = @_;
425              
426 19         70 my $extract_info = $self->extract_with_context($text);
427 19         74 return [ map { $_->{date} } grep { $_->{date} } @$extract_info ];
  44         249  
  44         310  
428             }
429              
430             __PACKAGE__->meta->make_immutable();
431              
432             1;
433              
434             __END__
435              
436             =head1 NAME
437              
438             Date::Extract::DE - Extract dates from german text
439              
440             =head1 VERSION
441              
442             0.0.6
443              
444             =begin readme
445              
446             =head1 INSTALLATION
447              
448             To install this module, run the following commands:
449              
450             perl Build.PL
451             ./Build
452             ./Build test
453             ./Build install
454              
455             =end readme
456              
457             =for test_synopsis
458             my $reference_date;
459             my $lookback_days;
460             my $text;
461              
462             =head1 SYNOPSIS
463              
464             use Date::Extract::DE;
465             my $parser = Date::Extract::DE->new( reference_date => $reference_date );
466             my $dates = $parser->extract($text);
467             my $infos = $parser->extract_with_context($text);
468             printf("%s => %s\n", $_->{context}, $_->{date}) foreach @$infos;
469              
470             =head1 DESCRIPTION
471              
472             This is a module to extract dates from german text (similar to L<Date::Extract>).
473              
474             =head1 METHODS
475              
476             =over 4
477              
478             =item new(reference_date => $reference_date, lookback_days => $lookback_days)
479              
480             Creates a new instance. Optionally, you can specify a reference Date::Simple
481             which is used to determine the year when a date is given incompletely in the
482             text (default is today). You can also specify a maximum numer of days to look
483             back when an incomplete date is guessed (otherwise the closest date is used)
484              
485             =item extract($text)
486              
487             Tries to extract dates from the text and returns an arrayref of L<Date::Simple>
488             instances
489              
490             =item extract_with_context($text)
491              
492             Tries to extract dates from the text and returns an arrayref of HashRef
493             instances. Each HashRef contains a key 'date' which maps to a L<Date::Simple>
494             instance, and a key 'context' mapping to the date string found in the original
495             text
496              
497             =back
498              
499             =head1 AUTHORS
500              
501             =over 4
502              
503             =item Andreas Mager C<< <quattro at cpan org> >>
504              
505             =item Christian Eder C<< <christian.eder@apa.at> >>
506              
507             =back
508              
509             =head1 LICENCE AND COPYRIGHT
510              
511             Copyright (c) 2020, APA-IT. All rights reserved.
512              
513             This module is free software; you can redistribute it and/or modify it
514             under the terms of the GNU General Public License as published by the
515             Free Software Foundation; either version 2 of the License, or (at your
516             option) any later version.
517              
518             This module is distributed in the hope that it will be useful, but
519             WITHOUT ANY WARRANTY; without even the implied warranty of
520             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
521             General Public License for more details.
522              
523             You should have received a copy of the GNU General Public License
524             along with this module. If not, see L<http://www.gnu.org/licenses/>.