[Rt-commit] rt branch 5.0/show-calendar-invite-brief created. rt-5.0.2-290-g81617efd6a

BPS Git Server git at git.bestpractical.com
Fri Jul 8 14:48:06 UTC 2022


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "rt".

The branch, 5.0/show-calendar-invite-brief has been created
        at  81617efd6a0e0673ae8501c781341a66e4940514 (commit)

- Log -----------------------------------------------------------------
commit 81617efd6a0e0673ae8501c781341a66e4940514
Author: Brian Conry <bconry at bestpractical.com>
Date:   Thu May 26 16:21:04 2022 -0500

    Display key details for text/calendar messages
    
    Previously ICal messages (typically meeting invitations) were handled
    poorly by RT.  It was very difficult to identify key details about the
    invitation, and in some circumstances the download link was hard to
    find.
    
    Now some key details are extracted from the message and displayed
    inline, and they always have a clear download link for viewing in a
    program with direct calendar support.

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 82bd3d04c6..792221bb11 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -5272,6 +5272,201 @@ sub GetDashboards {
     return \%dashboards;
 }
 
+sub _ExtractCalendarAttachmentData {
+    # The VCALENDAR structure is defined recursively,
+    # and this is a recursive function.
+
+    # Notes on the VCALENDAR structure,
+    # primary sources: RFCs 5545, 5546
+    #   other RFCs may also apply
+    #
+    # Each Component may have Properties and may have Subcomponents.
+    # Each of these is called an "Entry" in some documentation, and also in the Data::ICal module.
+    #
+    # RFC 5545 describes the structure, while RFC 5546 lists which named properties and subcomponents
+    # are permitted, required, and so on.
+    #
+    # VCALENDAR is the top-level component, with VTIMEZONE and VEVENT as the main subcomponents
+    # of interest under it.  VTIMEZINE and VEVENT may have their own subcomponents (e.g. DAYLIGHT
+    # and STANDARD under VTIMEZONE).
+
+    #VCALENDAR
+    # METHOD
+    #   PUBLISH (no interactivity, probably can be ignored)
+    #   REQUEST (interactive, looking for responses)
+    #   REPLY (a response, conveying status)
+    #   ADD (add instances to a recurring series)
+    #   CANCEL (cancel one or more instances)
+    #   REFRESH (used by an attendee to request an update, probably can be ignored)
+    #   COUNTER (used to propose alternate times, probably can be ignored)
+    #   DECLINECOUNTER (probably can be ignored)
+    # CALSCALE - should be absent or GREGORIAN
+
+    #VTIMEZONE
+    # use https://metacpan.org/pod/DateTime::TimeZone::ICal ?
+    # TZID - how it will be referred to later
+    # DAYLIGHT
+    #  DTSTART - irrelevant (start of timezone applicability?)
+    #  RDATE, RRULE, TZNAME, TZOFFSETFROM, TZOFFSETTO
+    # STANDARD
+    #  DTSTART - irrelevant (start of timezone applicability?)
+    #  RDATE, RRULE, TZNAME, TZOFFSETFROM, TZOFFSETTO
+
+    # N.B. I've never seen an invitation with multiple VTIMEZONE records, but it's not against the standard.
+    #      Each non-UTC datetime MUST have a tzid, but because I've never seen more than one I'm
+    #      not bothering to look at it.  This might be a problem for interpreting some attachments.
+
+    #VEVENT
+    # DTSTAMP - last-modified date/time
+    # SEQUENCE - kind of like a DNS serial number
+    # ORGANIZER
+    #  CN - if present would be the name
+    # SUMMARY
+    # LOCATION
+    # DESCRIPTION
+    # RECURRENCE-ID - used when referring to a specific instance of a recurring event
+    # DTSTART
+    # DTEND / DURATION
+    # EXDATE - exceptions to the recurrence rule
+    # RDATE
+    # RRULE
+
+    # Since we're not interested in testing RFC5546 conformance, we're just going to use this recursive
+    # function to walk the structure and cherry-pick what we want.
+
+    # the hashref into which we put our cherry-picked data elements
+    my $summary_data = shift;
+
+    # the Data::ICal::Entry object, from somewhere in the tree
+    my $entry = shift;
+
+    # Except for the root entry, everything has a parent, and we need to know what it
+    # is in order to be sure about what some of the elements mean.
+    my $parent_type = shift || undef;
+
+    my $entry_type;
+
+    eval {
+        $entry_type = $entry->ical_entry_type();
+    };
+
+    if ($@) {
+        RT::Logger->warn( $@ );
+        RT::Logger->warn( ref $entry );
+    }
+
+    my $properties = $entry->properties();
+
+    if ($entry_type eq 'VCALENDAR' and exists $properties->{method}) {
+        my $method = $properties->{method}[0]->value();
+
+        if ($method =~ /^(REQUEST|CANCEL)$/) {
+            $summary_data->{type} = $method;
+        }
+    }
+    elsif ($entry_type eq 'VTIMEZONE' and exists $properties->{tzid}) {
+        $summary_data->{timezone_name} = $properties->{tzid}[0]->value();
+    }
+    elsif ($parent_type and $parent_type eq 'VTIMEZONE' and $entry_type =~ /^(STANDARD|DAYLIGHT)$/ and exists $properties->{tzoffsetto}) {
+        my $offset_type = $1;
+
+        my $value = $properties->{tzoffsetto}[0]->value();
+        $value =~ s/(..)$/:$1/;
+        $value =~ s/(\D)0/$1/;
+        $value =~ s/:00$//;
+
+        $summary_data->{timezone_offset}{$offset_type} = $value;
+    }
+    elsif ($entry_type eq 'VEVENT') {
+        foreach my $property_name (qw{organizer summary location description sequence dtstamp dtstart recurrence-id}) {
+            if (exists $properties->{$property_name}) {
+                $summary_data->{$property_name} = $properties->{$property_name}[0]->value();
+            }
+        }
+
+        if (exists $properties->{rrule}) {
+            $summary_data->{recurring} = 1;
+
+            if (exists $properties->{exdate}) {
+                $summary_data->{exceptions} = 1;
+            }
+        }
+    }
+
+    foreach my $subentry (@{$entry->entries}) {
+        _ExtractCalendarAttachmentData( $summary_data, $subentry, $entry_type );
+    }
+}
+
+=head2 ParseCalendarData( RawData => $cal_data )
+
+Takes the raw data of an ICal file and parses it for useful data.
+
+Returns a hashref of the interesting bits, or undef if it couldn't parse the data.
+
+=cut
+
+sub ParseCalendarData {
+    require Data::ICal;
+
+    my %args = (
+        RawData => undef,
+        @_,
+    );
+
+    return unless $args{RawData};
+
+    my $cal_item = Data::ICal->new(data => $args{RawData});
+
+    if ( ref $cal_item and $cal_item->isa( 'Data::ICal::Entry' )) {
+        my %calendar_info = (
+            location => loc('unspecified'),
+            sequence => 0,
+            type     => 'Unknown calendar attachment', # loc
+        );
+
+        _ExtractCalendarAttachmentData( \%calendar_info, $cal_item );
+
+        if (exists $calendar_info{timezone_name}) {
+            my $offsets = join( '/', grep { defined $_ } @{$calendar_info{timezone_offset}}{ qw(STANDARD DAYLIGHT) } );
+
+            if ($offsets) {
+                $calendar_info{timezone_text} = "$calendar_info{timezone_name} (UTC $offsets)";
+            }
+            else {
+                $calendar_info{timezone_text} = $calendar_info{timezone_name};
+            }
+        }
+
+        foreach my $datetime ( qw(dtstamp dtstart) ) {
+            # dates with a trailing 'Z' actually are in UTC while the other dates are in some
+            # other timezine and the best we can do is to use their values unmodified, which
+            # is most easily accomplished by using UTC.
+
+            next unless exists $calendar_info{$datetime};
+
+            my $date = RT::Date->new( $HTML::Mason::Commands::session{'CurrentUser'} );
+            $date->Set(Format => 'iso', Value => $calendar_info{$datetime}, Timezone => 'UTC');
+
+            if ($calendar_info{$datetime} =~ /Z$/) {
+                # explicitly in UTC, so we know when it is, so go ahead and present it in the user's timezone
+                $calendar_info{$datetime} = $date->AsString();
+            }
+            else {
+                $calendar_info{$datetime} = $date->AsString( Timezone => 'UTC' ) . ' ' . ($calendar_info{timezone_text} || loc("unknown timezone"));
+            }
+        }
+
+        if ($calendar_info{organizer}) {
+            $calendar_info{organizer} =~ s/^MAILTO://i;
+        }
+
+        return \%calendar_info;
+    }
+
+    return undef;
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/share/html/Elements/ShowCalendarInvitation b/share/html/Elements/ShowCalendarInvitation
new file mode 100644
index 0000000000..f21087d3ee
--- /dev/null
+++ b/share/html/Elements/ShowCalendarInvitation
@@ -0,0 +1,134 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2022 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+% if ( @headers ) {
+<table>
+  <tbody>
+%   foreach my $header ( @headers ) {
+      <tr>
+        <td align="right" class="message-header-key text-nowrap"><% $header->{'Tag'} %></td>
+        <td class="message-header-value <% # css classes %>">
+          <% $header->{'Value'} | n %>
+        </td>
+      </tr>
+%   }
+  </tbody>
+</table>
+% }
+% if ( $description ) {
+<div class="messagebody">
+    <div class="message-stanza-folder closed" onclick="fold_message_stanza(this, 'Show\x20full\x20description', 'Hide\x20full\x20description');">Show full description</div>
+    <div class="message-stanza closed"><div class="message-stanza plain-text-white-space"><% $description %></div></div>
+</div>
+% }
+<%ONCE>
+my @simple_elements = (
+    {
+        Tag => 'From:',          # loc
+        Key => 'organizer',
+    },
+    {
+        Tag => 'Last Modified:', # loc
+        Key => 'dtstamp',
+    },
+    {
+        Tag => 'Subject:',       # loc
+        Key => 'summary',
+    },
+    {
+        Tag => 'Location:',      # loc
+        Key => 'location',
+    },
+    {
+        Tag => 'Starting:',      # loc
+        Key => 'dtstart',
+    },
+);
+</%ONCE>
+<%INIT>
+my $type_desc = loc($invitation_info->{type});
+
+if ($invitation_info->{type} eq 'REQUEST') {
+    $type_desc = loc('Invitation to a meeting');
+
+    if ($invitation_info->{recurring}) {
+        $type_desc = loc('Invitation to a recurring meeting');
+
+        if ($invitation_info->{exceptions}) {
+            $type_desc = loc('Invitation to a recurring meeting, with exceptions');
+        }
+    }
+}
+elsif ($invitation_info->{type} eq 'CANCEL') {
+    $type_desc = loc('Meeting cancellation notice');
+
+    if ($invitation_info->{recurring}) {
+        $type_desc = loc('Cancellation notice for a recurring meeting');
+
+        if ($invitation_info->{exceptions}) {
+            loc('Cancellation notice for a recurring meeting, with exceptions');
+        }
+    }
+}
+
+my @headers = ( { Tag => loc('Message Type:'), Value => $type_desc } );
+
+foreach my $simple_element ( @simple_elements ) {
+    if ( $invitation_info->{ $simple_element->{Key} } ) {
+        push @headers, { Tag => loc($simple_element->{Tag}), Value => $invitation_info->{ $simple_element->{Key} } };
+    }
+}
+
+my $description;
+if ( $invitation_info->{description} ) {
+    $description = $invitation_info->{description};
+}
+
+</%INIT>
+<%ARGS>
+$invitation_info => undef
+</%ARGS>
diff --git a/share/html/Elements/ShowTransactionAttachments b/share/html/Elements/ShowTransactionAttachments
index b87a516c5e..6fdb2e6704 100644
--- a/share/html/Elements/ShowTransactionAttachments
+++ b/share/html/Elements/ShowTransactionAttachments
@@ -160,9 +160,29 @@ my $render_attachment = sub {
         $disposition = 'inline';
     }
 
+    my $max_size = RT->Config->Get( 'MaxInlineBody', $session{'CurrentUser'} );
+
     # If it's text
     if ( $content_type =~ m{^(text|message)/} ) {
-        my $max_size = RT->Config->Get( 'MaxInlineBody', $session{'CurrentUser'} );
+
+        # provide a clear download link for meeting invitations
+        if ( $content_type eq 'text/calendar' and not $name) {
+            # A named attachment will already have a download button
+            $m->out('<div class="downloadattachment">');
+            if (my $url = RT->System->ExternalStorageURLFor($message)) {
+                $m->out('<a href="' . $url . '"');
+            }
+            else {
+                $m->out('<a href="' . $AttachmentPath . '/' . $Transaction->Id . '/' . $message->Id . '/meeting.ics' . '" target="_blank"');
+            }
+            my $download_alt = loc( 'Download Meeting Invitation' );
+            $m->out('alt="' . $download_alt . '" data-toggle="tooltip" data-placement="bottom" data-original-title="' . $download_alt . '">');
+            $m->out('<span class="far fa-calendar-alt fa-2x"></span>');
+            $m->out('<span class="downloadfilename">meeting.ics</span>');
+            $m->out('</a>');
+            $m->out('</div>');
+        }
+
         if ( $disposition ne 'inline' ) {
             $m->out('<p>'. loc( 'Message body is not shown because sender requested not to inline it.' ) .'</p>');
             return;
@@ -176,7 +196,25 @@ my $render_attachment = sub {
             return;
         }
 
-        if (
+        my $content;
+        # If we've cached the content, use it from there
+        if (my $x = $AttachmentContent->{ $Transaction->id }->{$message->id}) {
+            $content = $x->Content;
+        }
+        else {
+            $content = $message->Content;
+        }
+
+        if( $content_type eq 'text/calendar' ) {
+            my $calendar_info = ParseCalendarData( RawData => $content );
+
+            if ( $calendar_info ) {
+                $m->comp( '/Elements/ShowCalendarInvitation', invitation_info => $calendar_info );
+            }
+
+            return;
+        }
+        elsif (
 
             # it's a toplevel object
             !$ParentObj
@@ -196,15 +234,6 @@ my $render_attachment = sub {
             )
         ) {
 
-            my $content;
-            # If we've cached the content, use it from there
-            if (my $x = $AttachmentContent->{ $Transaction->id }->{$message->id}) {
-                $content = $x->Content;
-            }
-            else {
-                $content = $message->Content;
-            }
-
             $RT::Logger->debug(
                 "Rendering attachment #". $message->id
                 ." of '$content_type' type"

-----------------------------------------------------------------------


hooks/post-receive
-- 
rt


More information about the rt-commit mailing list