File Coverage

blib/lib/Date/Extract/DE.pm
Criterion Covered Total %
statement 112 122 91.8
branch 33 46 71.7
condition 11 21 52.3
subroutine 16 16 100.0
pod 2 2 100.0
total 174 207 84.0


line stmt bran cond sub pod time code
1             package Date::Extract::DE;
2              
3 1     1   1465 use Moose;
  1         491061  
  1         6  
4              
5 1     1   8850 use version; our $VERSION = qv('0.0.5');
  1         2228  
  1         6  
6              
7 1     1   711 use Date::Range;
  1         1012  
  1         34  
8 1     1   7 use Date::Simple ( 'date', 'today' );
  1         2  
  1         71  
9 1     1   995 use DateTime;
  1         515600  
  1         57  
10 1     1   782 use DateTime::Incomplete;
  1         67967  
  1         40  
11              
12 1     1   544 use MooseX::ClassAttribute;
  1         89146  
  1         6  
13              
14 1     1   325929 use utf8;
  1         4  
  1         9  
15              
16 1     1   32 use namespace::autoclean;
  1         3  
  1         10  
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         31 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 36     36   78 my ( $self, $month ) = @_;
78 36         130 $month =~ s/\W//g;
79 36         107 $month = ucfirst( lc $month ); # TODO: explore casefold here
80 36 100       1367 return $self->month_nr($month) if $self->month_nr($month);
81 6         13 $month = int $month;
82 6 50 33     48 return $month if $month && $month >= 1 && $month <= 12;
      33        
83 0         0 return;
84             }
85              
86             sub _guess_full_date {
87 28     28   61 my ( $self, $dt ) = @_;
88              
89 28         863 my $cand = $dt->closest( $self->_reference_dt );
90 28         418128 my $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
91 28 100 66     1503 if ( ( defined $self->lookback_days )
      66        
92             && ( $result < $self->reference_date )
93             && ( ( $self->reference_date - $result ) > $self->lookback_days ) ) {
94 2         70 $cand = $dt->next( $self->_reference_dt );
95 2         6205 $result = Date::Simple::ymd( $cand->year, $cand->month, $cand->day );
96             }
97 28         373 return $result;
98             }
99              
100             sub _process_date {
101 32     32   66 my ( $self, $date ) = @_;
102 32         44 my @dates;
103              
104 32 100       74 if ( exists $date->{conjugator} ) {
105 10 100       39 $date->{month2} = $date->{month1} unless $date->{month2};
106 10 100 66     71 if ( !$date->{year1} && !$date->{year2} ) {
    100 66        
    50 33        
107             my $dti1 = DateTime::Incomplete->new(
108             month => $date->{month1},
109             day => $date->{day1},
110 6         25 );
111 6         151 $date->{year1} = $self->_guess_full_date($dti1)->year();
112 6         70 $dti1->set( year => $date->{year1} );
113             my $dti2 = DateTime::Incomplete->new(
114             year => $date->{year1},
115             month => $date->{month2},
116             day => $date->{day2},
117 6         165 );
118 6 50       142 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
119 0         0 $date->{year2} = 1 + $date->{year1};
120             }
121             else {
122 6         13189 $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 2         11 );
131             my $dti2 = DateTime::Incomplete->new(
132             year => $date->{year1},
133             month => $date->{month2},
134             day => $date->{day2},
135 2         57 );
136 2 50       49 if ( 1 == DateTime->compare_ignore_floating( $dti1, $dti2 ) ) {
137 0         0 $date->{year2} = 1 + $date->{year1};
138             }
139             else {
140 2         4336 $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 10 100       33 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 8         34 );
172             push @dates,
173 8         433 map { { date => $_, context => $date->{date} } }
  32         1086  
174             $range->dates;
175             }
176             elsif ( $date->{conjugator} eq 'enum' ) {
177             push @dates,
178             {
179             date => Date::Simple::ymd(
180             $date->{year1}, $date->{month1}, $date->{day1}
181             ),
182             context => $date->{date}
183             },
184             {
185             date => Date::Simple::ymd(
186             $date->{year2}, $date->{month2}, $date->{day2}
187             ),
188             context => $date->{date}
189 2         55 };
190             }
191             }
192             else {
193 22 50       83 if ( !$date->{year1} ) {
194              
195             # guesswork
196             my $dti = DateTime::Incomplete->new(
197             month => $date->{month1},
198             day => $date->{day1}
199 22         240 );
200             push @dates,
201             {
202             date => $self->_guess_full_date($dti),
203             context => $date->{date}
204 22         608 };
205             }
206             else {
207             push @dates,
208             {
209             date => Date::Simple::ymd(
210             $date->{year1}, $date->{month1}, $date->{day1}
211             ),
212             context => $date->{date}
213 0         0 };
214             }
215             }
216 32         204 return @dates;
217             }
218              
219             sub extract_with_context {
220 26     26 1 8831 my ( $self, $text ) = @_;
221 26         45 my @found_dates;
222              
223 26         60 my @enum = ( '\+', ',', 'oder', 'o\.' );
224 26         56 my @and = ( 'und', 'u\.' );
225 26         48 my @range = ( '-', 'bis(?:\s*zum)?', );
226 26         1167 my @months = $self->all_months;
227             my $monthlist = join '|',
228             (
229 676         1210 map {"$_\\b"}
230 26         137 sort { length($b) <=> length($a) } @months
  2288         3004  
231             ),
232             '[1-9]\d?\.';
233 26         204 my $month_regex = qr/$monthlist/i;
234              
235             # once turned into a regex it no longer honors the i switch set on a
236             # containing regex.
237 26         9173 my $between_regex = qr/[Zz]wischen/;
238 26         60 my $day_regex = qr'[1-9]\d?\.';
239 26         56 my $year_regex = qr'\d{4}';
240 26         111 my $conjugator_enum = join '|', @enum;
241 26         53 my $conjugator_and = join '|', @and;
242 26         49 my $conjugator_range = join '|', @range;
243 26         238 my $conjugator_regex = qr/(?:
244             (?<enum>$conjugator_enum)
245             |
246             (?<and>$conjugator_and)
247             |
248             (?<range>$conjugator_range)
249             )/ix;
250              
251 26         642 my $date_regex = qr/\b(?:
252             (?<between>$between_regex)?\s*
253             0?(?<day1>$day_regex)\s*
254             0?(?<month1>$month_regex)\s*
255             (?<year1>$year_regex)?\s*
256             $conjugator_regex\s*
257             0?(?<day2>$day_regex)\s*
258             0?(?<month2>$month_regex)\s*
259             (?<year2>$year_regex)?\s*
260             |
261             (?<between>$between_regex)?\s*
262             0?(?<day1>$day_regex)\s*
263             (?:
264             $conjugator_regex\s*
265             0?(?<day2>$day_regex)\s*
266             )?
267             0?(?<month1>$month_regex)\s*
268             (?<year1>$year_regex)?
269             )/x;
270              
271 26         1400 while ( $text =~ m/(?<date>$date_regex)/g ) {
272 1     1   2551 my $date = {%+};
  1         524  
  1         413  
  32         687  
273             eval {
274 32         71 for my $c (qw(enum range and)) {
275 96 100       250 if ( exists $date->{$c} ) {
276 10 100       31 if ( $c eq 'and' ) {
277 4 100       15 if ( exists $date->{between} ) {
278 2         10 $date->{conjugator} = 'range';
279             }
280             else {
281 2         5 $date->{conjugator} = 'enum';
282             }
283             }
284             else {
285 6         21 $date->{conjugator} = $c;
286             }
287             }
288             }
289 32 50       153 $date->{day1} = int $date->{day1} if $date->{day1};
290 32 100       83 $date->{day2} = int $date->{day2} if $date->{day2};
291             $date->{month1} = $self->_translate_month( $date->{month1} )
292 32 50       137 if $date->{month1};
293             $date->{month2} = $self->_translate_month( $date->{month2} )
294 32 100       96 if $date->{month2};
295 32         59 push @found_dates, $date;
296 32         842 1;
297 32 50       117 } or do {
298 0 0       0 warn "$text\n\n$@" if $@;
299             };
300              
301             }
302 26         44 my @adjusted_dates;
303 26         50 foreach (@found_dates) {
304 32         208 $_->{date} =~ s/(?:^\s+)|(?:\s+$)//g;
305 32         98 push @adjusted_dates, $self->_process_date($_);
306             }
307 26         343 return \@adjusted_dates;
308             }
309              
310             sub extract {
311 13     13 1 15058 my ( $self, $text ) = @_;
312              
313 13         41 my $extract_info = $self->extract_with_context($text);
314 13         45 return [ map { $_->{date} } @$extract_info ];
  29         93  
315             }
316              
317             __PACKAGE__->meta->make_immutable();
318              
319             1;
320              
321             __END__
322              
323             =head1 NAME
324              
325             Date::Extract::DE - Extract dates from german text
326              
327             =head1 VERSION
328              
329             0.0.5
330              
331             =begin readme
332              
333             =head1 INSTALLATION
334              
335             To install this module, run the following commands:
336              
337             perl Build.PL
338             ./Build
339             ./Build test
340             ./Build install
341              
342             =end readme
343              
344             =for test_synopsis
345             my $reference_date;
346             my $lookback_days;
347             my $text;
348              
349             =head1 SYNOPSIS
350              
351             use Date::Extract::DE;
352             my $parser = Date::Extract::DE->new( reference_date => $reference_date );
353             my $dates = $parser->extract($text);
354             my $infos = $parser->extract_with_context($text);
355             printf("%s => %s\n", $_->{context}, $_->{date}) foreach @$infos;
356              
357             =head1 DESCRIPTION
358              
359             This is a module to extract dates from german text (similar to L<Date::Extract>).
360              
361             =head1 METHODS
362              
363             =over 4
364              
365             =item new(reference_date => $reference_date, lookback_days => $lookback_days)
366              
367             Creates a new instance. Optionally, you can specify a reference Date::Simple
368             which is used to determine the year when a date is given incompletely in the
369             text (default is today). You can also specify a maximum numer of days to look
370             back when an incomplete date is guessed (otherwise the closest date is used)
371              
372             =item extract($text)
373              
374             Tries to extract dates from the text and returns an arrayref of L<Date::Simple>
375             instances
376              
377             =item extract_with_context($text)
378              
379             Tries to extract dates from the text and returns an arrayref of HashRef
380             instances. Each HashRef contains a key 'date' which maps to a L<Date::Simple>
381             instance, and a key 'context' mapping to the date string found in the original
382             text
383              
384             =back
385              
386             =head1 AUTHORS
387              
388             =over 4
389              
390             =item Andreas Mager C<< <quattro at cpan org> >>
391              
392             =item Christian Eder C<< <christian.eder@apa.at> >>
393              
394             =back
395              
396             =head1 LICENCE AND COPYRIGHT
397              
398             Copyright (c) 2020, APA-IT. All rights reserved.
399              
400             This module is free software; you can redistribute it and/or modify it
401             under the terms of the GNU General Public License as published by the
402             Free Software Foundation; either version 2 of the License, or (at your
403             option) any later version.
404              
405             This module is distributed in the hope that it will be useful, but
406             WITHOUT ANY WARRANTY; without even the implied warranty of
407             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
408             General Public License for more details.
409              
410             You should have received a copy of the GNU General Public License
411             along with this module. If not, see L<http://www.gnu.org/licenses/>.