formatting for ICS calendar events

Return the HTML as an ext ics attribute for those few clients that
support it (Outlook), and return better-formatted plain text for all
other clients.

closes #9107

Also refactor a bit.

test plan: add assignment and calendar events to the calendar with
links, formatted text, etc in their descriptions. Export the calendar
feed. In Outlook, you'll see the full HTML of the event. In most other
clients, you'll see a plain-text version of the description, but it'll
be much better formatted than it was before.

Change-Id: I50af1c407483d84c65ca285cbf364b6a303e0379
Reviewed-on: https://gerrit.instructure.com/11891
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Rob Orton <rob@instructure.com>
This commit is contained in:
Brian Palmer 2012-06-27 12:47:40 -06:00
parent 71e88b1b85
commit b283bc3b87
7 changed files with 132 additions and 97 deletions

View File

@ -646,40 +646,7 @@ class Assignment < ActiveRecord::Base
end
def to_ics(in_own_calendar=true)
cal = Icalendar::Calendar.new
# to appease Outlook
cal.custom_property("METHOD","PUBLISH")
event = Icalendar::Event.new
event.klass = "PUBLIC"
event.start = self.due_at.utc_datetime if self.due_at
event.start.icalendar_tzid = 'UTC' if event.start
event.end = event.start if event.start
event.end.icalendar_tzid = 'UTC' if event.end
if self.all_day
event.start = Date.new(self.all_day_date.year, self.all_day_date.month, self.all_day_date.day)
event.start.ical_params = {"VALUE"=>["DATE"]}
event.end = event.start
event.end.ical_params = {"VALUE"=>["DATE"]}
end
event.summary = self.title
event.description = strip_tags(self.description).strip
event.location = self.location
event.dtstamp = self.updated_at.utc_datetime if self.updated_at
event.dtstamp.icalendar_tzid = 'UTC' if event.dtstamp
# This will change when there are other things that have calendars...
# can't call calendar_url or calendar_url_for here, have to do it manually
event.url "http://#{HostUrl.context_host(self.context)}/calendar?include_contexts=#{self.context.asset_string}&month=#{self.due_at.strftime("%m") rescue ""}&year=#{self.due_at.strftime("%Y") rescue ""}#assignment_#{self.id.to_s}"
event.uid "event-assignment-#{self.id.to_s}"
event.sequence 0
event = nil unless self.due_at
return event unless in_own_calendar
cal.add_event(event) if event
return cal.to_ical
return CalendarEvent::IcalEvent.new(self).to_ics(in_own_calendar)
end
def all_day

View File

@ -457,44 +457,9 @@ class CalendarEvent < ActiveRecord::Base
end
def to_ics(in_own_calendar=true)
cal = Icalendar::Calendar.new
# to appease Outlook
cal.custom_property("METHOD","PUBLISH")
loc_string = ""
loc_string << self.location_name + ", " if !self.location_name.blank?
loc_string << self.location_address if !self.location_address.blank?
event = Icalendar::Event.new
event.klass = "PUBLIC"
event.start = self.start_at.utc_datetime if self.start_at
event.start.icalendar_tzid = 'UTC' if event.start
event.end = self.end_at.utc_datetime if self.end_at
event.end.icalendar_tzid = 'UTC' if event.end
if self.all_day
event.start = Date.new(self.all_day_date.year, self.all_day_date.month, self.all_day_date.day)
event.start.ical_params = {"VALUE"=>["DATE"]}
event.end = event.start
event.end.ical_params = {"VALUE"=>["DATE"]}
end
event.summary = self.title
event.description = strip_tags(self.description).strip
event.location = loc_string
event.dtstamp = self.updated_at.utc_datetime if self.updated_at
event.dtstamp.icalendar_tzid = 'UTC' if event.dtstamp
# This will change when there are other things that have calendars...
# can't call calendar_url or calendar_url_for here, have to do it manually
event.url "http://#{HostUrl.context_host(self.context)}/calendar?include_contexts=#{self.context.asset_string}&month=#{self.start_at.strftime("%m") rescue ""}&year=#{self.start_at.strftime("%Y") rescue ""}#calendar_event_#{self.id.to_s}"
event.uid "event-calendar-event-#{self.id.to_s}"
event.sequence 0
event = nil unless self.start_at
return event unless in_own_calendar
cal.add_event(event) if event
return cal.to_ical
return CalendarEvent::IcalEvent.new(self).to_ics(in_own_calendar)
end
def self.search(query)
find(:all, :conditions => wildcard('title', 'description', query))
end
@ -622,5 +587,80 @@ class CalendarEvent < ActiveRecord::Base
given { |user, session| !deleted? && self.cached_context_grants_right?(user, session, :manage_calendar) }
can :delete
end
class IcalEvent
include Api
include ActionController::UrlWriter
include TextHelper
def initialize(event)
@event = event
end
def location
end
def to_ics(in_own_calendar)
cal = Icalendar::Calendar.new
# to appease Outlook
cal.custom_property("METHOD","PUBLISH")
event = Icalendar::Event.new
event.klass = "PUBLIC"
start_at = @event.is_a?(CalendarEvent) ? @event.start_at : @event.due_at
end_at = @event.is_a?(CalendarEvent) ? @event.end_at : @event.due_at
if start_at
event.start = start_at.utc_datetime
event.start.icalendar_tzid = 'UTC'
end
if end_at
event.end = end_at.utc_datetime
event.end.icalendar_tzid = 'UTC'
end
if @event.all_day
event.start = Date.new(@event.all_day_date.year, @event.all_day_date.month, @event.all_day_date.day)
event.start.ical_params = {"VALUE"=>["DATE"]}
event.end = event.start
event.end.ical_params = {"VALUE"=>["DATE"]}
end
event.summary = @event.title
if @event.description
html = api_user_content(@event.description, @event.context)
event.description html_to_text(html)
event.x_alt_desc(html, { 'FMTTYPE' => 'text/html' })
end
if @event.is_a?(CalendarEvent)
loc_string = ""
loc_string << @event.location_name + ", " if @event.location_name.present?
loc_string << @event.location_address if @event.location_address.present?
else
loc_string = @event.location
end
event.location = loc_string
event.dtstamp = @event.updated_at.utc_datetime if @event.updated_at
event.dtstamp.icalendar_tzid = 'UTC' if event.dtstamp
tag_name = @event.class.name.underscore
# This will change when there are other things that have calendars...
# can't call calendar_url or calendar_url_for here, have to do it manually
event.url "http://#{HostUrl.context_host(@event.context)}/calendar?include_contexts=#{@event.context.asset_string}&month=#{start_at.try(:strftime, "%m")}&year=#{start_at.try(:strftime, "%Y")}##{tag_name}_#{@event.id.to_s}"
event.uid "event-#{tag_name.gsub('_', '-')}-#{@event.id.to_s}"
event.sequence 0
event = nil unless start_at
return event unless in_own_calendar
cal.add_event(event) if event
return cal.to_ical
end
end
end

View File

@ -0,0 +1 @@
Icalendar::Event.ical_property :x_alt_desc

View File

@ -26,6 +26,22 @@ module TextHelper
text.gsub(/<\/?[^>\n]*>/, "").gsub(/&#\d+;/) {|m| puts m; m[2..-1].to_i.chr rescue '' }.gsub(/&\w+;/, "")
end
# Converts a string of html to plain text, preserving as much of the
# formatting and information as possible
#
# This is still a pretty basic implementation, I'm sure we'll find ways to
# tweak and improve it as time goes on.
def html_to_text(html_str)
doc = Nokogiri::HTML::DocumentFragment.parse(html_str.squeeze(" ").squeeze("\n"))
# translate anchor tags into a markdown-style name/link combo
doc.css('a').each { |node| next if node.text.strip == node['href']; node.replace("[#{node.text}](#{node['href']})") }
# translate img tags into just a url to the image
doc.css('img').each { |node| node.replace(node['src']) }
# append a line break to br and p tags, so they retain a line break after stripping tags
doc.css('br, p').each { |node| node.after("\n\n") }
doc.text.strip
end
def quote_clump(quote_lines)
txt = "<div class='quoted_text_holder'><a href='#' class='show_quoted_text_link'>#{TextHelper.escape_html(I18n.t('lib.text_helper.quoted_text_toggle', "show quoted text"))}</a><div class='quoted_text' style='display: none;'>"
txt += quote_lines.join("\n")

View File

@ -227,6 +227,21 @@ describe TextHelper do
TextHelper.make_subject_reply_to('Re: ohai').should == 'Re: ohai'
end
context ".html_to_text" do
it "should format links in markdown-like style" do
th.html_to_text("<a href='www.example.com'>Link</a>").should == "[Link](www.example.com)"
th.html_to_text("<a href='www.example.com'>www.example.com</a>").should == "www.example.com"
end
it "should turn images into urls" do
th.html_to_text("<img src='http://www.example.com/a'>").should == "http://www.example.com/a"
end
it "should insert newlines for ps and brs" do
th.html_to_text("Ohai<br>Text <p>paragraph of text.</p>End").should == "Ohai\n\nText paragraph of text.\n\nEnd"
end
end
context "markdown" do
context "safety" do
it "should escape Strings correctly" do

View File

@ -925,24 +925,24 @@ describe Assignment do
res.match(/DTEND;VALUE=DATE:20080903/).should_not be_nil
end
it ".to_ics should return a plain-text description" do
assignment_model(:due_at => "Sep 3 2008 12:00am", :description => <<-HTML)
<p>
it ".to_ics should return a plain-text description and alt html description" do
html = %{<div>
This assignment is due December 16th. Plz discuss the reading.
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p>Test.</p>
</p>
HTML
</div>}
assignment_model(:due_at => "Sep 3 2008 12:00am", :description => html)
ev = @assignment.to_ics(false)
ev.description.should == "This assignment is due December 16th. Plz discuss the reading.
Test."
ev.description.should == "This assignment is due December 16th. Plz discuss the reading.\n \n\n\n Test."
ev.x_alt_desc.should == html.strip
end
it ".to_ics should run the description through api_user_content to translate links" do
html = %{<a href="/calendar">Click!</a>}
assignment_model(:due_at => "Sep 3 2008 12:00am", :description => html)
ev = @assignment.to_ics(false)
ev.description.should == "[Click!](http://localhost/calendar)"
ev.x_alt_desc.should == %{<a href="http://localhost/calendar">Click!</a>}
end
end

View File

@ -110,21 +110,17 @@ describe CalendarEvent do
it ".to_ics should return a plain-text description" do
calendar_event_model(:start_at => "Sep 3 2008 12:00am", :description => <<-HTML)
<p>
This assignment is due December 16th. Plz discuss the reading.
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p>Test.</p>
This assignment is due December 16th. <b>Please</b> do the reading.
<br/>
<a href="www.example.com">link!</a>
</p>
HTML
ev = @event.to_ics(false)
ev.description.should == "This assignment is due December 16th. Plz discuss the reading.
Test."
ev.description.should == "This assignment is due December 16th. Please do the reading.
[link!](www.example.com)"
ev.x_alt_desc.should == @event.description
end
end