webgui/lib/WebGUI/Workflow/Activity/CalendarUpdateFeeds.pm
Graham Knop 99a9da626d rfe #640: Refactored autocommit and autocomment
Autocommit assets like CS posts or Calendar Events can now be added before the
parent is committed.  They will go into the same version tag as their parent.
2008-10-22 16:04:10 +00:00

555 lines
20 KiB
Perl
Executable file

package WebGUI::Workflow::Activity::CalendarUpdateFeeds;
=head1 LEGAL
-------------------------------------------------------------------
WebGUI is Copyright 2001-2008 Plain Black Corporation.
-------------------------------------------------------------------
Please read the legal notices (docs/legal.txt) and the license
(docs/license.txt) that came with this distribution before using
this software.
-------------------------------------------------------------------
http://www.plainblack.com info@plainblack.com
-------------------------------------------------------------------
=cut
use strict;
use warnings;
use base 'WebGUI::Workflow::Activity';
use WebGUI::Asset::Wobject::Calendar;
use WebGUI::Asset::Event;
use WebGUI::DateTime;
use DateTime::TimeZone;
use LWP::UserAgent;
use JSON ();
=head1 NAME
Package WebGUI::Workflow::Activity::CalendarUpdateFeeds;
=head1 DESCRIPTION
Imports calendar events from Calendar feeds.
=head1 SYNOPSIS
See WebGUI::Workflow::Activity for details on how to use any activity.
=head1 METHODS
These methods are available from this class:
=cut
#-------------------------------------------------------------------
=head2 definition ( session, definition )
See WebGUI::Workflow::Activity::defintion() for details.
=cut
sub definition {
my $class = shift;
my $session = shift;
my $definition = shift;
my $i18n = WebGUI::International->new($session, "Asset_Calendar");
push(@{$definition}, {
name => $i18n->get("workflow updateFeeds"),
properties => { }
});
return $class->SUPER::definition($session,$definition);
}
#-------------------------------------------------------------------
=head2 execute ( )
See WebGUI::Workflow::Activity::execute() for details.
=cut
sub execute {
my $self = shift;
my $session = $self->session;
my $object = shift;
my $instance = shift;
$self->session->user({userId => 3});
### TODO: If we take more than a minute, return WAITING so that some
# other activity can run
my $startTime = time();
my $dt = WebGUI::DateTime->new($session, $startTime)->toMysql;
my $eventList = [];
my $feedList;
if ($instance->getScratch('events')) {
$eventList = JSON::from_json($instance->getScratch('events'));
$feedList = JSON::from_json($instance->getScratch('feeds'));
}
else {
my $ua = LWP::UserAgent->new(agent => "WebGUI");
my $sth = $self->session->db->read("select * from Calendar_feeds");
FEED: while (my $feed = $sth->hashRef) {
#!!! KLUDGE - If the feed is on the same server, set a scratch value
# I do not know how dangerous this is, so THIS MUST CHANGE!
# Preferably: Spectre would add a userSession to the database,
# and send the appropriate cookie with the request.
my $sitename = $self->session->config->get("sitename")->[0];
if ($feed->{url} =~ m{http://[^/]*$sitename})
{
$feed->{url} .= ( $feed->{url} =~ /[?]/ ? ";" : "?" ) . "adminId=".$session->getId;
$self->session->db->write("REPLACE INTO userSessionScratch (sessionId,name,value) VALUES (?,?,?)",
[$session->getId,$feed->{assetId},"SPECTRE"]);
}
#/KLUDGE
#warn "FEED URL: ".$feed->{url} ."\n";
## Somebody point me to a DECENT iCalendar parser...
# Text::vFile perhaps?
# Get the feed
my $response = $ua->get($feed->{url});
if (!$response->is_success) {
# Update the result and last updated fields
$self->session->db->write("update Calendar_feeds set lastResult=?,lastUpdated=? where feedId=?",
[($response->message || $response->content),$dt,$feed->{feedId}]);
next FEED;
}
my $data = $response->content;
# If doesn't start with BEGIN:VCALENDAR then error
unless ($data =~ /^BEGIN:VCALENDAR/i) {
# Update the result and last updated fields
$self->session->db->write(
"update Calendar_feeds set lastResult=?,lastUpdated=? where feedId=?",
["Not an iCalendar feed",$dt,$feed->{feedId}]);
next FEED;
}
my $active = 0; # Parser on/off
my %current_event = ();
my $current_entry = "";
my %events;
my $line_number = 0;
for my $line (split /[\r\n]+/,$data) {
chomp $line;
$line_number++;
next unless $line =~ /\w/;
#warn "LINE $line_number: $line\n";
if ($line =~ /^BEGIN:VEVENT$/i) {
$active = 1;
}
elsif ($line =~ /^END:VEVENT$/i) {
$active = 0;
# Flush event
my $uid = lc $current_event{uid}[1];
delete $current_event{uid};
$events{$uid} = {%current_event};
%current_event = ();
}
elsif ($line =~ /^ /) {
# Add to entry data
$current_entry .= substr $line, 1;
}
else {
# Flush old entry
# KEY;ATTRIBUTE=VALUE;ATTRIBUTE=VALUE:KEYVALUE
my ($key_attrs,$value) = split /:/,$current_entry,2;
my @attrs = $key_attrs ? (split /;/, $key_attrs) : ();
my $key = shift @attrs;
my %attrs;
while (my $attribute = shift @attrs) {
my ($attr_key, $attr_value) = split /=/, $attribute, 2;
$attrs{lc $attr_key} = $attr_value;
}
# Unescape value
$current_event{lc $key} = [\%attrs,$value];
# Start new entry
$current_entry = $line;
}
}
my $feedData = $feedList->{$feed->{feedId}} = {
added => 0,
updated => 0,
errored => 0,
assetId => $feed->{assetId},
};
EVENT: for my $id (keys %events) {
#use Data::Dumper;
#warn "EVENT: $id; ".Dumper $events{$id};
# Prepare event data
my $properties = {
feedUid => $id,
feedId => $feed->{feedId},
description => _unwrapIcalText($events{$id}->{description}->[1]),
title => _unwrapIcalText($events{$id}->{summary}->[1]),
menuTitle => substr($events{$id}->{summary}->[1],0,15),
className => 'WebGUI::Asset::Event',
isHidden => 1,
};
# Prepare the date
my $dtstart = $events{$id}->{dtstart}->[1];
if ($dtstart =~ /T/) {
my ($date, $time) = split /T/, $dtstart;
my ($year, $month, $day) = $date =~ /(\d{4})(\d{2})(\d{2})/;
my ($hour, $minute, $second) = $time =~ /(\d{2})(\d{2})(\d{2})/;
my $tz = $events{$id}->{dtstart}->[0]->{tzid};
if (!$tz || !DateTime::TimeZone->is_valid_name($tz)) {
$tz = "UTC";
}
($properties->{startDate}, $properties->{startTime}) =
split / /, WebGUI::DateTime->new(
year => $year,
month => $month,
day => $day,
hour => $hour,
minute => $minute,
second => $second,
time_zone => $tz,
)->toMysql;
$properties->{timeZone} = $tz;
}
elsif ($dtstart =~ /(\d{4})(\d{2})(\d{2})/) {
my ($year, $month, $day) = $dtstart =~ /(\d{4})(\d{2})(\d{2})/;
$properties->{startDate} = join "-",$year,$month,$day;
}
elsif ($dtstart) {
$session->errorHandler->warn(
"Workflow::Activity::CalendarUpdateFeeds"
. " -- '$dtstart' does not appear to be a valid date"
);
$feedData->{errored}++;
next EVENT;
}
my $dtend = $events{$id}->{dtend}->[1];
my $duration = $events{$id}->{duration}->[1];
if ($dtend =~ /T/) {
my ($date, $time) = split /T/, $dtend;
my ($year, $month, $day) = $date =~ /(\d{4})(\d{2})(\d{2})/;
my ($hour, $minute, $second) = $time =~ /(\d{2})(\d{2})(\d{2})/;
my $tz = $events{$id}->{dtend}->[0]->{tzid};
if (!$tz || !DateTime::TimeZone->is_valid_name($tz)) {
$tz = "UTC";
}
($properties->{endDate}, $properties->{endTime}) =
split / /, WebGUI::DateTime->new(
year => $year,
month => $month,
day => $day,
hour => $hour,
minute => $minute,
second => $second,
time_zone => $tz,
)->toMysql;
$properties->{timeZone} = $tz;
}
elsif ($dtend =~ /(\d{4})(\d{2})(\d{2})/) {
my ($year, $month, $day) = $dtend =~ /(\d{4})(\d{2})(\d{2})/;
$properties->{endDate} = join "-",$year,$month,$day;
}
# If we can't parse it, forget the whole event
elsif ($dtend) {
$session->errorHandler->warn(
"Workflow::Activity::CalendarUpdateFeeds"
. " -- '$dtend' does not appear to be a valid date"
);
$feedData->{errored}++;
next EVENT;
}
# No dtend, but we have duration!
elsif ($duration) {
my ($days, $hours, $minutes, $seconds)
= $duration =~ m{
P
(?:(\d+)D)? # Days
T
(?:(\d+)H)? # Hours
(?:(\d+)M)? # Minutes
(?:(\d+)S)? # Seconds
}ix;
my $startDate = $properties->{startDate};
# Fill in bogus value to get a WebGUI::DateTime object,
# we'll figure out what we actually need later
my $startTime = $properties->{startTime} || "00:00:00";
my $datetime = WebGUI::DateTime->new($session,$startDate." ".$startTime);
$datetime->add(
days => $days || 0,
hours => $hours || 0,
minutes => $minutes || 0,
seconds => $seconds || 0,
);
$properties->{endDate} = $datetime->toDatabaseDate;
# If it not an all-day event, set the end time too
if ($properties->{startTime}) {
$properties->{endTime} = $datetime->toDatabaseTime;
}
}
# No dtend, no duration, just copy the start
else {
$properties->{endDate} = $properties->{startDate};
$properties->{endTime} = $properties->{startTime};
}
# If there are X-WebGUI-* fields
for my $key (grep /^X-WEBGUI-/, keys %{$events{$id}}) {
my $property_name = $key;
$property_name =~ s/^X-WEBGUI-//;
if (lc $property_name eq "groupidedit")
{
$properties->{groupIdEdit} = $events{$id}->{$key}->[1];
}
elsif (lc $property_name eq "groupidview")
{
$properties->{groupIdView} = $events{$id}->{$key}->[1];
}
elsif (lc $property_name eq "url")
{
$properties->{url} = $events{$id}->{$key}->[1];
}
elsif (lc $property_name eq "menutitle")
{
$properties->{menuTitle} = $events{$id}->{$key}->[1];
}
}
my $recur;
if ($events{$id}->{rrule}) {
$recur = _icalToRecur($session, $properties->{startDate}, $events{$id}->{rrule}->[1]);
}
# save events for later
push @$eventList, {
properties => $properties,
recur => $recur,
};
}
}
}
my $currentVersionTag = WebGUI::VersionTag->getWorking($self->session, 1);
if ($currentVersionTag) {
$currentVersionTag->clearWorking;
}
my $ttl = $self->getTTL;
while (@$eventList) {
if ($startTime + $ttl < time()) {
$instance->setScratch('events', JSON::to_json($eventList));
$instance->setScratch('feeds', JSON::to_json($feedList));
my $newVersionTag = WebGUI::VersionTag->getWorking($self->session, 1);
if ($newVersionTag) {
$newVersionTag->requestCommit;
}
if ($currentVersionTag) {
$currentVersionTag->setWorking;
}
return $self->WAITING;
}
my $eventData = shift @$eventList;
my $recur = $eventData->{recur};
my $properties = $eventData->{properties};
my $id = $properties->{feedUid};
my $feed = $feedList->{$properties->{feedId}};
# Update event
my ($assetId) = $self->session->db->quickArray("select assetId from Event where feedUid=?",[$id]);
# If this event already exists, update
if ($assetId) {
#warn "Updating $assetId\n";
my $event = WebGUI::Asset->newByDynamicClass($self->session,$assetId);
if ($event) {
$event->update($properties);
$feed->{updated}++;
}
}
else {
my $calendar = WebGUI::Asset->newByDynamicClass($self->session,$feed->{assetId});
if (!defined $calendar) {
$self->session->errorHandler->error("CalendarUpdateFeeds Activity: Calendar object failed to instanciate. Did you commit the calendar wobject?");
my $newVersionTag = WebGUI::VersionTag->getWorking($self->session, 1);
if ($newVersionTag) {
$newVersionTag->requestCommit;
}
if ($currentVersionTag) {
$currentVersionTag->setWorking;
}
return $self->ERROR;
}
my $event = $calendar->addChild($properties, { skipAutoCommitWorkflows => 1});
$feed->{added}++;
if ($recur) {
$event->setRecurrence($recur);
$event->generateRecurringEvents;
}
}
# TODO: Only update if last-updated field is
# greater than the event's lastUpdated property
}
my $newVersionTag = WebGUI::VersionTag->getWorking($self->session, 1);
if ($newVersionTag) {
$newVersionTag->requestCommit;
}
if ($currentVersionTag) {
$currentVersionTag->setWorking;
}
for my $feedId (keys %$feedList) {
my $feed = $feedList->{$feedId};
$self->session->db->write("update Calendar_feeds set lastResult=?,lastUpdated=? where feedId=?",
["Success! $feed->{added} added, $feed->{updated} updated, $feed->{errored} parsing errors",$dt,$feedId]);
}
$instance->deleteScratch('events');
$instance->deleteScratch('feeds');
return $self->COMPLETE;
}
# We need to use ical format for everything, but this is a stopgap until then.
sub _icalToRecur {
my $session = shift;
my $startDate = shift;
my $rrule = lc shift;
my $ical = {
map {split /=/} split(/;/, $rrule)
};
my $date = WebGUI::DateTime->new($session, "$startDate 00:00:00");
my $startWeekDay = substr('umtwrfs', $date->day_of_week % 7, 1);
my $icalDays = {
su => 'u',
mo => 'm',
tu => 't',
we => 'w',
th => 'r',
fr => 'f',
sa => 's',
};
my $icalMonths = {
1 => 'jan',
2 => 'feb',
3 => 'mar',
4 => 'apr',
5 => 'may',
6 => 'jun',
7 => 'jul',
8 => 'aug',
9 => 'sep',
10 => 'oct',
11 => 'nov',
12 => 'dec',
};
my $recur = {
startDate => $startDate,
every => $ical->{interval} || 1,
};
my $type = lc $ical->{"freq"};
if ($type eq "daily") {
$recur->{recurType} = 'daily';
}
elsif ($type eq "weekly") {
$recur->{recurType} = 'weekly';
$recur->{dayNames} = [];
for my $day (split /,/, $ical->{byday}) {
push @{$recur->{dayNames}}, $icalDays->{$day};
}
if(!@{$recur->{dayNames}}) {
$recur->{dayNames} = [ $startWeekDay ];
}
elsif (!defined $recur->{dayNames}->[0]) {
warn "---$ical->{byday}--- length:" . length($ical->{byday});
}
}
elsif ($type eq "monthly") {
$recur->{recurType} = "monthDay";
$recur->{dayNumber} = $ical->{bymonthday};
}
elsif ($type eq "yearly") {
$recur->{recurType} = "yearDay";
$recur->{dayNumber} = $ical->{bymonthday};
$recur->{months} = [
map { $icalMonths->{lc $1} } split(',', $ical->{bymonth})
];
}
if ($ical->{count}) {
$recur->{endAfter} = $ical->{count};
}
elsif ($ical->{until}) {
$recur->{endDate} = (_icalToMySQL($ical->{until}))[0];
}
else {
$recur->{endDate} = $date->clone->add(years => 2)->toDatabaseDate;
}
return $recur;
}
sub _icalToMySQL {
my $dt = shift;
my ($date, $time) = split /t/, $dt;
my ($year, $month, $day) = $date =~ /(\d{4})(\d{2})(\d{2})/;
my ($hour, $minute, $second) = $time =~ /(\d{2})(\d{2})(\d{2})/;
return split / /, WebGUI::DateTime->new(
year => $year,
month => $month,
day => $day,
hour => $hour,
minute => $minute,
second => $second,
)->toMysql;
}
sub _unwrapIcalText {
my $text = shift;
$text =~ s/\\([.;\\])/$1/g;
return $text;
}
=head1 BUGS
We should probably be using some sort of parser for the iCalendar files. I did
not have time to make a decent observation but the following were observed and
rejected
Data::ICal - Best one I saw. Rejected because I've run out of time
Text::vFile
Net::ICal
iCal::Parser - Bad data structure
Tie::iCal
=cut
1;