diff --git a/.gitignore b/.gitignore index 2879a3c..a5d2629 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db .idea/ *.iml test_database.sqlite + +#ics temp file +out.ics diff --git a/app/classes/Framadate/Services/ICalService.php b/app/classes/Framadate/Services/ICalService.php new file mode 100644 index 0000000..aadfbed --- /dev/null +++ b/app/classes/Framadate/Services/ICalService.php @@ -0,0 +1,183 @@ +logService = $logService; + $this->notificationService = $notificationService; + $this->sessionService = $sessionService; + $this->purgeService = $purgeService; + $this->pollRepository = RepositoryFactory::pollRepository(); + $this->slotRepository = RepositoryFactory::slotRepository(); + $this->voteRepository = RepositoryFactory::voteRepository(); + $this->commentRepository = RepositoryFactory::commentRepository(); + } + + /** + * Creates an ical-File and initiates the download. If possible, the provided time is used, else an all day event is created. + */ + public function getEvent(object $poll, string $start_day, string $start_time) { + if(!$this->dayIsReadable($start_day)) { + return; + } + + $ical_text = ""; + $start_time = $this->reviseTimeString($start_time); + if($start_time !== null) { + $ical_text = $this->getTimedEvent1Hour($poll, $start_day . " " . $start_time); + } + else { + $date = DateTime::createFromFormat('d-m-Y', $start_day); + $day = $date->format('Ymd'); + $ical_text = $this->getAllDayEvent($poll, $day); + } + $this->provideFile($poll->title, $ical_text); + } + + /** + * Calls getTimedEvent with one hour as a time slot, starting at $start_daytime + */ + function getTimedEvent1Hour(object $poll, string $start_daytime) { + $end_daytime = date(DATE_ATOM, strtotime('+1 hours', strtotime($start_daytime))); + return $this->getTimedEvent($poll, $start_daytime, $end_daytime); + } + + /** + * Generates the text for an ical event including the time + */ + function getTimedEvent(object $poll, string $start_daytime, string $end_daytime) { + $vcalendar = new VObject\Component\VCalendar([ + 'VEVENT' => [ + 'SUMMARY' => $poll->title, + 'DESCRIPTION' => $this->stripMD($poll->description), + 'DTSTART' => new \DateTime($start_daytime), + 'DTEND' => new \DateTime($end_daytime) + ], + 'PRODID' => ICAL_PRODID + ]); + return $vcalendar->serialize(); + } + + /** + * Generates the text for an ical event if the time is not known + */ + function getAllDayEvent(object $poll, string $day) { + $vcalendar = new VObject\Component\VCalendar(); + $vevent = $vcalendar->add('VEVENT'); + $vevent->add('SUMMARY', $poll->title); + $vevent->add('DESCRIPTION', $this->stripMD($poll->description)); + $dtstart = $vevent->add('DTSTART', $day); + $dtstart['VALUE'] = 'DATE'; + unset($vcalendar->PRODID); + $vcalendar->add('PRODID', ICAL_PRODID); + return $vcalendar->serialize(); + } + + /** + * Creates a file and initiates the download + * @param string $ical_text + */ + function provideFile(string $title, string $ical_text) { + header('Content-Description: File Transfer'); + header('Content-Disposition: attachment; filename=' . $this->stripTitle($title) . ICAL_ENDING); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header("Content-Type: text/plain"); + echo $ical_text; + exit; + } + + /** + * Reformats a string value into a time readable by DateTime + * @param string $time + * @return string the corrected value, null if the format is unknown + */ + function reviseTimeString(string $time) { + // 24-hour clock / international format + if (preg_match('/^\d\d(:)\d\d$/', $time)) { + return $time; + } + // 12-hour clock / using am and pm + else if (preg_match('/^\d[0-2]?:?\d{0,2}\s?[aApP][mM]$/', $time)) { + return $this->formatTime($time); + } + // french format HHhMM or HHh + else if (preg_match('/^\d\d?[hH]\d?\d?$/', $time)) { + return $this->formatTime(str_pad(str_ireplace("H", ":", $time), 5, "0")); + } + // Number only + else if (preg_match('/^\d{1,4}$/', $time)) { + return $this->formatTime(str_pad(str_pad($time, 2, "0", STR_PAD_LEFT), 4, "0")); + } + return null; + } + + /** + * @param string $time + * @return 1 if the day string can be parsed, 0 if not and false if an error occured + */ + function dayIsReadable(string $day) { + return preg_match('/^\d{2}-\d{2}-\d{4}$/', $day); + } + + /** + * @param string $time + * @return string date string in format H:i (e.g. 19:00) + */ + function formatTime(string $time) { + return date("H:i", strtotime($time)); + } + + /** + * Converts MD Code to HTML, then strips HTML away + */ + function stripMD(string $string) { + return strip_tags(smarty_modifier_markdown($string)); + } + + /** + * Strips a string so it's usable as a file name (only digits, letters and underline allowed) + */ + function stripTitle(string $string) { + return preg_replace('/[^a-z0-9_]+/', '-', strtolower($string)); + } +} diff --git a/app/inc/constants.php b/app/inc/constants.php index ee68a1d..725abb2 100644 --- a/app/inc/constants.php +++ b/app/inc/constants.php @@ -43,3 +43,6 @@ const SESSION_EDIT_LINK_TIME = "EditLinkMail"; // CSRF (300s = 5min) const TOKEN_TIME = 300; + +const ICAL_ENDING = ".ics"; +const ICAL_PRODID = "-//Framasoft//Framadate//EN"; diff --git a/app/inc/services.php b/app/inc/services.php index 2db37ab..93377b9 100644 --- a/app/inc/services.php +++ b/app/inc/services.php @@ -1,6 +1,7 @@ =2.6.20", + "php": "^7.1 || ^8.0", + "sabre/uri": ">=1.0,<3.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.16.1", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sabre\\Xml\\": "lib/" + }, + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "time": "2020-10-03T10:08:14+00:00" + }, { "name": "sensiolabs/ansi-to-html", "version": "v1.1.3", @@ -1214,6 +1429,7 @@ "code", "zf" ], + "abandoned": "laminas/laminas-code", "time": "2019-10-05T23:18:22+00:00" }, { @@ -1268,6 +1484,7 @@ "events", "zf2" ], + "abandoned": "laminas/laminas-eventmanager", "time": "2018-04-25T15:33:34+00:00" } ], @@ -3887,5 +4104,6 @@ "platform-dev": [], "platform-overrides": { "php": "7.1.0" - } + }, + "plugin-api-version": "1.1.0" } diff --git a/po/de.po b/po/de.po index 9c7d77a..9cc6c72 100644 --- a/po/de.po +++ b/po/de.po @@ -1928,6 +1928,10 @@ msgstr "" "Benutzer, die mit \"Ja\" für diese Option abgestimmt haben, haben folgende E-" "Mail-Adressen:" +#: .studs.Download+as+ical/ics+file +msgid "Download as ical/ics file" +msgstr "Als ical/ics-Datei herunterladen" + #: .studs.Deletion+date: msgid "Deletion date:" msgstr "Löschdatum:" diff --git a/po/en.po b/po/en.po index c7a27f7..a945770 100755 --- a/po/en.po +++ b/po/en.po @@ -1893,6 +1893,10 @@ msgid "Users who voted \"Yes\" for this option have left those email addresses:" msgstr "" "Users who voted \"Yes\" for this option have left those email addresses:" +#: .studs.Download+as+ical/ics+file +msgid "Download as ical/ics file" +msgstr "Download as ical/ics file" + #: .studs.Deletion+date: msgid "Deletion date:" msgstr "Deletion date:" diff --git a/studs.php b/studs.php index 87a003b..7cae71d 100644 --- a/studs.php +++ b/studs.php @@ -50,6 +50,7 @@ $selectedNewVotes = []; /* Services */ /*----------*/ +$icalService = Services::ical(); $inputService = Services::input(); $notificationService = Services::notification(); $pollService = Services::poll(); @@ -218,6 +219,20 @@ function getMessageForOwnVoteEditableVote(SessionService &$sessionService, Smart return $message; } +// ------------------------------- +// Get iCal file +// ------------------------------- +if (isset($_GET['get_ical_file'])) { + $dayAndTime = strval(filter_input(INPUT_GET, 'get_ical_file', FILTER_DEFAULT)); + $dayAndTime = strval(Utils::base64url_decode($dayAndTime)); + $elements = explode("|", $dayAndTime); + if(count($elements) > 1) { + $icalService->getEvent($poll, strval($elements[0]), strval($elements[1])); + } + header('HTTP/1.1 500 Internal Server Error'); + echo 'Internal error'; +} + // Retrieve data if ($resultPubliclyVisible || $accessGranted) { $slots = $pollService->allSlotsByPoll($poll); diff --git a/tpl/part/vote_table_date.tpl b/tpl/part/vote_table_date.tpl index 092b927..7f0ba85 100644 --- a/tpl/part/vote_table_date.tpl +++ b/tpl/part/vote_table_date.tpl @@ -480,8 +480,12 @@ {foreach $slots as $slot} {foreach $slot->moments as $moment} {if $best_choices['y'][$i] == $max} -
  • {$slot->day|date_format_intl:DATE_FORMAT_SHORT|html} - {$moment|html}
  • - {/if} + {assign var="space" value="`$slot->day|date_format_intl:DATE_FORMAT_DATE|html`|`$moment`"} +
  • {$slot->day|date_format_intl:DATE_FORMAT_SHORT|html} - {$moment|html} + + +
  • + {/if} {$i = $i+1} {/foreach} {/foreach}