Added possibility to export the calendar in iCal format (closes #141)

This commit is contained in:
Bernd Bestel 2019-03-04 17:44:48 +01:00
parent 77b0bc675c
commit 90291fdbca
14 changed files with 336 additions and 71 deletions

View File

@ -4,7 +4,8 @@
"slim/slim": "^3.8", "slim/slim": "^3.8",
"morris/lessql": "^0.3.4", "morris/lessql": "^0.3.4",
"rubellum/slim-blade-view": "^0.1.1", "rubellum/slim-blade-view": "^0.1.1",
"tuupola/cors-middleware": "^0.7.0" "tuupola/cors-middleware": "^0.7.0",
"eluceo/ical": "^0.15.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

53
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": "c1bc4c17739e9d0ee8b33628f6d4b9a4", "content-hash": "0b203f875499dfeaa61890cdec018a2d",
"packages": [ "packages": [
{ {
"name": "container-interop/container-interop", "name": "container-interop/container-interop",
@ -104,6 +104,57 @@
], ],
"time": "2018-01-09T20:05:19+00:00" "time": "2018-01-09T20:05:19+00:00"
}, },
{
"name": "eluceo/ical",
"version": "0.15.0",
"source": {
"type": "git",
"url": "https://github.com/markuspoerschke/iCal.git",
"reference": "add0ca99aa1f77f134a2e8b071f2ebc22b115139"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/markuspoerschke/iCal/zipball/add0ca99aa1f77f134a2e8b071f2ebc22b115139",
"reference": "add0ca99aa1f77f134a2e8b071f2ebc22b115139",
"shasum": ""
},
"require": {
"php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^6.0"
},
"suggest": {
"ext-mbstring": "Massive performance enhancement of line folding"
},
"type": "library",
"autoload": {
"psr-4": {
"Eluceo\\iCal\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Markus Poerschke",
"email": "markus@eluceo.de",
"role": "Developer"
}
],
"description": "The eluceo/iCal package offers a abstraction layer for creating iCalendars. You can easily create iCal files by using PHP object instead of typing your *.ics file by hand. The output will follow RFC 5545 as best as possible.",
"homepage": "https://github.com/markuspoerschke/iCal",
"keywords": [
"calendar",
"iCalendar",
"ical",
"ics",
"php calendar"
],
"time": "2019-01-13T22:00:58+00:00"
},
{ {
"name": "http-interop/http-factory", "name": "http-interop/http-factory",
"version": "0.3.0", "version": "0.3.0",

View File

@ -0,0 +1,62 @@
<?php
namespace Grocy\Controllers;
use \Grocy\Services\CalendarService;
use \Grocy\Services\ApiKeyService;
class CalendarApiController extends BaseApiController
{
public function __construct(\Slim\Container $container)
{
parent::__construct($container);
$this->CalendarService = new CalendarService();
$this->ApiKeyService = new ApiKeyService();
}
protected $CalendarService;
protected $ApiKeyService;
public function Ical(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
try
{
$vCalendar = new \Eluceo\iCal\Component\Calendar('grocy');
$events = $this->CalendarService->GetEvents();
foreach($events as $event)
{
$vEvent = new \Eluceo\iCal\Component\Event();
$vEvent->setDtStart(new \DateTime($event['start']))
->setDtEnd(new \DateTime($event['start']))
->setSummary($event['title'])
->setNoTime($event['date_format'] === 'date')
->setUseUtc(false);
$vCalendar->addComponent($vEvent);
}
$response->write($vCalendar->render());
$response = $response->withHeader('Content-Type', 'text/calendar; charset=utf-8');
return $response->withHeader('Content-Disposition', 'attachment; filename="grocy.ics"');
}
catch (\Exception $ex)
{
return $this->GenericErrorResponse($response, $ex->getMessage());
}
}
public function IcalSharingLink(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
try
{
return $this->ApiResponse(array(
'url' => $this->AppContainer->UrlManager->ConstructUrl('/api/calendar/ical?secret=' . $this->ApiKeyService->GetOrCreateApiKey(ApiKeyService::API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL))
));
}
catch (\Exception $ex)
{
return $this->GenericErrorResponse($response, $ex->getMessage());
}
}
}

View File

@ -2,80 +2,22 @@
namespace Grocy\Controllers; namespace Grocy\Controllers;
use \Grocy\Services\StockService; use \Grocy\Services\CalendarService;
use \Grocy\Services\TasksService;
use \Grocy\Services\ChoresService;
use \Grocy\Services\BatteriesService;
class CalendarController extends BaseController class CalendarController extends BaseController
{ {
public function __construct(\Slim\Container $container) public function __construct(\Slim\Container $container)
{ {
parent::__construct($container); parent::__construct($container);
$this->StockService = new StockService(); $this->CalendarService = new CalendarService();
$this->TasksService = new TasksService();
$this->ChoresService = new ChoresService();
$this->BatteriesService = new BatteriesService();
} }
protected $StockService; protected $CalendarService;
protected $TasksService;
protected $ChoresService;
protected $BatteriesService;
public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
$products = $this->Database->products();
$titlePrefix = $this->LocalizationService->Localize('Product expires') . ': ';
$stockEvents = array();
foreach($this->StockService->GetCurrentStock() as $currentStockEntry)
{
$stockEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name,
'start' => $currentStockEntry->best_before_date
);
}
$titlePrefix = $this->LocalizationService->Localize('Task due') . ': ';
$taskEvents = array();
foreach($this->TasksService->GetCurrent() as $currentTaskEntry)
{
$taskEvents[] = array(
'title' => $titlePrefix . $currentTaskEntry->name,
'start' => $currentTaskEntry->due_date
);
}
$chores = $this->Database->chores();
$titlePrefix = $this->LocalizationService->Localize('Chore due') . ': ';
$choreEvents = array();
foreach($this->ChoresService->GetCurrent() as $currentChoreEntry)
{
$choreEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($chores, 'id', $currentChoreEntry->chore_id)->name,
'start' => $currentChoreEntry->next_estimated_execution_time
);
}
$batteries = $this->Database->batteries();
$titlePrefix = $this->LocalizationService->Localize('Battery charge cycle due') . ': ';
$batteryEvents = array();
foreach($this->BatteriesService->GetCurrent() as $currentBatteryEntry)
{
$batteryEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($batteries, 'id', $currentBatteryEntry->battery_id)->name,
'start' => $currentBatteryEntry->next_estimated_charge_time
);
}
$fullcalendarEventSources = array();
$fullcalendarEventSources[] = $stockEvents;
$fullcalendarEventSources[] = $taskEvents;
$fullcalendarEventSources[] = $choreEvents;
$fullcalendarEventSources[] = $batteryEvents;
return $this->AppContainer->view->render($response, 'calendar', [ return $this->AppContainer->view->render($response, 'calendar', [
'fullcalendarEventSources' => $fullcalendarEventSources 'fullcalendarEventSources' => $this->CalendarService->GetEvents()
]); ]);
} }
} }

View File

@ -1859,6 +1859,61 @@
} }
} }
} }
},
"/calendar/ical": {
"get": {
"summary": "Returns the calendar in iCal format",
"tags": [
"Calendar"
],
"responses": {
"200": {
"description": "The iCal file contents",
"content": {
"text/calendar": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "The operation was not successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GenericErrorResponse"
}
}
}
}
}
}
},
"/calendar/ical/sharing-link": {
"get": {
"summary": "Returns a (public) sharing link for the calendar in iCal format",
"tags": [
"Calendar"
],
"responses": {
"200": {
"description": "The (public) sharing link for the calendar in iCal format",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
}
}
}
}
}
}
} }
}, },
"components": { "components": {

View File

@ -333,5 +333,7 @@ return array(
'Picture' => 'Picture', 'Picture' => 'Picture',
'Uncheck ingredients to not put them on the shopping list' => 'Uncheck ingredients to not put them on the shopping list', 'Uncheck ingredients to not put them on the shopping list' => 'Uncheck ingredients to not put them on the shopping list',
'This is for statistical purposes only' => 'This is for statistical purposes only', 'This is for statistical purposes only' => 'This is for statistical purposes only',
'You have to select a recipe' => 'You have to select a recipe' 'You have to select a recipe' => 'You have to select a recipe',
'Key type' => 'Key type',
'Export as iCal' => 'Export as iCal'
); );

View File

@ -44,6 +44,18 @@ class ApiKeyAuthMiddleware extends BaseMiddleware
$validApiKey = false; $validApiKey = false;
} }
// Handling of special purpose API keys
if (!$validApiKey)
{
if ($routeName === 'calendar-ical')
{
if ($request->getQueryParam('secret') !== null && $apiKeyService->IsValidApiKey($request->getQueryParam('secret'), ApiKeyService::API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL))
{
$validApiKey = true;
}
}
}
if (!$validSession && !$validApiKey) if (!$validSession && !$validApiKey)
{ {
define('GROCY_AUTHENTICATED', false); define('GROCY_AUTHENTICATED', false);

2
migrations/0056.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE api_keys
ADD key_type TEXT NOT NULL DEFAULT 'default';

View File

@ -9,3 +9,19 @@
"eventLimit": true, "eventLimit": true,
"eventSources": fullcalendarEventSources "eventSources": fullcalendarEventSources
}); });
$("#ical-button").on("click", function(e)
{
e.preventDefault();
Grocy.Api.Get('calendar/ical/sharing-link',
function(result)
{
location.href = result.url;
},
function(xhr)
{
console.error(xhr);
}
);
});

View File

@ -182,6 +182,13 @@ $app->group('/api', function()
$this->get('/tasks', '\Grocy\Controllers\TasksApiController:Current'); $this->get('/tasks', '\Grocy\Controllers\TasksApiController:Current');
$this->post('/tasks/{taskId}/complete', '\Grocy\Controllers\TasksApiController:MarkTaskAsCompleted'); $this->post('/tasks/{taskId}/complete', '\Grocy\Controllers\TasksApiController:MarkTaskAsCompleted');
} }
// Calendar
if (GROCY_FEATURE_FLAG_CALENDAR)
{
$this->get('/calendar/ical', '\Grocy\Controllers\CalendarApiController:Ical')->setName('calendar-ical');
$this->get('/calendar/ical/sharing-link', '\Grocy\Controllers\CalendarApiController:IcalSharingLink');
}
})->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName)) })->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName))
->add(JsonMiddleware::class) ->add(JsonMiddleware::class)
->add(new CorsMiddleware([ ->add(new CorsMiddleware([

View File

@ -4,10 +4,13 @@ namespace Grocy\Services;
class ApiKeyService extends BaseService class ApiKeyService extends BaseService
{ {
const API_KEY_TYPE_DEFAULT = 'default';
const API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL = 'special-purpose-calendar-ical';
/** /**
* @return boolean * @return boolean
*/ */
public function IsValidApiKey($apiKey) public function IsValidApiKey($apiKey, $keyType = self::API_KEY_TYPE_DEFAULT)
{ {
if ($apiKey === null || empty($apiKey)) if ($apiKey === null || empty($apiKey))
{ {
@ -15,7 +18,7 @@ class ApiKeyService extends BaseService
} }
else else
{ {
$apiKeyRow = $this->Database->api_keys()->where('api_key = :1 AND expires > :2', $apiKey, date('Y-m-d H:i:s', time()))->fetch(); $apiKeyRow = $this->Database->api_keys()->where('api_key = :1 AND expires > :2 AND key_type = :3', $apiKey, date('Y-m-d H:i:s', time()), $keyType)->fetch();
if ($apiKeyRow !== null) if ($apiKeyRow !== null)
{ {
$apiKeyRow->update(array( $apiKeyRow->update(array(
@ -33,14 +36,15 @@ class ApiKeyService extends BaseService
/** /**
* @return string * @return string
*/ */
public function CreateApiKey() public function CreateApiKey($keyType = self::API_KEY_TYPE_DEFAULT)
{ {
$newApiKey = $this->GenerateApiKey(); $newApiKey = $this->GenerateApiKey();
$apiKeyRow = $this->Database->api_keys()->createRow(array( $apiKeyRow = $this->Database->api_keys()->createRow(array(
'api_key' => $newApiKey, 'api_key' => $newApiKey,
'user_id' => GROCY_USER_ID, 'user_id' => GROCY_USER_ID,
'expires' => '2999-12-31 23:59:59' // Default is that API keys expire never 'expires' => '2999-12-31 23:59:59', // Default is that API keys expire never
'key_type' => $keyType
)); ));
$apiKeyRow->save(); $apiKeyRow->save();
@ -68,6 +72,28 @@ class ApiKeyService extends BaseService
return null; return null;
} }
// Returns any valid key for $keyType,
// not allowed for key type "default"
public function GetOrCreateApiKey($keyType)
{
if ($keyType === self::API_KEY_TYPE_DEFAULT)
{
return null;
}
else
{
$apiKeyRow = $this->Database->api_keys()->where('key_type = :1 AND expires > :2', $keyType, date('Y-m-d H:i:s', time()))->fetch();
if ($apiKeyRow !== null)
{
return $apiKeyRow->api_key;
}
else
{
return $this->CreateApiKey($keyType);
}
}
}
private function GenerateApiKey() private function GenerateApiKey()
{ {
return RandomString(50); return RandomString(50);

View File

@ -0,0 +1,80 @@
<?php
namespace Grocy\Services;
use \Grocy\Services\StockService;
use \Grocy\Services\TasksService;
use \Grocy\Services\ChoresService;
use \Grocy\Services\BatteriesService;
class CalendarService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->StockService = new StockService();
$this->TasksService = new TasksService();
$this->ChoresService = new ChoresService();
$this->BatteriesService = new BatteriesService();
}
protected $StockService;
protected $TasksService;
protected $ChoresService;
protected $BatteriesService;
public function GetEvents()
{
$products = $this->Database->products();
$titlePrefix = $this->LocalizationService->Localize('Product expires') . ': ';
$stockEvents = array();
foreach($this->StockService->GetCurrentStock() as $currentStockEntry)
{
if ($currentStockEntry->amount > 0)
{
$stockEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name,
'start' => $currentStockEntry->best_before_date,
'date_format' => 'date'
);
}
}
$titlePrefix = $this->LocalizationService->Localize('Task due') . ': ';
$taskEvents = array();
foreach($this->TasksService->GetCurrent() as $currentTaskEntry)
{
$taskEvents[] = array(
'title' => $titlePrefix . $currentTaskEntry->name,
'start' => $currentTaskEntry->due_date,
'date_format' => 'date'
);
}
$chores = $this->Database->chores();
$titlePrefix = $this->LocalizationService->Localize('Chore due') . ': ';
$choreEvents = array();
foreach($this->ChoresService->GetCurrent() as $currentChoreEntry)
{
$choreEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($chores, 'id', $currentChoreEntry->chore_id)->name,
'start' => $currentChoreEntry->next_estimated_execution_time,
'date_format' => 'datetime'
);
}
$batteries = $this->Database->batteries();
$titlePrefix = $this->LocalizationService->Localize('Battery charge cycle due') . ': ';
$batteryEvents = array();
foreach($this->BatteriesService->GetCurrent() as $currentBatteryEntry)
{
$batteryEvents[] = array(
'title' => $titlePrefix . FindObjectInArrayByPropertyValue($batteries, 'id', $currentBatteryEntry->battery_id)->name,
'start' => $currentBatteryEntry->next_estimated_charge_time,
'date_format' => 'datetime'
);
}
return array_merge($stockEvents, $taskEvents, $choreEvents, $batteryEvents);
}
}

View File

@ -16,12 +16,17 @@
@section('content') @section('content')
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h1>@yield('title')</h1> <h1>
@yield('title')
<a id="ical-button" class="btn btn-outline-dark" href="#">
<i class="fas fa-calendar-plus"></i>&nbsp;{{ $L('Export as iCal') }}
</a>
</h1>
</div> </div>
</div> </div>
<script> <script>
var fullcalendarEventSources = {!! json_encode($fullcalendarEventSources) !!} var fullcalendarEventSources = {!! json_encode(array($fullcalendarEventSources)) !!}
</script> </script>
<div class="row"> <div class="row">

View File

@ -38,6 +38,7 @@
<th>{{ $L('Expires') }}</th> <th>{{ $L('Expires') }}</th>
<th>{{ $L('Last used') }}</th> <th>{{ $L('Last used') }}</th>
<th>{{ $L('Created') }}</th> <th>{{ $L('Created') }}</th>
<th>{{ $L('Key type') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="d-none"> <tbody class="d-none">
@ -66,6 +67,9 @@
{{ $apiKey->row_created_timestamp }} {{ $apiKey->row_created_timestamp }}
<time class="timeago timeago-contextual" datetime="{{ $apiKey->row_created_timestamp }}"></time> <time class="timeago timeago-contextual" datetime="{{ $apiKey->row_created_timestamp }}"></time>
</td> </td>
<td>
{{ $apiKey->key_type }}
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>