[Rt-commit] rt branch 5.0/show-calendar-invite-brief created. rt-5.0.3-487-g0bacee8e63
BPS Git Server
git at git.bestpractical.com
Wed Apr 19 19:44:51 UTC 2023
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 0bacee8e6351e1e62df2193e54382d0921aa36b4 (commit)
- Log -----------------------------------------------------------------
commit 0bacee8e6351e1e62df2193e54382d0921aa36b4
Author: sunnavy <sunnavy at bestpractical.com>
Date: Thu Apr 20 03:41:25 2023 +0800
Test text/calendar messages
diff --git a/t/data/invite.ics b/t/data/invite.ics
new file mode 100644
index 0000000000..c8eac4beee
--- /dev/null
+++ b/t/data/invite.ics
@@ -0,0 +1,50 @@
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VTIMEZONE
+TZID:America/New_York
+X-LIC-LOCATION:America/New_York
+BEGIN:DAYLIGHT
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+DTSTART:19700308T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+DTSTART:19701101T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=America/New_York:20230419T040000
+DTEND;TZID=America/New_York:20230419T050000
+DTSTAMP:20230419T073919Z
+ORGANIZER;CN=alice at example.com:mailto:alice at example.com
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
+ ;CN=alice at example.com;X-NUM-GUESTS=0:mailto:alice at example.com
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
+ TRUE;CN=bob at example.com;X-NUM-GUESTS=0:mailto:bob at example.com
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
+ TRUE;CN=richard at example.com;X-NUM-GUESTS=0:mailto:richard at example.com
+X-MICROSOFT-CDO-OWNERAPPTID:1741891508
+CREATED:20230419T073523Z
+DESCRIPTION:<b><u>这是说明</u></b>
+LAST-MODIFIED:20230419T073919Z
+LOCATION:New York
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:测试标题
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:This is an event reminder
+TRIGGER:-P0DT0H10M0S
+END:VALARM
+END:VEVENT
+END:VCALENDAR
diff --git a/t/web/attachments.t b/t/web/attachments.t
index b6c1b06db3..b4af89bd11 100644
--- a/t/web/attachments.t
+++ b/t/web/attachments.t
@@ -2,10 +2,13 @@ use strict;
use warnings;
use RT::Test tests => undef;
+use File::Spec ();
+use utf8;
use constant LogoFile => $RT::StaticPath .'/images/bpslogo.png';
use constant FaviconFile => $RT::StaticPath .'/images/favicon.png';
use constant TextFile => $RT::StaticPath .'/css/mobile.css';
+use constant CalendarFile => RT::Test::get_relocatable_file( 'invite.ics', ( File::Spec->updir(), 'data' ) );
my ($url, $m) = RT::Test->started_ok;
ok $m->login, 'logged in';
@@ -519,4 +522,34 @@ diag "update and create";
}
+diag "Calendar attachments";
+{
+ $m->goto_create_ticket($queue);
+
+ $m->form_name('TicketCreate');
+ $m->field( 'Attach', CalendarFile );
+ $m->field( 'Subject', 'Attachments test' );
+ $m->field( 'Content', 'Some content' );
+ $m->click('SubmitTicket');
+ is( $m->status, 200, "request successful" );
+
+ ok( $m->find_link( text => 'invite.ics', url_regex => qr{Attachment/} ), 'page has the file link' );
+ my %headers = (
+ 'Type' => 'Invitation to a meeting',
+ 'From' => 'alice at example.com',
+ 'Subject' => '测试标题',
+ 'Location' => 'New York',
+ 'Starting' => 'Wed Apr 19 04:00:00 2023 America/New_York',
+ 'Ending' => 'Wed Apr 19 05:00:00 2023 America/New_York',
+ 'Attendees' => 'alice at example.com, bob at example.com, richard at example.com',
+ 'Last Modified' => 'Wed Apr 19 03:39:19 2023',
+ );
+
+ for my $tag ( sort keys %headers ) {
+ $m->text_contains("$tag: $headers{$tag}");
+ }
+
+ $m->content_contains( '<b><u>这是说明</u></b>', 'found html description' );
+}
+
done_testing;
commit 4652eb14a91537b182166533eb85fbe87de2a4c9
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 d5745fcad2..8af276ee4b 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -5595,6 +5595,203 @@ sub ShortenQuery {
}
}
+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 ( $entry_type eq 'VEVENT' ) {
+ for my $property_name (
+ qw{organizer summary location description sequence dtstamp dtstart dtend recurrence-id attendee})
+ {
+ if ( exists $properties->{$property_name} ) {
+ if ( $property_name eq 'attendee' ) {
+ $summary_data->{$property_name} = join ', ',
+ map { $_->value =~ /.*mailto:(.+)/i ? $1 : () } @{ $properties->{$property_name} };
+ }
+ else {
+ $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} ) {
+ $calendar_info{timezone_text} = $calendar_info{timezone_name};
+ }
+
+ foreach my $datetime (qw(dtstamp dtstart dtend)) {
+
+ # 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};
+
+ # it could be date without time
+ if ( $calendar_info{$datetime} =~ /^\d{8}$/ ) {
+ my $date = RT::Date->new( $session{'CurrentUser'} );
+ $date->Set( Format => 'iso', Value => "$calendar_info{$datetime}00:00:00" );
+ $calendar_info{$datetime} = $date->AsString( Time => 0, Timezone => 'UTC' );
+ }
+ else {
+ my $date = RT::Date->new( $session{'CurrentUser'} );
+ $date->Set( Format => 'iso', Value => $calendar_info{$datetime} );
+
+ 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..0255d21c8d
--- /dev/null
+++ b/share/html/Elements/ShowCalendarInvitation
@@ -0,0 +1,152 @@
+%# 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');"><% loc('Show full description') %></div>
+ <div class="message-stanza closed">
+% if ( $description =~ /<.{1,5}>/ ) {
+ <div class="message-stanza">
+ <% ScrubHTML($description) |n %>
+% } else {
+ <div class="message-stanza plain-text-white-space">
+ <% $description %>
+% }
+ </div>
+ </div>
+</div>
+% }
+
+<%ONCE>
+my @simple_elements = (
+ {
+ Tag => 'From', # loc
+ Key => 'organizer',
+ },
+ {
+ Tag => 'Subject', # loc
+ Key => 'summary',
+ },
+ {
+ Tag => 'Location', # loc
+ Key => 'location',
+ },
+ {
+ Tag => 'Starting', # loc
+ Key => 'dtstart',
+ },
+ {
+ Tag => 'Ending', # loc
+ Key => 'dtend',
+ },
+ {
+ Tag => 'Attendees', # loc
+ Key => 'attendee',
+ },
+ {
+ Tag => 'Last Modified', # loc
+ Key => 'dtstamp',
+ },
+);
+</%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('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..80580cbcfe 100644
--- a/share/html/Elements/ShowTransactionAttachments
+++ b/share/html/Elements/ShowTransactionAttachments
@@ -56,7 +56,8 @@ foreach my $message ( @{ $Attachments->{ $Parent || 0 } || [] } ) {
);
my $name = defined $message->Filename && length $message->Filename ? $message->Filename : '';
- my $should_render_download = $message->ContentLength || $name;
+ # calendar files are already rendered.
+ my $should_render_download = ($message->ContentLength || $name) && lc($message->ContentType) ne 'text/calendar';
$m->callback(CallbackName => 'BeforeAttachment', ARGSRef => \%ARGS, Object => $Object, Transaction => $Transaction, Attachment => $message, Name => $name, ShouldRenderDownload => \$should_render_download);
@@ -163,6 +164,25 @@ my $render_attachment = sub {
# 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' ) {
+ # 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">' . ( $name || '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,26 @@ 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' ) {
+ # iCalendar is supposed to be UTF-8 encoded.
+ my $calendar_info = ParseCalendarData( RawData => Encode::decode('UTF-8', $content, Encode::FB_PERLQQ) );
+
+ if ( $calendar_info ) {
+ $m->comp( '/Elements/ShowCalendarInvitation', invitation_info => $calendar_info );
+ }
+
+ return;
+ }
+ elsif (
# it's a toplevel object
!$ParentObj
@@ -196,15 +235,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