Files
scripts/Perl OTRS/Kernel/System/Calendar/Import/ICal.pm
2024-10-14 00:08:40 +02:00

800 lines
27 KiB
Perl

# --
# Copyright (C) 2001-2019 OTRS AG, https://otrs.com/
# --
# This software comes with ABSOLUTELY NO WARRANTY. For details, see
# the enclosed file COPYING for license information (GPL). If you
# did not receive this file, see https://www.gnu.org/licenses/gpl-3.0.txt.
# --
package Kernel::System::Calendar::Import::ICal;
use strict;
use warnings;
use Data::ICal;
use Data::ICal::Entry::Event;
use Date::ICal;
use Kernel::System::VariableCheck qw(:all);
our @ObjectDependencies = (
'Kernel::Config',
'Kernel::System::Cache',
'Kernel::System::Calendar',
'Kernel::System::Calendar::Appointment',
'Kernel::System::Calendar::Plugin',
'Kernel::System::Calendar::Team',
'Kernel::System::DateTime',
'Kernel::System::DB',
'Kernel::System::Encode',
'Kernel::System::Log',
'Kernel::System::Main',
'Kernel::System::User',
);
=head1 NAME
Kernel::System::Calendar::Import::ICal - C<iCalendar> import lib
=head1 DESCRIPTION
Import functions for C<iCalendar> format.
=head1 PUBLIC INTERFACE
=head2 new()
create an object. Do not use it directly, instead use:
use Kernel::System::ObjectManager;
local $Kernel::OM = Kernel::System::ObjectManager->new();
my $ImportObject = $Kernel::OM->Get('Kernel::System::Calendar::Export::ICal');
=cut
sub new {
my ( $Type, %Param ) = @_;
# allocate new hash for object
my $Self = {%Param};
bless( $Self, $Type );
return $Self;
}
=head2 Import()
Import calendar in C<iCalendar> format.
my $Success = $ImportObject->Import(
CalendarID => 123,
ICal => # (required) iCal string
'
BEGIN:VCALENDAR
PRODID:Zimbra-Calendar-Provider
VERSION:2.0
METHOD:REQUEST
...
',
UserID => 1, # (required) UserID
UpdateExisting => 0, # (optional) Delete existing Appointments within same Calendar if UniqueID matches
UntilLimit => '2017-01-01 00:00:00', # (optional) If provided, system will use this value for limiting recurring Appointments without defined end date
# instead of AppointmentCalendar::Import::RecurringMonthsLimit to do the calculation
# NOTE: PLEASE USE THIS PARAMETER FOR UNIT TESTS ONLY
);
Returns number of imported appointments if successful, otherwise 0.
=cut
sub Import {
my ( $Self, %Param ) = @_;
# check needed stuff
for my $Needed (qw(CalendarID ICal UserID)) {
if ( !$Param{$Needed} ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Need $Needed!",
);
return;
}
}
my $UntilLimitedTimestamp = $Param{UntilLimit} || '';
if ( !$UntilLimitedTimestamp ) {
# Calculate until time which will be used if there is any recurring Appointment without end
# time defined.
my $UntilTimeObject = $Kernel::OM->Create('Kernel::System::DateTime');
my $RecurringMonthsLimit
= $Kernel::OM->Get('Kernel::Config')->Get("AppointmentCalendar::Import::RecurringMonthsLimit")
|| '12'; # default 12 months
$UntilTimeObject->Add(
Months => $RecurringMonthsLimit,
);
$UntilLimitedTimestamp = $UntilTimeObject->ToString();
}
# Turn on UTF8 flag on supplied string for correct encoding in PostgreSQL backend.
$Kernel::OM->Get('Kernel::System::Encode')->EncodeInput( \$Param{ICal} );
my $Calendar = Data::ICal->new( data => $Param{ICal} );
# If external library encountered an error while parsing the ICS file, log the received message
# at this time and return.
if ( $Calendar->{errno} ) {
my $ErrorMessage = $Calendar->{error_message} // '';
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "[Data::ICal] $ErrorMessage",
);
return;
}
my @Entries = @{ $Calendar->entries() };
my $AppointmentsImported = 0;
my $PluginObject = $Kernel::OM->Get('Kernel::System::Calendar::Plugin');
my $AppointmentObject = $Kernel::OM->Get('Kernel::System::Calendar::Appointment');
ENTRY:
for my $Entry (@Entries) {
my $Properties = $Entry->properties();
my %Parameters;
my %LinkedObjects;
# get uid
if (
IsArrayRefWithData( $Properties->{'uid'} )
&& ref $Properties->{'uid'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'uid'}->[0]->{'value'}
)
{
$Parameters{UniqueID} = $Properties->{'uid'}->[0]->{'value'};
}
# get title
if (
IsArrayRefWithData( $Properties->{'summary'} )
&& ref $Properties->{'summary'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'summary'}->[0]->{'value'}
)
{
$Parameters{Title} = $Properties->{'summary'}->[0]->{'value'};
}
# get description
if (
IsArrayRefWithData( $Properties->{'description'} )
&& ref $Properties->{'description'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'description'}->[0]->{'value'}
)
{
$Parameters{Description} = $Properties->{'description'}->[0]->{'value'};
}
# get start time
if (
IsArrayRefWithData( $Properties->{'dtstart'} )
&& ref $Properties->{'dtstart'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'dtstart'}->[0]->{'value'}
)
{
my $TimezoneID;
if ( ref $Properties->{'dtstart'}->[0]->{'_parameters'} eq 'HASH' ) {
# check if it's an all day event
# 1) there is no time component for the date value
# 2) there is an explicit value parameter set to DATE
if (
length $Properties->{'dtstart'}->[0]->{'value'} == 8
||
(
$Properties->{'dtstart'}->[0]->{'_parameters'}->{'VALUE'}
&& $Properties->{'dtstart'}->[0]->{'_parameters'}->{'VALUE'} eq 'DATE'
)
)
{
$Parameters{AllDay} = 1;
}
# check timezone
if ( $Properties->{'dtstart'}->[0]->{'_parameters'}->{'TZID'} ) {
$TimezoneID = $Properties->{'dtstart'}->[0]->{'_parameters'}->{'TZID'};
}
}
my $StartTimeICal = $Self->_FormatTime(
Time => $Properties->{'dtstart'}->[0]->{'value'},
);
my $StartTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $StartTimeICal,
TimeZone => $TimezoneID,
},
);
if ( !$Parameters{AllDay} ) {
$StartTimeObject->ToOTRSTimeZone();
}
$Parameters{StartTime} = $StartTimeObject->ToString();
}
# get end time
if (
IsArrayRefWithData( $Properties->{'dtend'} )
&& ref $Properties->{'dtend'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'dtend'}->[0]->{'value'}
)
{
my $TimezoneID;
if ( ref $Properties->{'dtend'}->[0]->{'_parameters'} eq 'HASH' ) {
# check timezone
if ( $Properties->{'dtend'}->[0]->{'_parameters'}->{'TZID'} ) {
$TimezoneID = $Properties->{'dtend'}->[0]->{'_parameters'}->{'TZID'};
}
}
my $EndTimeICal = $Self->_FormatTime(
Time => $Properties->{'dtend'}->[0]->{'value'},
);
my $EndTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $EndTimeICal,
TimeZone => $TimezoneID,
},
);
if ( !$Parameters{AllDay} ) {
$EndTimeObject->ToOTRSTimeZone();
}
$Parameters{EndTime} = $EndTimeObject->ToString();
}
# Some iCalendar implementations (looking at you icalendar-ruby) do not require nor include
# end time for appointments. In this case, prevent failing and use start time instead.
else {
$Parameters{EndTime} = $Parameters{StartTime};
}
# get location
if (
IsArrayRefWithData( $Properties->{'location'} )
&& ref $Properties->{'location'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'location'}->[0]->{'value'}
)
{
$Parameters{Location} = $Properties->{'location'}->[0]->{'value'};
}
# get rrule
if (
IsArrayRefWithData( $Properties->{'rrule'} )
&& ref $Properties->{'rrule'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'rrule'}->[0]->{'value'}
)
{
my ( $Frequency, $Until, $Interval, $Count, $DayNames, $MonthDay, $Months );
my @Rules = split ';', $Properties->{'rrule'}->[0]->{'value'};
RULE:
for my $Rule (@Rules) {
if ( $Rule =~ /FREQ=(.*?)$/i ) {
$Frequency = $1;
}
elsif ( $Rule =~ /UNTIL=(.*?)$/i ) {
$Until = $1;
}
elsif ( $Rule =~ /INTERVAL=(\d+?)$/i ) {
$Interval = $1;
}
elsif ( $Rule =~ /COUNT=(\d+?)$/i ) {
$Count = $1;
}
elsif ( $Rule =~ /BYDAY=(.*?)$/i ) {
$DayNames = $1;
}
elsif ( $Rule =~ /BYMONTHDAY=(.*?)$/i ) {
$MonthDay = $1;
}
elsif ( $Rule =~ /BYMONTH=(.*?)$/i ) {
$Months = $1;
}
}
$Interval ||= 1; # default value
# this appointment is repeating
if ( $Frequency eq "DAILY" ) {
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = $Interval == 1 ? "Daily" : "CustomDaily";
$Parameters{RecurrenceInterval} = $Interval;
}
elsif ( $Frequency eq "WEEKLY" ) {
if ($DayNames) {
# custom
my @Days;
# SU,MO,TU,WE,TH,FR,SA
for my $DayName ( split( ',', $DayNames ) ) {
if ( uc $DayName eq 'MO' ) {
push @Days, 1;
}
elsif ( uc $DayName eq 'TU' ) {
push @Days, 2;
}
elsif ( uc $DayName eq 'WE' ) {
push @Days, 3;
}
elsif ( uc $DayName eq 'TH' ) {
push @Days, 4;
}
elsif ( uc $DayName eq 'FR' ) {
push @Days, 5;
}
elsif ( uc $DayName eq 'SA' ) {
push @Days, 6;
}
elsif ( uc $DayName eq 'SU' ) {
push @Days, 7;
}
}
if ( scalar @Days > 0 ) {
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "CustomWeekly";
$Parameters{RecurrenceInterval} = $Interval;
$Parameters{RecurrenceFrequency} = \@Days;
}
}
else {
# each n days
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "Weekly";
$Parameters{RecurrenceInterval} = $Interval;
}
}
elsif ( $Frequency eq "MONTHLY" ) {
# Skip unsupported custom monthly recurring rules:
# - FREQ=MONTHLY;BYDAY=2SA
if ($DayNames) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Skip import of unsupported recurring rule: "
. $Properties->{'rrule'}->[0]->{'value'},
);
next ENTRY;
}
if ($MonthDay) {
# Custom
# FREQ=MONTHLY;UNTIL=20170101T080000Z;BYMONTHDAY=16,31'
my @Days = split( ',', $MonthDay );
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "CustomMonthly";
$Parameters{RecurrenceFrequency} = \@Days;
$Parameters{RecurrenceInterval} = $Interval;
}
else {
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "Monthly";
$Parameters{RecurrenceInterval} = $Interval;
}
}
elsif ( $Frequency eq "YEARLY" ) {
# Skip unsupported custom yearly recurring rules:
# - FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
# - FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
if ($DayNames) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Skip import of unsupported recurring rule: "
. $Properties->{'rrule'}->[0]->{'value'},
);
next ENTRY;
}
my @Months = split( ',', $Months || '' );
my $StartTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $Parameters{StartTime},
},
);
if (
scalar @Months > 1
|| (
scalar @Months == 1
&& $StartTimeObject->Get()->{Day} != $Months[0]
)
)
{
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "CustomYearly";
$Parameters{RecurrenceFrequency} = \@Months;
$Parameters{RecurrenceInterval} = $Interval;
}
else {
$Parameters{Recurring} = 1;
$Parameters{RecurrenceType} = "Yearly";
$Parameters{RecurrenceInterval} = $Interval;
}
}
# FREQ=YEARLY;INTERVAL=2;BYMONTH=1,2,12
# FREQ=MONTHLY;UNTIL=20170302T121500Z'
# FREQ=MONTHLY;UNTIL=20170202T090000Z;INTERVAL=2;BYMONTHDAY=31',
# FREQ=WEEKLY;INTERVAL=2;BYDAY=TU
# FREQ=YEARLY;UNTIL=20200602T080000Z;INTERVAL=2;BYMONTHDAY=1;BYMONTH=4';
# FREQ=DAILY;COUNT=3
if ($Until) {
$Parameters{RecurrenceUntil} = $Self->_FormatTime(
Time => $Until,
);
}
elsif ($Count) {
$Parameters{RecurrenceCount} = $Count;
}
else {
# default value
$Parameters{RecurrenceUntil} = $UntilLimitedTimestamp;
}
# excluded dates
if ( IsArrayRefWithData( $Properties->{'exdate'} ) ) {
my @RecurrenceExclude;
for my $Exclude ( @{ $Properties->{'exdate'} } ) {
if (
ref $Exclude eq 'Data::ICal::Property'
&& $Exclude->{'value'}
)
{
my $TimezoneID;
if ( ref $Exclude->{'_parameters'} eq 'HASH' ) {
# check timezone
if ( $Exclude->{'_parameters'}->{'TZID'} ) {
$TimezoneID = $Exclude->{'_parameters'}->{'TZID'};
}
}
my $ExcludeTimeICal = $Self->_FormatTime(
Time => $Exclude->{'value'},
);
my $ExcludeTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $ExcludeTimeICal,
TimeZone => $TimezoneID,
},
);
if ( !$Parameters{AllDay} ) {
$ExcludeTimeObject->ToOTRSTimeZone();
}
push @RecurrenceExclude, $ExcludeTimeObject->ToString();
}
}
$Parameters{RecurrenceExclude} = \@RecurrenceExclude;
}
}
# check if team object is registered
if ( $Kernel::OM->Get('Kernel::System::Main')->Require( 'Kernel::System::Calendar::Team', Silent => 1 ) ) {
# get team
if (
IsArrayRefWithData( $Properties->{'x-otrs-team'} )
&& ref $Properties->{'x-otrs-team'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'x-otrs-team'}->[0]->{'value'}
)
{
my @Teams = split( ",", $Properties->{'x-otrs-team'}->[0]->{'value'} );
if (@Teams) {
my @TeamIDs;
# get team ids
for my $TeamName (@Teams) {
my %Team = $Kernel::OM->Get('Kernel::System::Calendar::Team')->TeamGet(
Name => $TeamName,
UserID => $Param{UserID},
);
push @TeamIDs, $Team{ID} if $Team{ID};
}
$Parameters{TeamID} = \@TeamIDs if @TeamIDs;
}
}
# get resource
if (
IsArrayRefWithData( $Properties->{'x-otrs-resource'} )
&& ref $Properties->{'x-otrs-resource'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'x-otrs-resource'}->[0]->{'value'}
)
{
my @Resources = split( ",", $Properties->{'x-otrs-resource'}->[0]->{'value'} );
if (@Resources) {
my @Users;
# get user ids
for my $UserLogin (@Resources) {
my $UserID = $Kernel::OM->Get('Kernel::System::User')->UserLookup(
UserLogin => $UserLogin,
);
push @Users, $UserID if $UserID;
}
$Parameters{ResourceID} = \@Users if @Users;
}
}
}
# get available plugin keys suitable for lowercase search
my $PluginKeys = $PluginObject->PluginKeys();
# plugin fields (start with 'x-otrs-plugin-')
my @PluginFields = grep { $_ =~ /x-otrs-plugin-/i } keys %{$Properties};
PLUGINFIELD:
for my $PluginField (@PluginFields) {
if (
IsArrayRefWithData( $Properties->{$PluginField} )
&& ref $Properties->{$PluginField}->[0] eq 'Data::ICal::Property'
&& $Properties->{$PluginField}->[0]->{'value'}
)
{
# extract lowercase plugin key
$PluginField =~ /x-otrs-plugin-(.*)$/;
my $PluginKeyLC = $1;
# get proper plugin key
my $PluginKey = $PluginKeys->{$PluginKeyLC};
next PLUGINFIELD if !$PluginKey;
my @PluginData = split( ",", $Properties->{$PluginField}->[0]->{'value'} );
$LinkedObjects{$PluginKey} = \@PluginData;
}
}
next ENTRY if !$Parameters{Title};
my %Appointment;
# get recurrence id
if (
IsArrayRefWithData( $Properties->{'recurrence-id'} )
&& ref $Properties->{'recurrence-id'}->[0] eq 'Data::ICal::Property'
&& $Properties->{'recurrence-id'}->[0]->{'value'}
)
{
# get parent id
my %ParentAppointment = $AppointmentObject->AppointmentGet(
UniqueID => $Parameters{UniqueID},
CalendarID => $Param{CalendarID},
);
next ENTRY if !%ParentAppointment;
$Parameters{ParentID} = $ParentAppointment{AppointmentID};
my $TimezoneID;
if ( ref $Properties->{'recurrence-id'}->[0]->{'_parameters'} eq 'HASH' ) {
# check timezone
if ( $Properties->{'recurrence-id'}->[0]->{'_parameters'}->{'TZID'} ) {
$TimezoneID = $Properties->{'recurrence-id'}->[0]->{'_parameters'}->{'TZID'};
}
}
my $RecurrenceIDICal = $Self->_FormatTime(
Time => $Properties->{'recurrence-id'}->[0]->{'value'},
);
my $RecurrenceIDObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $RecurrenceIDICal,
TimeZone => $TimezoneID,
},
);
if ( !$Parameters{AllDay} ) {
$RecurrenceIDObject->ToOTRSTimeZone();
}
$Param{RecurrenceID} = $RecurrenceIDObject->ToString();
# delete existing overridden occurrence
$AppointmentObject->AppointmentDeleteOccurrence(
UniqueID => $Parameters{UniqueID},
CalendarID => $Param{CalendarID},
RecurrenceID => $Param{RecurrenceID},
UserID => $Param{UserID},
);
}
# Check if appointment with same UniqueID in the same calendar already exists.
else {
%Appointment = $AppointmentObject->AppointmentGet(
UniqueID => $Parameters{UniqueID},
CalendarID => $Param{CalendarID},
);
if (
$Appointment{CalendarID}
&& ( !$Param{UpdateExisting} || $Appointment{CalendarID} != $Param{CalendarID} )
)
{
# If overwrite option isn't activated, create new appointment by clearing the
# UniqueID.
if (%Appointment) {
delete $Parameters{UniqueID};
}
%Appointment = ();
}
}
my $Success;
# appointment exists in same Calendar, update it
if (
%Appointment
&& $Appointment{AppointmentID}
&& $Param{CalendarID} == $Appointment{CalendarID}
)
{
$Success = $AppointmentObject->AppointmentUpdate(
CalendarID => $Param{CalendarID},
AppointmentID => $Appointment{AppointmentID},
UserID => $Param{UserID},
%Parameters,
);
}
# there is no appointment, create new one
else {
$Success = $AppointmentObject->AppointmentCreate(
CalendarID => $Param{CalendarID},
UserID => $Param{UserID},
%Parameters,
);
}
if ($Success) {
PLUGINKEY:
for my $PluginKey ( sort keys %LinkedObjects ) {
next PLUGINKEY if !IsArrayRefWithData( $LinkedObjects{$PluginKey} );
# add links
for my $PluginData ( @{ $LinkedObjects{$PluginKey} } ) {
my $LinkSuccess = $PluginObject->PluginLinkAdd(
AppointmentID => $Success,
PluginKey => $PluginKey,
PluginData => $PluginData,
UserID => $Param{UserID},
);
if ( !$LinkSuccess ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message =>
"Unable to create object link (AppointmentID=$Success - $PluginKey=$PluginData) during calendar import!"
);
}
}
}
$AppointmentsImported++;
}
}
return $AppointmentsImported;
}
sub _FormatTime {
my ( $Self, %Param ) = @_;
# check needed stuff
for my $Needed (qw(Time)) {
if ( !$Param{$Needed} ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Need $Needed!",
);
return;
}
}
my $TimeStamp;
if ( $Param{Time} =~ /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/i ) {
# format string
$TimeStamp = "$1-$2-$3 $4:$5:$6";
}
elsif ( $Param{Time} =~ /(\d{4})(\d{2})(\d{2})/ ) {
# only date is given (without time)
$TimeStamp = "$1-$2-$3 00:00:00";
}
return $TimeStamp;
}
{
no warnings 'redefine'; ## no critic
# Include additional optional repeatable properties used by some iCalendar implementations, in
# order to prevent Perl warnings.
sub Data::ICal::Entry::Alarm::optional_repeatable_properties { ## no critic
qw(
uid acknowledged related-to description
);
}
sub Data::ICal::Entry::Event::optional_repeatable_properties { ## no critic
my $Self = shift;
my @Properties;
if ( not $Self->vcal10 ) { ## no critic
@Properties = qw(
attach attendee categories comment
contact exdate exrule request-status related-to
resources rdate rrule
);
}
else {
@Properties = qw(
aalarm attach attendee categories
dalarm exdate exrule malarm palarm related-to
resources rdate rrule
);
}
push @Properties, '';
return @Properties;
}
}
1;
=head1 TERMS AND CONDITIONS
This software is part of the OTRS project (L<https://otrs.org/>).
This software comes with ABSOLUTELY NO WARRANTY. For details, see
the enclosed file COPYING for license information (GPL). If you
did not receive this file, see L<https://www.gnu.org/licenses/gpl-3.0.txt>.
=cut