require "add_to_calendar_links/version"
# erb util needed for url_encode method
# CGI::escape uses + instead of %20 which doesn't work for ical files
require "erb"
include ERB::Util
require 'tzinfo'
require 'date'
require 'uri'
module AddToCalendarLinks
class Error < StandardError; end
class URLs
attr_accessor :start_datetime, :end_datetime, :title, :timezone, :location, :url, :description, :add_url_to_description, :organizer, :strip_html, :sequence, :last_modified, :uid
def initialize(start_datetime:, end_datetime: nil, title:, timezone:, location: nil, url: nil, description: nil, add_url_to_description: true, organizer: nil, strip_html: false, sequence: nil, last_modified: Time.now.utc, uid:)
@start_datetime = start_datetime
@end_datetime = end_datetime
@title = title
@timezone = TZInfo::Timezone.get(timezone)
@location = location
@url = url
@description = description
@add_url_to_description = add_url_to_description
@organizer = URI.parse(organizer) if organizer
@strip_html = strip_html
@sequence = sequence
@uid = uid
@last_modified = last_modified
validate_attributes
end
def google_url
# Eg. https://www.google.com/calendar/render?action=TEMPLATE&text=Holly%27s%208th%20Birthday!&dates=20200615T180000/20200615T190000&ctz=Europe/London&details=Join%20us%20to%20celebrate%20with%20lots%20of%20games%20and%20cake!&location=Apartments,%20London&sprop=&sprop=name:
calendar_url = "https://www.google.com/calendar/render?action=TEMPLATE"
params = {}
params[:text] = url_encode(title)
if end_datetime
params[:dates] = "#{format_date_google(start_datetime)}/#{format_date_google(end_datetime)}"
else
params[:dates] = "#{format_date_google(start_datetime)}/#{format_date_google(start_datetime + 60*60)}" # end time is 1 hour later
end
params[:ctz] = timezone.identifier
params[:location] = url_encode(location) if location
params[:details] = url_encode(description) if description
if add_url_to_description && url
if params[:details]
params[:details] << url_encode("\n\n#{url}")
else
params[:details] = url_encode(url)
end
end
params.each do |key, value|
calendar_url << "{key}=#{value}"
end
return calendar_url
end
def yahoo_url
# Eg. https://calendar.yahoo.com/?v=60&view=d&type=20&title=Holly%27s%208th%20Birthday!&st=20200615T170000Z&dur=0100&desc=Join%20us%20to%20celebrate%20with%20lots%20of%20games%20and%20cake!&in_loc=7%20Apartments,%20London
calendar_url = "https://calendar.yahoo.com/?v=60&VIEW=d&TYPE=20"
params = {}
params[:TITLE] = url_encode(title)
params[:ST] = utc_datetime(start_datetime)
if end_datetime
# params[:ET] = utc_datetime(end_datetime)
seconds = duration_seconds(start_datetime, end_datetime)
params[:DUR] = seconds_to_hours_minutes(seconds)
else
# params[:ET] = utc_datetime(start_datetime + 60*60)
params[:DUR] = "0100"
end
params[:DESC] = url_encode(strip_html_tags(description)).truncate(3000) if description
if add_url_to_description && url
if params[:DESC]
params[:DESC] << url_encode("\n\n#{url}")
else
params[:DESC] = url_encode(url)
end
end
params[:IN_LOC] = url_encode(location) if location
params.each do |key, value|
calendar_url << "{key}=#{value}"
end
return calendar_url
end
def office365_url
# Eg. https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent&subject=Holly%27s%208th%20Birthday%21&startdt=2020-05-12T12:30:00Z&enddt=2020-05-12T16:00:00Z&body=Come%20join%20us%20for%20lots%20of%20fun%20%26%20cake%21%0A%0Ahttps%3A%2F%2Fwww.example.com%2Fevent-details&location=Flat%204%2C%20The%20Edge%2C%2038%20Smith-Dorrien%20St%2C%20London%2C%20N1%207GU
microsoft("office365")
end
def outlook_com_url
# Eg. https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent&subject=Holly%27s%208th%20Birthday%21&startdt=2020-05-12T12:30:00Z&enddt=2020-05-12T16:00:00Z&body=Come%20join%20us%20for%20lots%20of%20fun%20%26%20cake%21%0A%0Ahttps%3A%2F%2Fwww.example.com%2Fevent-details&location=Flat%204%2C%20The%20Edge%2C%2038%20Smith-Dorrien%20St%2C%20London%2C%20N1%207GU
microsoft("outlook.com")
end
def ical_file
calendar_url = "BEGIN:VCALENDAR\nVERSION:2.0\nMETHOD:REQUEST\nBEGIN:VEVENT"
params = {}
params[:DTSTART] = utc_datetime(start_datetime)
if end_datetime
params[:DTEND] = utc_datetime(end_datetime)
else
params[:DTEND] = utc_datetime(start_datetime + 60*60) # 1 hour later
end
params[:SUMMARY] = strip_html_tags(title) #ical doesnt support html so remove all markup. Optional for other formats
params[:URL] = url if url
params[:DESCRIPTION] = strip_html_tags(description).strip if description
if add_url_to_description && url
if params[:DESCRIPTION]
params[:DESCRIPTION] << "\\n\\n#{url}"
else
params[:DESCRIPTION] = url
end
end
params[:LOCATION] = strip_html_tags(location) if location
if uid
params[:UID] = uid
else
params[:UID] = "-#{urlc}" if url
params[:UID] = "-#{utc_datetime(start_datetime)}-#{title}" unless params[:UID] # set uid based on starttime and title only if url is unavailable
end
params[:ORGANIZER] = organizer if organizer
params[:SEQUENCE] = sequence if sequence
params["LAST-MODIFIED"] = format_date_google(last_modified) if last_modified
params[:METHOD] = "REQUEST"
params.each do |key, value|
calendar_url << "\n#{key}:#{value}"
end
calendar_url << "\nEND:VEVENT\nEND:VCALENDAR"
return calendar_url
end
def ical_url
# Downloads a *.ics file provided as a data-uri
# Eg. "data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0ABEGIN:VEVENT%0ADTSTART:20200512T123000Z%0ADTEND:20200512T160000Z%0ASUMMARY:Holly%27s%208th%20Birthday%21%0AURL:https%3A%2F%2Fwww.example.com%2Fevent-details%0ADESCRIPTION:Come%20join%20us%20for%20lots%20of%20fun%20%26%20cake%21\\n\\nhttps%3A%2F%2Fwww.example.com%2Fevent-details%0ALOCATION:Flat%204%5C%2C%20The%20Edge%5C%2C%2038%20Smith-Dorrien%20St%5C%2C%20London%5C%2C%20N1%207GU%0AUID:-https%3A%2F%2Fwww.example.com%2Fevent-details%0AEND:VEVENT%0AEND:VCALENDAR"
calendar_url = "data:text/calendar;charset=utf8,BEGIN:VCALENDAR%0AVERSION:2.0%0AMETHOD:REQUEST%0ABEGIN:VEVENT"
params = {}
params[:DTSTART] = utc_datetime(start_datetime)
if end_datetime
params[:DTEND] = utc_datetime(end_datetime)
else
params[:DTEND] = utc_datetime(start_datetime + 60*60) # 1 hour later
end
params[:SUMMARY] = url_encode_ical(title, strip_html: true) #ical doesnt support html so remove all markup. Optional for other formats
params[:URL] = url_encode(url) if url
params[:DESCRIPTION] = url_encode_ical(strip_html_tags(description, line_break_seperator: "\n")) if description
if add_url_to_description && url
if params[:DESCRIPTION]
params[:DESCRIPTION] << "\\n\\n#{url_encode(url)}"
else
params[:DESCRIPTION] = url_encode(url)
end
end
params[:LOCATION] = url_encode_ical(location) if location
if uid
params[:UID] = uid
else
params[:UID] = "-#{url_encode(url)}" if url
params[:UID] = "-#{utc_datetime(start_datetime)}-#{url_encode_ical(title)}" unless params[:UID] # set uid based on starttime and title only if url is unavailable
end
params[:ORGANIZER] = organizer if organizer
params[:SEQUENCE] = sequence if sequence
params["LAST-MODIFIED"] = format_date_google(last_modified) if last_modified
new_line = "%0A"
params.each do |key, value|
calendar_url << "#{new_line}#{key}:#{value}"
end
calendar_url << "%0AEND:VEVENT%0AEND:VCALENDAR"
return calendar_url
end
def apple_url
ical_url
end
def outlook_url
ical_url
end
def android_url
ical_url
end
private
def validate_attributes
# msg = "- Object must be a DateTime or Time object."
msg = "- Object must be a Time object."
raise(ArgumentError, ":start_datetime #{msg} #{start_datetime.class} given") unless start_datetime.kind_of? Time
if end_datetime
raise(ArgumentError, ":end_datetime #{msg} #{end_datetime.class} given") unless end_datetime.kind_of? Time
raise(ArgumentError, ":end_datetime must be greater than :start_datetime") unless end_datetime > start_datetime
end
raise(ArgumentError, ":title must be a string") unless self.title.kind_of? String
raise(ArgumentError, ":title must not be blank") if self.title.strip.empty? # strip first, otherwise " ".empty? #=> false
if location
raise(ArgumentError, ":location must be a string") unless self.location.kind_of? String
end
if description
raise(ArgumentError, ":description must be a string") unless self.description.kind_of? String
end
if organizer
raise(ArgumentError, ":organizer must be a string") unless self.organizer.kind_of? String
end
end
def microsoft(service)
# Eg.
calendar_url = case service
when "outlook.com"
"https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent"
when "office365"
"https://outlook.office.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent"
else
raise MicrosoftServiceError, ":service must be 'outlook.com' or 'office365'. '#{service}' given"
end
params = {}
params[:subject] = url_encode(title.gsub(' & ', ' and '))
params[:startdt] = utc_datetime_microsoft(start_datetime)
if end_datetime
params[:enddt] = utc_datetime_microsoft(end_datetime)
else
params[:enddt] = utc_datetime_microsoft(start_datetime + 60*60) # 1 hour later
end
params[:body] = url_encode(newlines_to_html_br(description)) if description
if add_url_to_description && url
if params[:body]
params[:body] << url_encode(newlines_to_html_br("\n\n#{url}"))
else
params[:body] = url_encode(url)
end
end
params[:location] = url_encode(location) if location
params.each do |key, value|
calendar_url << "{key}=#{value}"
end
return calendar_url
end
def utc_datetime(datetime)
t = timezone.local_to_utc(
Time.new(
datetime.strftime("%Y").to_i,
datetime.strftime("%m").to_i,
datetime.strftime("%d").to_i,
datetime.strftime("%H").to_i,
datetime.strftime("%M").to_i,
datetime.strftime("%S").to_i
)
)
return t.strftime('%Y%m%dT%H%M%SZ')
end
def utc_datetime_microsoft(datetime)
t = timezone.local_to_utc(
Time.new(
datetime.strftime("%Y").to_i,
datetime.strftime("%m").to_i,
datetime.strftime("%d").to_i,
datetime.strftime("%H").to_i,
datetime.strftime("%M").to_i,
datetime.strftime("%S").to_i
)
)
return t.strftime('%Y-%m-%dT%H:%M:%SZ')
end
def format_date_google(start_datetime)
start_datetime.strftime('%Y%m%dT%H%M%S')
end
def duration_seconds(start_time, end_time)
(start_time.to_i - end_time.to_i).abs
end
def seconds_to_hours_minutes(sec)
"%02d%02d" % [sec / 3600, sec / 60 % 60]
end
def newlines_to_html_br(string)
string.gsub(/(?:\n\r?|\r\n?)/, '
')
end
def url_encode_ical(s, strip_html: @strip_html)
# per https://tools.ietf.org/html/rfc5545#section-3.3.11
string = s.dup # don't modify original input
if strip_html
string = strip_html_tags(string)
end
string.gsub!("\\", "\\\\\\") # \ >> \\ --yes, really: https://stackoverflow.com/questions/6209480/how-to-replace-backslash-with-double-backslash
string.gsub!("\r\n", "\n") # so can handle all newlines the same
string.split("\n").map { |e|
if e.empty?
e
else
url_encode(e)
end
}.compact.join("\\n").gsub(/(\\n){2,}/, "\\n\\n").strip
end
def strip_html_tags(description, line_break_seperator: "\\n")
string = description.dup
string.gsub!("
", line_break_seperator)
string.gsub!("
", line_break_seperator) string.gsub!("
", line_break_seperator) string.gsub!("&", "and") string.gsub!(" ", " ") string.gsub!(/<\/?[^>]*>/, "") string.gsub!(/(\\n){2,}/, "\\n\\n") string.strip end end end