Merge branch 'ical-1.1.x-backport' into 'v1.1.x'

[1.1.x backport] Allow downloading ics/ical files for best choices

See merge request framasoft/framadate/framadate!482
This commit is contained in:
Thomas Citharel 2021-04-22 07:23:40 +00:00
commit e0028dc813
9 changed files with 440 additions and 6 deletions

3
.gitignore vendored
View File

@ -25,3 +25,6 @@ Thumbs.db
.project .project
.idea/ .idea/
*.iml *.iml
#ics temp file
out.ics

View File

@ -0,0 +1,191 @@
<?php
/**
* This software is governed by the CeCILL-B license. If a copy of this license
* is not distributed with this file, you can obtain one at
* http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.txt
*
* Authors of STUdS (initial project): Guilhem BORGHESI (borghesi@unistra.fr) and Raphaël DROZ
* Authors of Framadate/OpenSondage: Framasoft (https://github.com/framasoft)
*
* =============================
*
* Ce logiciel est régi par la licence CeCILL-B. Si une copie de cette licence
* ne se trouve pas avec ce fichier vous pouvez l'obtenir sur
* http://www.cecill.info/licences/Licence_CeCILL-B_V1-fr.txt
*
* Auteurs de STUdS (projet initial) : Guilhem BORGHESI (borghesi@unistra.fr) et Raphaël DROZ
* Auteurs de Framadate/OpenSondage : Framasoft (https://github.com/framasoft)
*/
namespace Framadate\Services;
use DateTime;
use Framadate\Repositories\RepositoryFactory;
use Sabre\VObject;
class ICalService {
/**
* @var NotificationService
*/
private $notificationService;
/**
* @var SessionService
*/
private $sessionService;
/**
* @var LogService
*/
private $logService;
public function __construct(LogService $logService, NotificationService $notificationService, SessionService $sessionService) {
$this->logService = $logService;
$this->notificationService = $notificationService;
$this->sessionService = $sessionService;
$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($poll, string $start_day, string $start_time) {
if(!$this->dayIsReadable($start_day)) {
return;
}
$ical_text = "";
$elements = explode("-", $start_time);
$end_time = null;
if(count($elements) === 2) {
$start_time = trim($elements[0]);
$end_time = trim($elements[1]);
}
$start_time = $this->reviseTimeString($start_time);
if($end_time !== null) {
$end_time = $this->reviseTimeString($end_time);
}
if($start_time !== null) {
if($end_time !== null) {
$ical_text = $this->getTimedEvent($poll, $start_day . " " . $start_time, $start_day . " " . $end_time);
} else {
$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($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($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($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));
}
}

View File

@ -42,3 +42,6 @@ const SESSION_EDIT_LINK_TIME = "EditLinkMail";
// CSRF (300s = 5min) // CSRF (300s = 5min)
const TOKEN_TIME = 300; const TOKEN_TIME = 300;
const ICAL_ENDING = ".ics";
const ICAL_PRODID = "-//Framasoft//Framadate//EN";

View File

@ -63,7 +63,8 @@
"ircmaxell/password-compat": "dev-master", "ircmaxell/password-compat": "dev-master",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"erusev/parsedown": "^1.7", "erusev/parsedown": "^1.7",
"egulias/email-validator": "~2.1" "egulias/email-validator": "~2.1",
"sabre/vobject": "~4.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^9", "phpunit/phpunit": "^9",

223
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "e7fb9e571fc5d84bc2637bf28453ebe0", "content-hash": "09c4e4991b22cda619447a5746b01a97",
"packages": [ "packages": [
{ {
"name": "doctrine/lexer", "name": "doctrine/lexer",
@ -239,7 +239,7 @@
"version": "dev-develop", "version": "dev-develop",
"source": { "source": {
"type": "git", "type": "git",
"url": "git@github.com:olivierperez/o80-i18n.git", "url": "https://github.com/olivierperez/o80-i18n.git",
"reference": "ef98bd7bd733d23729999ac148f79ea1d7b9008c" "reference": "ef98bd7bd733d23729999ac148f79ea1d7b9008c"
}, },
"dist": { "dist": {
@ -669,6 +669,221 @@
], ],
"time": "2020-12-08T15:02:56+00:00" "time": "2020-12-08T15:02:56+00:00"
}, },
{
"name": "sabre/uri",
"version": "2.2.1",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "f502edffafea8d746825bd5f0b923a60fd2715ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/f502edffafea8d746825bd5f0b923a60fd2715ff",
"reference": "f502edffafea8d746825bd5f0b923a60fd2715ff",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.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": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"time": "2020-10-03T10:33:23+00:00"
},
{
"name": "sabre/vobject",
"version": "4.3.5",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d8a0a9ae215a8acfb51afc29101c7344670b9c83",
"reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.0"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"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": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"time": "2021-02-12T06:28:04+00:00"
},
{
"name": "sabre/xml",
"version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "c3b959f821c19b36952ec4a595edd695c216bfc6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/c3b959f821c19b36952ec4a595edd695c216bfc6",
"reference": "c3b959f821c19b36952ec4a595edd695c216bfc6",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=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": "smarty/smarty", "name": "smarty/smarty",
"version": "v3.1.36", "version": "v3.1.36",
@ -4518,12 +4733,12 @@
"version": "1.9.1", "version": "1.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/webmozart/assert.git", "url": "https://github.com/webmozarts/assert.git",
"reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", "url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
"reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389",
"shasum": "" "shasum": ""
}, },

View File

@ -445,6 +445,7 @@
"studs": { "studs": {
"Adding the vote succeeded": "Die Wertung wurde hinzugefügt", "Adding the vote succeeded": "Die Wertung wurde hinzugefügt",
"Deletion date:": "Löschdatum:", "Deletion date:": "Löschdatum:",
"Download as ical/ics file": "Als ical/ics-Datei herunterladen",
"If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "Wenn Sie an dieser Umfrage teilnehmen möchten, müssen Sie ihren Namen angeben, die Auswahl treffen, die Ihnen am ehesten zusagt und mit dem Plus-Button am Ende der Zeile bestätigen.", "If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "Wenn Sie an dieser Umfrage teilnehmen möchten, müssen Sie ihren Namen angeben, die Auswahl treffen, die Ihnen am ehesten zusagt und mit dem Plus-Button am Ende der Zeile bestätigen.",
"POLL_LOCKED_WARNING": "Die Abstimmung wurde vom Administrator gesperrt. Bewertungen und Kommentare werden eingefroren; es ist nicht mehr möglich teilzunehmen", "POLL_LOCKED_WARNING": "Die Abstimmung wurde vom Administrator gesperrt. Bewertungen und Kommentare werden eingefroren; es ist nicht mehr möglich teilzunehmen",
"The poll is expired, it will be deleted soon.": "Der Zeitraum für die Umfrage ist überschritten, sie wird bald gelöscht werden.", "The poll is expired, it will be deleted soon.": "Der Zeitraum für die Umfrage ist überschritten, sie wird bald gelöscht werden.",

View File

@ -449,6 +449,7 @@
"studs": { "studs": {
"Adding the vote succeeded": "Vote added", "Adding the vote succeeded": "Vote added",
"Deletion date:": "Deletion date:", "Deletion date:": "Deletion date:",
"Download as ical/ics file": "Download as ical/ics file",
"If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "If you want to vote in this poll, you have to give your name, make your choice, and submit it with the plus button at the end of the line.", "If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "If you want to vote in this poll, you have to give your name, make your choice, and submit it with the plus button at the end of the line.",
"POLL_LOCKED_WARNING": "The administrator locked this poll. Votes and comments are frozen, it is no longer possible to participate", "POLL_LOCKED_WARNING": "The administrator locked this poll. Votes and comments are frozen, it is no longer possible to participate",
"The poll is expired, it will be deleted soon.": "The poll has expired, it will soon be deleted.", "The poll is expired, it will be deleted soon.": "The poll has expired, it will soon be deleted.",

View File

@ -22,6 +22,7 @@ use Framadate\Exception\ConcurrentEditionException;
use Framadate\Exception\ConcurrentVoteException; use Framadate\Exception\ConcurrentVoteException;
use Framadate\Message; use Framadate\Message;
use Framadate\Security\Token; use Framadate\Security\Token;
use Framadate\Services\ICalService;
use Framadate\Services\InputService; use Framadate\Services\InputService;
use Framadate\Services\LogService; use Framadate\Services\LogService;
use Framadate\Services\MailService; use Framadate\Services\MailService;
@ -62,6 +63,7 @@ $mailService = new MailService($config['use_smtp'], $config['smtp_options']);
$notificationService = new NotificationService($mailService); $notificationService = new NotificationService($mailService);
$securityService = new SecurityService(); $securityService = new SecurityService();
$sessionService = new SessionService(); $sessionService = new SessionService();
$icalService = new ICalService($logService, $notificationService, $sessionService);
/* PAGE */ /* PAGE */
/* ---- */ /* ---- */
@ -215,6 +217,20 @@ function getMessageForOwnVoteEditableVote(SessionService &$sessionService, Smart
return $message; 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 // Retrieve data
if ($resultPubliclyVisible || $accessGranted) { if ($resultPubliclyVisible || $accessGranted) {
$slots = $pollService->allSlotsByPoll($poll); $slots = $pollService->allSlotsByPoll($poll);

View File

@ -406,7 +406,10 @@
{foreach $slots as $slot} {foreach $slots as $slot}
{foreach $slot->moments as $moment} {foreach $slot->moments as $moment}
{if $best_choices['y'][$i] == $max} {if $best_choices['y'][$i] == $max}
<li><strong>{$slot->day|date_format:$date_format.txt_full|html} - {$moment|html}</strong></li> {assign var="space" value="`$slot->day|date_format:'d-m-Y'|html`|`$moment`"}
<li><strong>{$slot->day|date_format:$date_format.txt_full|html} - {$moment|html}</strong>
<a href="{poll_url id=$poll_id action='get_ical_file' action_value=($space)}" class="btn btn-link btn-sm" title="{__('studs', 'Download as ical/ics file')}">
<i class="fa fa-calendar text-muted" aria-hidden="true"></i></a></li>
{/if} {/if}
{$i = $i+1} {$i = $i+1}
{/foreach} {/foreach}