File Coverage

blib/lib/Date/Extract/DE.pm
Criterion Covered Total %
statement 106 116 91.3
branch 31 46 67.3
condition 9 21 42.8
subroutine 15 15 100.0
pod 1 1 100.0
total 162 199 81.4


line stmt bran cond sub pod time code
1             package Date::Extract::DE;
2              
3 1     1   1422 use Moose;
  1         486952  
  1         6  
4              
5 1     1   8613 use version; our $VERSION = qv('0.0.4');
  1         2037  
  1         7  
6              
7 1     1   645 use Date::Range;
  1         1010  
  1         37  
8 1     1   8 use Date::Simple ( 'date', 'today' );
  1         2  
  1         77  
9 1     1   918 use DateTime;
  1         511247  
  1         47  
10 1     1   640 use DateTime::Incomplete;
  1         62308  
  1         40  
11              
12 1     1   563 use MooseX::ClassAttribute;
  1         87603  
  1         4  
13              
14 1     1   318088 use utf8;
  1         3  
  1         10  
15              
16 1     1   47 use namespace::autoclean;
  1         2  
  1         11  
17              
18             has 'reference_date',
19             is => 'ro',
20             isa => 'Date::Simple',
21             default => sub { today() };
22             has 'lookback_days',
23             is => 'ro',
24             isa => 'Int';
25              
26             has '_reference_dt',
27             is => 'ro',
28             isa => 'DateTime',
29             lazy => 1,
30             builder => '_build__reference_dt';
31              
32             class_has '_months',
33             is => 'ro',
34             isa => 'HashRef[Int]',
35             traits => [qw/Hash/],
36             handles => { all_months => 'keys', month_nr => 'get' },
37             default => sub {
38             my %months = (
39             'Jänner' => 1,
40             'Jannuar' => 1,
41             'Feber' => 2,
42             'Februar' => 2,
43             'März' => 3,
44             'April' => 4,
45             'Mai' => 5,
46             'Juni' => 6,
47             'Juli' => 7,
48             'August' => 8,
49             'September' => 9,
50             'Oktober' => 10,
51             'November' => 11,
52             'Dezember' => 12,
53             );
54              
55             for my $m ( keys %months ) {
56             $months{ substr( $m, 0, 3 ) } = $months{$m};
57             }
58             return \%months;
59             };
60              
61             sub _build__reference_dt {
62 1     1   4 my ($self) = @_;
63              
64 1         30 return DateTime->new(
65             year => $self->reference_date->year,
66             month => $self->reference_date->month,
67             day => $self->reference_date->day,
68             hour => 0,
69             minute => 0,
70             second => 0,
71             nanosecond => 0,
72             time_zone => 'Europe/Vienna',
73             );
74             }
75              
76             sub _translate_month {
77 16     16   75 my ( $self, $month ) = @_;
78 16         45 $month =~ s/\W//g;
79 16         53 $month = ucfirst( lc $month ); # TODO: explore casefold here
80 16 100       615 return $self->month_nr($month) if $self->month_nr($month);
81 1         4 $month = int $month;
82 1 50 33     15 return $month if $month && $month >= 1 && $month <= 12;
      33        
83 0         0 return;
84             }
85              
86             sub _guess_full_date {
87 14     14   23 my ( $self, $dt ) = @_;
88              
89 14         414 my $cand = $dt->closest( $self->_reference_dt );
90 14         209786 my $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
91 14 100 66     773 if ( ( defined $self->lookback_days )
      66        
92             && ( $result < $self->reference_date )
93             && ( ( $self->reference_date - $result ) > $self->lookback_days ) ) {
94 1         58 $cand = $dt->next( $self->_reference_dt );
95 1         3246 $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
96             }
97 14         122 return $result;
98             }
99              
100             sub _process_date {
101 15     15   30 my ( $self, $date ) = @_;
102 15         21 my @dates;
103              
104 15 100       31 if ( exists $date->{conjugator} ) {
105 4 100       15 $date->{month2} = $date->{month1} unless $date->{month2};
106 4 100 66     27 if ( !$date->{year1} && !$date->{year2} ) {
    50 33        
    0 0        
107             my $dti1 = DateTime::Incomplete->new(
108             month => $date->{month1},
109             day => $date->{day1},
110 3         16 );
111 3         72 $date->{year1} = $self->_guess_full_date($dti1)->year();
112 3         13 $dti1->set( year => $date->{year1} );
113             my $dti2 = DateTime::Incomplete->new(
114             year => $date->{year1},
115             month => $date->{month2},
116             day => $date->{day2},
117 3         76 );
118 3 50       71 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
119 0         0 $date->{year2} = 1 + $date->{year1};
120             }
121             else {
122 3         6881 $date->{year2} = $date->{year1};
123             }
124             }
125             elsif ( $date->{year1} && !$date->{year2} ) {
126             my $dti1 = DateTime::Incomplete->new(
127             year => $date->{year1},
128             month => $date->{month1},
129             day => $date->{day1},
130 1         7 );
131             my $dti2 = DateTime::Incomplete->new(
132             year => $date->{year1},
133             month => $date->{month2},
134             day => $date->{day2},
135 1         31 );
136 1 50       22 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
137 0         0 $date->{year2} = 1 + $date->{year1};
138             }
139             else {
140 1         2158 $date->{year2} = $date->{year1};
141             }
142             }
143             elsif ( !$date->{year1} && $date->{year2} ) {
144             my $dti1 = DateTime::Incomplete->new(
145             year => $date->{year2},
146             month => $date->{month1},
147             day => $date->{day1},
148 0         0 );
149             my $dti2 = DateTime::Incomplete->new(
150             year => $date->{year2},
151             month => $date->{month2},
152             day => $date->{day2},
153 0         0 );
154 0 0       0 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
155 0         0 $date->{year1} = $date->{year2} - 1;
156             }
157             else {
158 0         0 $date->{year1} = $date->{year2};
159             }
160             }
161             else {
162             }
163 4 100       17 if ( $date->{conjugator} eq 'range' ) {
    50          
164             my $range = Date::Range->new(
165             Date::Simple::ymd(
166             $date->{year1}, $date->{month1}, $date->{day1}
167             ),
168             Date::Simple::ymd(
169             $date->{year2}, $date->{month2}, $date->{day2}
170             )
171 3         37 );
172 3         129 push @dates, $range->dates();
173             }
174             elsif ( $date->{conjugator} eq 'enum' ) {
175             push @dates,
176             Date::Simple::ymd(
177             $date->{year1}, $date->{month1}, $date->{day1}
178             ),
179             Date::Simple::ymd( $date->{year2}, $date->{month2},
180 1         6 $date->{day2} );
181             }
182             }
183             else {
184 11 50       31 if ( !$date->{year1} ) {
185              
186             # guesswork
187             my $dti = DateTime::Incomplete->new(
188             month => $date->{month1},
189             day => $date->{day1}
190 11         45 );
191 11         316 push @dates, $self->_guess_full_date($dti);
192             }
193             else {
194             push @dates,
195             Date::Simple::ymd( $date->{year1}, $date->{month1},
196 0         0 $date->{day1} );
197             }
198             }
199 15         438 return @dates;
200             }
201              
202             sub extract {
203 12     12 1 7242 my ( $self, $text ) = @_;
204 12         23 my @found_dates;
205              
206 12         30 my @enum = ( '\+', ',', 'oder', 'o\.' );
207 12         22 my @and = ( 'und', 'u\.' );
208 12         18 my @range = ( '-', 'bis(?:\s*zum)?', );
209 12         515 my @months = $self->all_months;
210             my $monthlist = join '|',
211             (
212 312         563 map {"$_\\b"}
213 12         55 sort { length($b) <=> length($a) } @months
  996         1343  
214             ),
215             '[1-9]\d?\.';
216 12         114 my $month_regex = qr/$monthlist/i;
217              
218             # once turned into a regex it no longer honors the i switch set on a
219             # containing regex.
220 12         8633 my $between_regex = qr/[Zz]wischen/;
221 12         26 my $day_regex = qr'[1-9]\d?\.';
222 12         30 my $year_regex = qr'\d{4}';
223 12         28 my $conjugator_enum = join '|', @enum;
224 12         21 my $conjugator_and = join '|', @and;
225 12         21 my $conjugator_range = join '|', @range;
226 12         112 my $conjugator_regex = qr/(?:
227             (?<enum>$conjugator_enum)
228             |
229             (?<and>$conjugator_and)
230             |
231             (?<range>$conjugator_range)
232             )/ix;
233              
234 12         418 my $date_regex = qr/\b(?:
235             (?<between>$between_regex)?\s*
236             0?(?<day1>$day_regex)\s*
237             0?(?<month1>$month_regex)\s*
238             (?<year1>$year_regex)?\s*
239             $conjugator_regex\s*
240             0?(?<day2>$day_regex)\s*
241             0?(?<month2>$month_regex)\s*
242             (?<year2>$year_regex)?\s*
243             |
244             (?<between>$between_regex)?\s*
245             0?(?<day1>$day_regex)\s*
246             (?:
247             $conjugator_regex\s*
248             0?(?<day2>$day_regex)\s*
249             )?
250             0?(?<month1>$month_regex)\s*
251             (?<year1>$year_regex)?
252             )/x;
253              
254 12         760 while ( $text =~ m/(?<date>$date_regex)/g ) {
255 1     1   2304 my $date = {%+};
  1         506  
  1         324  
  15         308  
256             eval {
257 15         33 for my $c (qw(enum range and)) {
258 45 100       110 if ( exists $date->{$c} ) {
259 4 100       14 if ( $c eq 'and' ) {
260 2 100       6 if ( exists $date->{between} ) {
261 1         4 $date->{conjugator} = 'range';
262             }
263             else {
264 1         4 $date->{conjugator} = 'enum';
265             }
266             }
267             else {
268 2         7 $date->{conjugator} = $c;
269             }
270             }
271             }
272 15 50       66 $date->{day1} = int $date->{day1} if $date->{day1};
273 15 100       38 $date->{day2} = int $date->{day2} if $date->{day2};
274             $date->{month1} = $self->_translate_month( $date->{month1} )
275 15 50       54 if $date->{month1};
276             $date->{month2} = $self->_translate_month( $date->{month2} )
277 15 100       39 if $date->{month2};
278 15         30 push @found_dates, $date;
279 15         400 1;
280 15 50       55 } or do {
281 0 0       0 warn "$text\n\n$@" if $@;
282             };
283              
284             }
285 12         23 my @adjusted_dates;
286 12         20 foreach (@found_dates) {
287 15         40 push @adjusted_dates, $self->_process_date($_);
288             }
289 12         135 return \@adjusted_dates;
290             }
291              
292             __PACKAGE__->meta->make_immutable();
293              
294             1;
295              
296             __END__
297              
298             =head1 NAME
299              
300             Date::Extract::DE - Extract dates from german text
301              
302             =head1 VERSION
303              
304             0.0.4
305              
306             =begin readme
307              
308             =head1 INSTALLATION
309              
310             To install this module, run the following commands:
311              
312             perl Build.PL
313             ./Build
314             ./Build test
315             ./Build install
316              
317             =end readme
318              
319             =for test_synopsis
320             my $reference_date;
321             my $lookback_days;
322             my $text;
323              
324             =head1 SYNOPSIS
325              
326             use Date::Extract::DE;
327             my $parser = Date::Extract::DE->new( reference_date => $reference_date );
328             my $dates = $parser->extract($text);
329              
330             =head1 DESCRIPTION
331              
332             This is a module to extract dates from german text (similar to L<Date::Extract>).
333              
334             =head1 METHODS
335              
336             =over 4
337              
338             =item new(reference_date => $reference_date, lookback_days => $lookback_days)
339              
340             Creates a new instance. Optionally, you can specify a reference Date::Simple
341             which is used to determine the year when a date is given incompletely in the
342             text (default is today). You can also specify a maximum numer of days to look
343             back when an incomplete date is guessed (otherwise the closest date is used)
344              
345             =item extract($text)
346              
347             Tries to extract dates from the text and returns an arrayref of L<Date::Simple> instances
348              
349             =back
350              
351             =head1 AUTHORS
352              
353             =over 4
354              
355             =item Andreas Mager C<< <quattro at cpan org> >>
356              
357             =item Christian Eder C<< <christian.eder@apa.at> >>
358              
359             =back
360              
361             =head1 LICENCE AND COPYRIGHT
362              
363             Copyright (c) 2020, APA-IT. All rights reserved.
364              
365             This module is free software; you can redistribute it and/or modify it
366             under the terms of the GNU General Public License as published by the
367             Free Software Foundation; either version 2 of the License, or (at your
368             option) any later version.
369              
370             This module is distributed in the hope that it will be useful, but
371             WITHOUT ANY WARRANTY; without even the implied warranty of
372             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
373             General Public License for more details.
374              
375             You should have received a copy of the GNU General Public License
376             along with this module. If not, see L<http://www.gnu.org/licenses/>.