From 8ed0574a68de88dd69a9f033cbbf1b23637dbe8d Mon Sep 17 00:00:00 2001 From: Rene Krenn Date: Wed, 9 Jan 2019 17:40:19 +0100 Subject: [PATCH] TT#48988 timezone conversions for CF timeset periods when passing the ?tz=Europe/Vienna with POST/PUT/PATCH, the callforward timeset period definition input will be converted from Europe/Vienna timezone to system timezone before persising to DB. when passing the ?tz parameter with GET requests, the callforward timeset period definition from DB will be converted to the given timezone. the ?use_owner_tz parameter will take the subscriber's inherited timezone. disarmed in code for now. Change-Id: If4e130b241c28821844e0700231d1cd6883bcbfb --- lib/NGCP/Panel/Controller/API/CFTimeSets.pm | 7 +- lib/NGCP/Panel/Role/API/CFTimeSets.pm | 283 ++++++++++++- lib/NGCP/Panel/Utils/Generic.pm | 46 ++- sandbox/cftimeset_time_tz_conversion.t | 421 ++++++++++++++++++++ 4 files changed, 753 insertions(+), 4 deletions(-) create mode 100644 sandbox/cftimeset_time_tz_conversion.t diff --git a/lib/NGCP/Panel/Controller/API/CFTimeSets.pm b/lib/NGCP/Panel/Controller/API/CFTimeSets.pm index cabb6533c5..4c8c9137ae 100644 --- a/lib/NGCP/Panel/Controller/API/CFTimeSets.pm +++ b/lib/NGCP/Panel/Controller/API/CFTimeSets.pm @@ -155,12 +155,15 @@ sub POST :Allow { $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Invalid field 'times'. Must be an array."); last; } + my $times = $resource->{times}; + # enable tz and use_owner_tz params for POST: + #$times = $self->apply_owner_timezone($c,$b_subscriber,$resource->{times},'deflate'); try { $tset = $schema->resultset('voip_cf_time_sets')->create({ name => $resource->{name}, subscriber_id => $subscriber->id, }); - for my $t ( @{$resource->{times}} ) { + for my $t ( @$times ) { delete $t->{time_set_id}; $tset->create_related("voip_cf_periods", $t); } @@ -169,7 +172,7 @@ sub POST :Allow { $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Failed to create cftimeset."); last; } - + last unless $self->add_create_journal_item_hal($c,sub { my $self = shift; my ($c) = @_; diff --git a/lib/NGCP/Panel/Role/API/CFTimeSets.pm b/lib/NGCP/Panel/Role/API/CFTimeSets.pm index f2ee5a7685..ae38f4588b 100644 --- a/lib/NGCP/Panel/Role/API/CFTimeSets.pm +++ b/lib/NGCP/Panel/Role/API/CFTimeSets.pm @@ -15,6 +15,8 @@ use NGCP::Panel::Utils::Subscriber; use NGCP::Panel::Form; +use NGCP::Panel::Utils::DateTime qw(); + sub get_form { my ($self, $c) = @_; if($c->user->roles eq "subscriber" || $c->user->roles eq "subscriberadmin") { @@ -24,6 +26,279 @@ sub get_form { } } +sub apply_owner_timezone { + + my ($self, $c, $subscriber, $times, $mode) = @_; + + my $tz_name; + if($c->req->param('tz')) { + if (DateTime::TimeZone->is_valid_name($c->req->param('tz'))) { + $tz_name = $c->req->param('tz'); + } else { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Query parameter 'tz' value is not a valid time zone"); + return; + } + } elsif ($subscriber and $c->req->param('use_owner_tz')) { + my $tz = $c->model('DB')->resultset('voip_subscriber_timezone')->search_rs({ + subscriber_id => $subscriber->id + })->first; + $tz_name = NGCP::Panel::Utils::DateTime::normalize_db_tz_name($tz->name) if $tz; + } + $times //= []; + my $tz_local = DateTime::TimeZone->new(name => 'local'); + my ($tz,$offset); + if ($tz_name + and ($tz = DateTime::TimeZone->new(name => $tz_name)) + and abs($offset = $tz->offset_for_datetime(DateTime->now()) - $tz_local->offset_for_datetime(DateTime->now())) > 0) { + + my $offset_hrs = int($offset / 3600.0); + if ($offset_hrs * 3600 != $offset) { + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "only full hours supported for timezone offset ($tz_name: $offset seconds = $offset_hrs hours)"); + return; + } + + #foreach my $time (@$times) { + # foreach $field (qw(year month mday) { + # if (exists $time->{$field} and $time->{$field}) { + # $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "cannot apply a timezone offset for time with '$field' field ($time->{$field})"); + # return; + # } + # } + #} + + my $merge = 0; + if ('deflate' eq $mode) { #for writing to db + $offset_hrs = $offset_hrs * -1; + $c->log->debug("cf timeset $mode for timezone $tz_name: offset $offset_hrs hours"); + } elsif ('inflate' eq $mode) { #for reading from db + $merge = 1; + $c->log->debug("cf timeset $mode for timezone $tz_name: offset $offset_hrs hours"); + } else { + die("invalid mode $mode"); + } + + my ($yearmonthmday_map,$yearmonthmdays) = array_to_map($times,sub { my $time = shift; + return (length($time->{year}) > 0 ? $time->{year} : '*') . + '_' . ($time->{month} || '*') . '_' . ($time->{mday} || '*'); + },undef,'group'); + + $times = []; + foreach my $yearmonthmday (@$yearmonthmdays) { + if ($offset_hrs > 0) { + push(@$times,@{_add($yearmonthmday_map->{$yearmonthmday},$offset_hrs,$merge)}); + } else { + push(@$times,@{_subtract($yearmonthmday_map->{$yearmonthmday},$offset_hrs,$merge)}); + } + } + + } else { + $c->log->debug("no timezone to convert to, or zero tz offset"); + } + return $times; + +} + +sub _add { + my ($times,$offset_hrs,$merge) = @_; + + my @result = (); + foreach my $time (@$times) { + my $p1 = { %$time }; + unless (length($time->{hour}) > 0) { + #nothing to do if there is no hour defined: + push(@result,$p1); + next; + } + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + my $hour_range; + if (defined $hour_end) { + $hour_range = 1; + } else { + $hour_end = $hour_start; + $hour_range = 0; + } + $hour_start += abs($offset_hrs); + $hour_end += abs($offset_hrs); + if ($hour_start < 24 and $hour_end < 24) { + $p1->{hour} = ($hour_range ? $hour_start . '-' . $hour_end : $hour_start); + push(@result,$p1); + next; + } + my ($wday_start, $wday_end) = split(/\-/, $time->{wday} || '1-7'); + my $wday_range; + if (defined $wday_end) { + $wday_range = 1; + } else { + $wday_end = $wday_start; + $wday_range = 0; + } + my @nums = (); + if ($wday_start <= $wday_end) { + push(@nums,$wday_start .. $wday_end); + } else { + push(@nums,$wday_start .. 7); + push(@nums,1 .. $wday_end); + } + my ($p2,$p_shift_wday); + if ($hour_start > 23 and $hour_end > 23) { #26-28 + $p1->{hour} = ($hour_range ? ($hour_start % 24) . '-' . ($hour_end % 24) : ($hour_start % 24)); #2-4 + $p_shift_wday = $p1; + } elsif ($hour_start < $hour_end) { + if ($hour_end > 23) { #17-23 +3-> 20-26 + $p1->{hour} = $hour_start . '-23'; #20-0 + $p2 = { %$time }; + $p2->{hour} = '0-' . ($hour_end % 24); #0-2 + $p_shift_wday = $p2; + } + } else { + if ($hour_start > 23) { #23-17 +3-> 26-20 + $p1->{hour} = ($hour_start % 24) . '-' . $hour_end; #2-20 + $p_shift_wday = $p1; + } + } + if ($p_shift_wday and (scalar @nums) < 7) { + $p_shift_wday->{wday} = ($wday_range ? (($wday_start) % 7 + 1) . '-' . (($wday_end) % 7 + 1) : (($wday_start) % 7 + 1)); + } + push(@result,$p1); + push(@result,$p2) if $p2; + } + return ($merge ? merge_adjacent(\@result) : \@result); + +} + +sub _subtract { + my ($times,$offset_hrs,$merge) = @_; + + my @result = (); + foreach my $time (@$times) { + my $p1 = { %$time }; + unless (length($time->{hour}) > 0) { + #nothing to do if there is no hour defined: + push(@result,$p1); + next; + } + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + my $hour_range; + if (defined $hour_end) { + $hour_range = 1; + } else { + $hour_end = $hour_start; + $hour_range = 0; + } + $hour_start -= abs($offset_hrs); + $hour_end -= abs($offset_hrs); + if ($hour_start >= 0 and $hour_end >= 0) { + $p1->{hour} = ($hour_range ? $hour_start . '-' . $hour_end : $hour_start); + push(@result,$p1); + next; + } + my ($wday_start, $wday_end) = split(/\-/, $time->{wday} || '1-7'); + my $wday_range; + if (defined $wday_end) { + $wday_range = 1; + } else { + $wday_end = $wday_start; + $wday_range = 0; + } + my @nums = (); + if ($wday_start <= $wday_end) { + push(@nums,$wday_start .. $wday_end); + } else { + push(@nums,$wday_start .. 7); + push(@nums,1 .. $wday_end); + } + my ($p2,$p_shift_wday); + if ($hour_start < 0 and $hour_end < 0) { #-4 - -2 + $p1->{hour} = ($hour_range ? ($hour_start % 24) . '-' . ($hour_end % 24) : ($hour_start % 24)); #20-22 + $p_shift_wday = $p1; + } elsif ($hour_start < $hour_end) { #-4 - 3 + if ($hour_start < 0) { #0-7 -4-> -4 - 3 + $p1->{hour} = ($hour_start % 24) . '-23'; #20-0 + $p2 = { %$time }; + $p2->{hour} = '0-' . $hour_end; #0-3 + $p_shift_wday = $p1; + } + } else { + if ($hour_end < 0) { #22 - 2 -6-> 16 - -4 + $p1->{hour} = $hour_start . '-' . ($hour_end % 24); #16-20 + $p_shift_wday = $p1; + } + } + if ($p_shift_wday and (scalar @nums) < 7) { + $p_shift_wday->{wday} = ($wday_range ? (($wday_start - 2) % 7 + 1) . '-' . (($wday_end - 2) % 7 + 1) : (($wday_start - 2) % 7 + 1)); + } + push(@result,$p1); + push(@result,$p2) if $p2; + } + return ($merge ? _merge_adjacent(\@result) : \@result); + +} + +sub _merge_adjacent { + my ($times) = @_; + + my ($wday_map,$wdays) = array_to_map($times,sub { my $time = shift; + my $wday = $time->{wday} || '1-7'; + $wday = '1-7' if $time->{wday} eq '7-1'; + $wday .= '_' . (defined $time->{minute} ? $time->{minute} : '*'); + return $wday; + },undef,'group'); + + my @result = (); + my $idx = 0; + foreach my $wday (@$wdays) { + my %hour_start_map = (); + my %hour_end_map = (); + my %skip_map = (); + my $old_idx = $idx; + foreach my $time (@{$wday_map->{$wday}}) { + if (length($time->{hour}) > 0) { + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + $hour_end //= $hour_start; + if ($hour_end >= $hour_start) { #we do not create any adjacent roll-over hours, so we also skip such when merging + if (not defined $hour_start_map{$hour_start} + or $hour_end > $hour_start_map{$hour_start}->{hour_end}) { + $hour_start_map{$hour_start} = { hour_end => $hour_end, idx => $idx, }; + } else { + $skip_map{$idx} = 0; + } + if (not defined $hour_end_map{$hour_end} + or $hour_start < $hour_end_map{$hour_end}->{hour_start}) { + $hour_end_map{$hour_end} = { hour_start => $hour_start, }; #, idx => $idx, + } else { + $skip_map{$idx} = 0; + } + } else { + $skip_map{$idx} = 1; + } + } else { + $skip_map{$idx} = 1; + } + $idx++; + } + $idx = $old_idx; + foreach my $time (@{$wday_map->{$wday}}) { + my $p = { %$time }; + if (exists $skip_map{$idx}) { + push(@result,$p) if $skip_map{$idx}; + } else { + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + $hour_end //= $hour_start; + #if ($hour_end_map{$hour_end}->{idx} == $idx) { + my $adjacent_start = $hour_end + 1; + if (exists $hour_start_map{$adjacent_start}) { + $p->{hour} = $hour_start . '-' . $hour_start_map{$adjacent_start}->{hour_end}; + $skip_map{$hour_start_map{$adjacent_start}->{idx}} = 0; + } + push(@result,$p); + #} + } + $idx++; + } + } + return \@result; +} + sub hal_from_item { my ($self, $c, $item, $type) = @_; my $form; @@ -36,6 +311,8 @@ sub hal_from_item { push @times, $timeelem; } $resource{times} = \@times; + # enable tz and use_owner_tz params for GET: + #$resource{times} = $self->apply_owner_timezone($c,$item->subscriber->voip_subscriber,\@times,'inflate'); my $b_subs_id = $item->subscriber->voip_subscriber->id; $resource{subscriber_id} = $b_subs_id; @@ -135,13 +412,17 @@ sub update_item { last; } + my $times = $resource->{times}; + # enable tz and use_owner_tz params for PUT/PATCH/POST: + #$times = $self->apply_owner_timezone($c,$b_subscriber,$resource->{times},'deflate'); + try { $item->update({ name => $resource->{name}, subscriber_id => $subscriber->id, })->discard_changes; $item->voip_cf_periods->delete; - for my $t ( @{$resource->{times}} ) { + for my $t ( @$times ) { delete $t->{time_set_id}; $item->create_related("voip_cf_periods", $t); } diff --git a/lib/NGCP/Panel/Utils/Generic.pm b/lib/NGCP/Panel/Utils/Generic.pm index 10b0d2797e..45021aa984 100644 --- a/lib/NGCP/Panel/Utils/Generic.pm +++ b/lib/NGCP/Panel/Utils/Generic.pm @@ -17,7 +17,7 @@ use Data::Compare qw//; my $MIME_TYPES = { #first extension is default, others are for extension 2 mime_type detection 'audio/x-wav' => ['wav'], - 'audio/mpeg' => ['mp3'], + 'audio/mpeg' => ['mp3'], 'audio/ogg' => ['ogg'], }; @@ -168,4 +168,48 @@ sub extension_to_mime_type { return $mime_type; } +sub array_to_map { + + my ($array_ptr,$get_key_code,$get_value_code,$mode) = @_; + my $map = {}; + my @keys = (); + my @values = (); + if (defined $array_ptr and ref $array_ptr eq 'ARRAY') { + if (defined $get_key_code and ref $get_key_code eq 'CODE') { + if (not (defined $get_value_code and ref $get_value_code eq 'CODE')) { + $get_value_code = sub { return shift; }; + } + $mode = lc($mode); + if (not ($mode eq 'group' or $mode eq 'first' or $mode eq 'last')) { + $mode = 'group'; + } + foreach my $item (@$array_ptr) { + my $key = &$get_key_code($item); + if (defined $key) { + my $value = &$get_value_code($item); + if (defined $value) { + if (not exists $map->{$key}) { + if ($mode eq 'group') { + $map->{$key} = [ $value ]; + } else { + $map->{$key} = $value; + } + push(@keys,$key); + } else { + if ($mode eq 'group') { + push(@{$map->{$key}}, $value); + } elsif ($mode eq 'last') { + $map->{$key} = $value; + } + } + push(@values,$value); + } + } + } + } + } + return ($map,\@keys,\@values); + +} + 1; diff --git a/sandbox/cftimeset_time_tz_conversion.t b/sandbox/cftimeset_time_tz_conversion.t new file mode 100644 index 0000000000..b43f068d0c --- /dev/null +++ b/sandbox/cftimeset_time_tz_conversion.t @@ -0,0 +1,421 @@ +use strict; + +use Test::More; + +{ + use DateTime; + use DateTime::TimeZone; + my $tz = DateTime::TimeZone->new(name => 'America/Chicago'); #'Europe/Dublin'); + my $dt = DateTime->now(); + my $offset = $tz->offset_for_datetime($dt); + diag($dt .' - offset: ' . $offset); +} + +#goto SKIP; +### add +{ + # single day, no roll-over hours: + is_deeply(_add([{wday => '7',hour => '2-3',},],3), [{wday => '7',hour => '5-6', + },], "single day add, hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '7',hour => '20-23',},],3), [{wday => '7',hour => '23-23',},{wday => '1',hour => '0-2', + }], "single day add, hour to shifted to next day"); + is_deeply(_add([{wday => '7',hour => '22-23',},],3), [{wday => '1',hour => '1-2', + },], "single day add, hour from and hour to shifted to next day"); + + # single day, roll over hours: + is_deeply(_add([{wday => '7',hour => '3-2',},],3), [{wday => '7',hour => '6-5', + },], "single day add, roll-over hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '7',hour => '22-3',},],3), [{wday => '1',hour => '1-6', + },], "single day add, roll-over hour from shifted to next day"); + is_deeply(_add([{wday => '7',hour => '23-22',},],3), [{wday => '1',hour => '2-1', + },], "single day add, roll-over hour from and hour to shifted to next day"); + + # range days, no roll-over hours: + is_deeply(_add([{wday => '3-7',hour => '2-3',},],3), [{wday => '3-7',hour => '5-6', + },], "range days add, hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '3-7',hour => '20-23',},],3), [{wday => '3-7',hour => '23-23',},{wday => '4-1',hour => '0-2', + }], "range days add, hour to shifted to next day"); + is_deeply(_add([{wday => '3-7',hour => '22-23',},],3), [{wday => '4-1',hour => '1-2', + },], "range days add, hour from and hour to shifted to next day"); + + # range days, roll over hours: + is_deeply(_add([{wday => '3-7',hour => '3-2',},],3), [{wday => '3-7',hour => '6-5', + },], "range days add, roll-over hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '3-7',hour => '22-3',},],3), [{wday => '4-1',hour => '1-6', + },], "range days add, roll-over hour from shifted to next day"); + is_deeply(_add([{wday => '3-7',hour => '23-22',},],3), [{wday => '4-1',hour => '2-1', + },], "range days add, roll-over hour from and hour to shifted to next day"); + + # all days, no roll-over hours: + is_deeply(_add([{wday => '7-1',hour => '2-3',},],3), [{wday => '7-1',hour => '5-6', + },], "any days add, hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '1-7',hour => '20-23',},],3), [{wday => '1-7',hour => '23-23',},{wday => '1-7',hour => '0-2', + }], "any days add, hour to shifted to next day"); + is_deeply(_add([{wday => '',hour => '22-23',},],3), [{wday => '',hour => '1-2', + },], "any days add, hour from and hour to shifted to next day"); + + # all days, roll over hours: + is_deeply(_add([{wday => '7-1',hour => '3-2',},],3), [{wday => '7-1',hour => '6-5', + },], "any days add, roll-over hour from and hour to not shifted to next day"); + is_deeply(_add([{wday => '',hour => '22-3',},],3), [{wday => '',hour => '1-6', + },], "any days add, roll-over hour from shifted to next day"); + is_deeply(_add([{wday => '1-7',hour => '23-22',},],3), [{wday => '1-7',hour => '2-1', + },], "any days add, roll-over hour from and hour to shifted to next day"); + +} + +#SKIP: +### subtract +{ + # single day, no roll-over hours: + is_deeply(_subtract([{wday => '1',hour => '5-6',},],3), [{wday => '1',hour => '2-3', + },], "single day subtract, hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1',hour => '2-4',},],3), [{wday => '7',hour => '23-23',},{wday => '1',hour => '0-1', + }], "single day subtract, hour from shifted to previous day"); + is_deeply(_subtract([{wday => '1',hour => '0-2',},],3), [{wday => '7',hour => '21-23', + },], "single day subtract, hour from and hour to shifted to previous day"); + + # single day, roll over hours: + is_deeply(_subtract([{wday => '1',hour => '6-5',},],3), [{wday => '1',hour => '3-2', + },], "single day subtract, roll-over hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1',hour => '6-1',},],3), [{wday => '7',hour => '3-22', + },], "single day subtract, roll-over hour to shifted to previous day"); + is_deeply(_subtract([{wday => '1',hour => '2-1',},],3), [{wday => '7',hour => '23-22', + },], "single day subtract, roll-over hour from and hour to shifted to previous day"); + + # range days, no roll-over hours: + is_deeply(_subtract([{wday => '1-3',hour => '5-6',},],3), [{wday => '1-3',hour => '2-3', + },], "range days subtract, hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1-3',hour => '2-4',},],3), [{wday => '7-2',hour => '23-23',},{wday => '1-3',hour => '0-1', + }], "range days subtract, hour from shifted to previous day"); + is_deeply(_subtract([{wday => '1-3',hour => '0-2',},],3), [{wday => '7-2',hour => '21-23', + },], "range days subtract, hour from and hour to shifted to previous day"); + + # range days, roll over hours: + is_deeply(_subtract([{wday => '1-3',hour => '6-5',},],3), [{wday => '1-3',hour => '3-2', + },], "range days subtract, roll-over hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1-3',hour => '6-1',},],3), [{wday => '7-2',hour => '3-22', + },], "range days subtract, roll-over hour to shifted to previous day"); + is_deeply(_subtract([{wday => '1-3',hour => '2-1',},],3), [{wday => '7-2',hour => '23-22', + },], "range days subtract, roll-over hour from and hour to shifted to previous day"); + + # all days, no roll-over hours: + is_deeply(_subtract([{wday => '7-1',hour => '5-6',},],3), [{wday => '7-1',hour => '2-3', + },], "any day subtract, hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1-7',hour => '2-4',},],3), [{wday => '1-7',hour => '23-23',},{wday => '1-7',hour => '0-1', + }], "any day subtract, hour from shifted to previous day"); + is_deeply(_subtract([{wday => '',hour => '0-2',},],3), [{wday => '',hour => '21-23', + },], "any day subtract, hour from and hour to shifted to previous day"); + + # all days, roll over hours: + is_deeply(_subtract([{wday => '7-1',hour => '6-5',},],3), [{wday => '7-1',hour => '3-2', + },], "any day subtract, roll-over hour from and hour to not shifted to previous day"); + is_deeply(_subtract([{wday => '1-7',hour => '6-1',},],3), [{wday => '1-7',hour => '3-22', + },], "any day subtract, roll-over hour to shifted to previous day"); + is_deeply(_subtract([{wday => '',hour => '2-1',},],3), [{wday => '',hour => '23-22', + },], "any day subtract, roll-over hour from and hour to shifted to previous day"); +} + +{ + is_deeply(_subtract(_add([{wday => '7',hour => '20-23',},],3),3,1), [{wday => '7',hour => '20-23',},], + "merge adjacent add->subtract"); + is_deeply(_add(_subtract([{wday => '1',hour => '2-4',},],3),3,1), [{wday => '1',hour => '2-4',},], + "merge adjacent subtract->add"); +} + +done_testing; + +sub _add { + my ($times,$offset_hrs,$merge) = @_; + + my @result = (); + foreach my $time (@$times) { + my $p1 = { %$time }; + unless (length($time->{hour}) > 0) { + #nothing to do if there is no hour defined: + push(@result,$p1); + next; + } + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + my $hour_range; + if (defined $hour_end) { + $hour_range = 1; + } else { + $hour_end = $hour_start; + $hour_range = 0; + } + $hour_start += abs($offset_hrs); + $hour_end += abs($offset_hrs); + if ($hour_start < 24 and $hour_end < 24) { + $p1->{hour} = ($hour_range ? $hour_start . '-' . $hour_end : $hour_start); + push(@result,$p1); + next; + } + my ($wday_start, $wday_end) = split(/\-/, $time->{wday} || '1-7'); + my $wday_range; + if (defined $wday_end) { + $wday_range = 1; + } else { + $wday_end = $wday_start; + $wday_range = 0; + } + my @nums = (); + if ($wday_start <= $wday_end) { + push(@nums,$wday_start .. $wday_end); + } else { + push(@nums,$wday_start .. 7); + push(@nums,1 .. $wday_end); + } + my ($p2,$p_shift_wday); + if ($hour_start > 23 and $hour_end > 23) { #26-28 + $p1->{hour} = ($hour_range ? ($hour_start % 24) . '-' . ($hour_end % 24) : ($hour_start % 24)); #2-4 + $p_shift_wday = $p1; + } elsif ($hour_start < $hour_end) { + if ($hour_end > 23) { #17-23 +3-> 20-26 + $p1->{hour} = $hour_start . '-23'; #20-0 + $p2 = { %$time }; + $p2->{hour} = '0-' . ($hour_end % 24); #0-2 + $p_shift_wday = $p2; + } + } else { + if ($hour_start > 23) { #23-17 +3-> 26-20 + $p1->{hour} = ($hour_start % 24) . '-' . $hour_end; #2-20 + $p_shift_wday = $p1; + } + } + if ($p_shift_wday and (scalar @nums) < 7) { + $p_shift_wday->{wday} = ($wday_range ? (($wday_start) % 7 + 1) . '-' . (($wday_end) % 7 + 1) : (($wday_start) % 7 + 1)); + } + push(@result,$p1); + push(@result,$p2) if $p2; + } + return ($merge ? merge_adjacent(\@result) : \@result); + +} + +sub _subtract { + my ($times,$offset_hrs,$merge) = @_; + + my @result = (); + foreach my $time (@$times) { + my $p1 = { %$time }; + unless (length($time->{hour}) > 0) { + #nothing to do if there is no hour defined: + push(@result,$p1); + next; + } + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + my $hour_range; + if (defined $hour_end) { + $hour_range = 1; + } else { + $hour_end = $hour_start; + $hour_range = 0; + } + $hour_start -= abs($offset_hrs); + $hour_end -= abs($offset_hrs); + if ($hour_start >= 0 and $hour_end >= 0) { + $p1->{hour} = ($hour_range ? $hour_start . '-' . $hour_end : $hour_start); + push(@result,$p1); + next; + } + my ($wday_start, $wday_end) = split(/\-/, $time->{wday} || '1-7'); + my $wday_range; + if (defined $wday_end) { + $wday_range = 1; + } else { + $wday_end = $wday_start; + $wday_range = 0; + } + my @nums = (); + if ($wday_start <= $wday_end) { + push(@nums,$wday_start .. $wday_end); + } else { + push(@nums,$wday_start .. 7); + push(@nums,1 .. $wday_end); + } + my ($p2,$p_shift_wday); + if ($hour_start < 0 and $hour_end < 0) { #-4 - -2 + $p1->{hour} = ($hour_range ? ($hour_start % 24) . '-' . ($hour_end % 24) : ($hour_start % 24)); #20-22 + $p_shift_wday = $p1; + } elsif ($hour_start < $hour_end) { #-4 - 3 + if ($hour_start < 0) { #0-7 -4-> -4 - 3 + $p1->{hour} = ($hour_start % 24) . '-23'; #20-0 + $p2 = { %$time }; + $p2->{hour} = '0-' . $hour_end; #0-3 + $p_shift_wday = $p1; + } + } else { + if ($hour_end < 0) { #22 - 2 -6-> 16 - -4 + $p1->{hour} = $hour_start . '-' . ($hour_end % 24); #16-20 + $p_shift_wday = $p1; + } + } + if ($p_shift_wday and (scalar @nums) < 7) { + $p_shift_wday->{wday} = ($wday_range ? (($wday_start - 2) % 7 + 1) . '-' . (($wday_end - 2) % 7 + 1) : (($wday_start - 2) % 7 + 1)); + } + push(@result,$p1); + push(@result,$p2) if $p2; + } + return ($merge ? _merge_adjacent(\@result) : \@result); + +} + +sub _merge_adjacent { + my ($times) = @_; + + my ($wday_map,$wdays) = array_to_map($times,sub { my $time = shift; + my $wday = $time->{wday} || '1-7'; + $wday = '1-7' if $time->{wday} eq '7-1'; + $wday .= '_' . (defined $time->{minute} ? $time->{minute} : '*'); + return $wday; + },undef,'group'); + + my @result = (); + my $idx = 0; + foreach my $wday (@$wdays) { + my %hour_start_map = (); + my %hour_end_map = (); + my %skip_map = (); + my $old_idx = $idx; + foreach my $time (@{$wday_map->{$wday}}) { + if (length($time->{hour}) > 0) { + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + $hour_end //= $hour_start; + if ($hour_end >= $hour_start) { #we do not create any adjacent roll-over hours, so we also skip such when merging + if (not defined $hour_start_map{$hour_start} + or $hour_end > $hour_start_map{$hour_start}->{hour_end}) { + $hour_start_map{$hour_start} = { hour_end => $hour_end, idx => $idx, }; + } else { + $skip_map{$idx} = 0; + } + if (not defined $hour_end_map{$hour_end} + or $hour_start < $hour_end_map{$hour_end}->{hour_start}) { + $hour_end_map{$hour_end} = { hour_start => $hour_start, }; #, idx => $idx, + } else { + $skip_map{$idx} = 0; + } + } else { + $skip_map{$idx} = 1; + } + } else { + $skip_map{$idx} = 1; + } + $idx++; + } + $idx = $old_idx; + foreach my $time (@{$wday_map->{$wday}}) { + my $p = { %$time }; + if (exists $skip_map{$idx}) { + push(@result,$p) if $skip_map{$idx}; + } else { + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + $hour_end //= $hour_start; + #if ($hour_end_map{$hour_end}->{idx} == $idx) { + my $adjacent_start = $hour_end + 1; + if (exists $hour_start_map{$adjacent_start}) { + $p->{hour} = $hour_start . '-' . $hour_start_map{$adjacent_start}->{hour_end}; + $skip_map{$hour_start_map{$adjacent_start}->{idx}} = 0; + } + push(@result,$p); + #} + } + $idx++; + } + } + return \@result; +} + +sub _merge { + my ($times) = @_; + + my ($wday_map,$wdays,$wday_groups) = array_to_map($times,sub { my $time = shift; + my $wday = $time->{wday} || '1-7'; + $wday = '1-7' if $time->{wday} eq '7-1'; + $wday .= '_' . (defined $time->{minute} ? $time->{minute} : '*'); + return $wday; + },undef,'group'); + + my @result = (); + my %ranges = (); + foreach my $wday (@$wdays) { + foreach my $time (@{$wday_map->{$wday}}) { + my $p = { %$time }; + if (length($time->{hour}) > 0) { + my ($hour_start,$hour_end) = split(/\-/, $time->{hour}); + $hour_end //= $hour_start; + if ($hour_end >= $hour_start) { + #https://stackoverflow.com/questions/42928964/finding-and-merging-down-intervalls-in-perl + my $in_range = 0; + foreach my $range (@{$ranges{$wday}} ) { + if (($hour_start >= $range->{start} and $hour_start <= $range->{end}) + or ( $hour_end >= $range->{start} and $hour_end <= $range->{end}) + ) { + $range->{end} = $hour_end if $hour_end > $range->{end}; + $range->{start} = $hour_start if $hour_start < $range->{start}; + $in_range++; + } + } + if (not $in_range) { + push(@{$ranges{$wday}},{ start => $hour_start, end => $hour_end, }); + $p->{hour} = $hour_start . '-' . $hour_end; + push(@result,$p); + } + } else { #splitting by the tz offset add/subtract never produces roll-overs, so we don't merge such + push(@result,$p); + } + } else { + push(@result,$p); + } + } + } + return \@result; + +} + + + + +sub array_to_map { + + my ($array_ptr,$get_key_code,$get_value_code,$mode) = @_; + my $map = {}; + my @keys = (); + my @values = (); + if (defined $array_ptr and ref $array_ptr eq 'ARRAY') { + if (defined $get_key_code and ref $get_key_code eq 'CODE') { + if (not (defined $get_value_code and ref $get_value_code eq 'CODE')) { + $get_value_code = sub { return shift; }; + } + $mode = lc($mode); + if (not ($mode eq 'group' or $mode eq 'first' or $mode eq 'last')) { + $mode = 'group'; + } + foreach my $item (@$array_ptr) { + my $key = &$get_key_code($item); + if (defined $key) { + my $value = &$get_value_code($item); + if (defined $value) { + if (not exists $map->{$key}) { + if ($mode eq 'group') { + $map->{$key} = [ $value ]; + } else { + $map->{$key} = $value; + } + push(@keys,$key); + } else { + if ($mode eq 'group') { + push(@{$map->{$key}}, $value); + } elsif ($mode eq 'last') { + $map->{$key} = $value; + } + } + push(@values,$value); + } + } + } + } + } + return ($map,\@keys,\@values); + +} \ No newline at end of file