mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-11-21 15:33:23 +01:00
WIP REST Api for Timesheet app
This commit is contained in:
parent
0ad1729fe0
commit
2aedd7f5ef
@ -19,7 +19,7 @@ use EGroupware\Api\CalDAV\Principals;
|
||||
// explicit import non-namespaced classes
|
||||
require_once(__DIR__.'/WebDAV/Server.php');
|
||||
|
||||
use EGroupware\Api\Contacts\JsContactParseException;
|
||||
use EGroupware\Api\CalDAV\JsParseException;
|
||||
use HTTP_WebDAV_Server;
|
||||
use calendar_hooks;
|
||||
|
||||
@ -1156,7 +1156,9 @@ class CalDAV extends HTTP_WebDAV_Server
|
||||
'address-data' => self::mkprop(self::CARDDAV, 'address-data', '')
|
||||
] : ($is_calendar ? [
|
||||
'calendar-data' => self::mkprop(self::CALDAV, 'calendar-data', ''),
|
||||
] : 'all'),
|
||||
] : [
|
||||
'data' => self::mkprop(self::CALDAV, 'data', '')
|
||||
]),
|
||||
'other' => [],
|
||||
);
|
||||
|
||||
@ -1262,7 +1264,7 @@ class CalDAV extends HTTP_WebDAV_Server
|
||||
// check if this is a property-object
|
||||
elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val']))
|
||||
{
|
||||
$value = in_array($prop['name'], ['address-data', 'calendar-data']) ? $prop['val'] : self::jsonProps($prop['val']);
|
||||
$value = in_array($prop['name'], ['address-data', 'calendar-data', 'data']) ? $prop['val'] : self::jsonProps($prop['val']);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -2557,7 +2559,7 @@ class CalDAV extends HTTP_WebDAV_Server
|
||||
if (self::isJSON())
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (is_a($e, JsContactParseException::class))
|
||||
if (is_a($e, JsParseException::class))
|
||||
{
|
||||
$status = '422 Unprocessable Entity';
|
||||
}
|
||||
|
@ -561,7 +561,7 @@ abstract class Handler
|
||||
*/
|
||||
function get_path($entry)
|
||||
{
|
||||
return (is_array($entry) ? $entry[self::$path_attr] : $entry).self::$path_extension;
|
||||
return (is_array($entry) ? $entry[static::$path_attr] : $entry).static::$path_extension;
|
||||
}
|
||||
|
||||
/**
|
||||
|
387
api/src/CalDAV/JsBase.php
Normal file
387
api/src/CalDAV/JsBase.php
Normal file
@ -0,0 +1,387 @@
|
||||
<?php
|
||||
/**
|
||||
* EGroupware REST API - JsBase base-class
|
||||
*
|
||||
* @link https://www.egroupware.org
|
||||
* @author Ralf Becker <rb@egroupware.org>
|
||||
* @package calendar
|
||||
* @copyright (c) 2023 by Ralf Becker <rb@egroupware.org>
|
||||
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
|
||||
*/
|
||||
|
||||
namespace EGroupware\Api\CalDAV;
|
||||
|
||||
use EGroupware\Api;
|
||||
|
||||
/**
|
||||
* Shared base-class of all REST API / JMAP classes
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc8984
|
||||
* @link https://jmap.io/spec-calendars.html
|
||||
*/
|
||||
class JsBase
|
||||
{
|
||||
const APP = null;
|
||||
|
||||
const MIME_TYPE_JSON = "application/json";
|
||||
|
||||
const URN_UUID_PREFIX = 'urn:uuid:';
|
||||
const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i';
|
||||
|
||||
/**
|
||||
* Get UID with either "urn:uuid:" prefix for UUIDs or just the text
|
||||
*
|
||||
* @param string $uid
|
||||
* @return string
|
||||
*/
|
||||
protected static function uid(string $uid)
|
||||
{
|
||||
return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and optionally generate UID
|
||||
*
|
||||
* @param string|null $uid
|
||||
* @param string|null $old old value, if given it must NOT change
|
||||
* @param bool $generate_when_empty true: generate UID if empty, false: throw error
|
||||
* @return string without urn:uuid: prefix
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false)
|
||||
{
|
||||
if (empty($uid) || strlen($uid) < 12)
|
||||
{
|
||||
if (!$generate_when_empty)
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid));
|
||||
}
|
||||
$uid = \HTTP_WebDAV_Server::_new_uuid();
|
||||
}
|
||||
if (strpos($uid, self::URN_UUID_PREFIX) === 0)
|
||||
{
|
||||
$uid = substr($uid, strlen(self::URN_UUID_PREFIX));
|
||||
}
|
||||
if (isset($old) && $old !== $uid)
|
||||
{
|
||||
throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid));
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a date-time value in UTC
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc8984#section-1.4.4
|
||||
* @param null|string|\DateTime $date
|
||||
* @param bool $user false: timestamp in server-time, true: timestamp in user-time, does NOT matter for DateTime objects
|
||||
* @return string|null
|
||||
*/
|
||||
protected static function UTCDateTime($date, bool $user=false)
|
||||
{
|
||||
static $utc=null;
|
||||
if (!isset($utc)) $utc = new \DateTimeZone('UTC');
|
||||
|
||||
if (!isset($date))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
$date = $user ? Api\DateTime::user2server($date, 'object') : Api\DateTime::to($date, 'object');
|
||||
$date->setTimezone($utc);
|
||||
|
||||
// we need to use "Z", not "+00:00"
|
||||
return substr($date->format(Api\DateTime::RFC3339), 0, -6).'Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Output an account as email, if available, username or as last resort the numerical account_id
|
||||
*
|
||||
* @param int|null $account_id
|
||||
* @return string|int|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected static function account(int $account_id=null)
|
||||
{
|
||||
if (!$account_id)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Api\Accounts::id2name($account_id, 'account_email') ?: Api\Accounts::id2name($account_id) ?: $account_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an account specified as email, account_lid or account_id
|
||||
*
|
||||
* @param string|int $value
|
||||
* @param ?bool $user
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected static function parseAccount(string $value, bool $user=true)
|
||||
{
|
||||
if (is_numeric($value) && ($exists = Api\Accounts::getInstance()->exists(value)) &&
|
||||
(!isset($user) || $exists === ($user ? 1 : 2)))
|
||||
{
|
||||
$account_id = (int)$value;
|
||||
}
|
||||
else
|
||||
{
|
||||
$account_id = Api\Accounts::getInstance()->name2id($value,
|
||||
strpos($value, '@') !== false ? 'account_email' : 'account_lid',
|
||||
isset($user) ? ($user ? 'u' : 'g') : null);
|
||||
}
|
||||
if (!$account_id)
|
||||
{
|
||||
throw new JsParseException("Invalid or non-existing account '$value'");
|
||||
}
|
||||
return $account_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON options for errors thrown as exceptions
|
||||
*/
|
||||
const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE;
|
||||
|
||||
const AT_TYPE = '@type';
|
||||
|
||||
/**
|
||||
* Return EGroupware custom fields
|
||||
*
|
||||
* @param array $contact
|
||||
* @return array
|
||||
*/
|
||||
protected static function customfields(array $contact)
|
||||
{
|
||||
$fields = [];
|
||||
foreach(Api\Storage\Customfields::get(static::APP) as $name => $data)
|
||||
{
|
||||
$value = $contact['#'.$name];
|
||||
if (isset($value))
|
||||
{
|
||||
switch($data['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$value = Api\DateTime::to($value, Api\DateTime::RFC3339);
|
||||
break;
|
||||
case 'float':
|
||||
$value = (double)$value;
|
||||
break;
|
||||
case 'int':
|
||||
$value = (int)$value;
|
||||
break;
|
||||
case 'select':
|
||||
$value = explode(',', $value);
|
||||
break;
|
||||
}
|
||||
$fields[$name] = array_filter([
|
||||
'value' => $value,
|
||||
'type' => $data['type'],
|
||||
'label' => $data['label'],
|
||||
'values' => $data['values'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom fields
|
||||
*
|
||||
* Not defined custom fields are ignored!
|
||||
* Not send custom fields are set to null!
|
||||
*
|
||||
* @param array $cfs name => object with attribute data and optional type, label, values
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseCustomfields(array $cfs)
|
||||
{
|
||||
$contact = [];
|
||||
$definitions = Api\Storage\Customfields::get(self::APP);
|
||||
|
||||
foreach($definitions as $name => $definition)
|
||||
{
|
||||
$data = $cfs[$name];
|
||||
if (isset($data))
|
||||
{
|
||||
if (is_scalar($data) || is_array($data) && !isset($data['value']))
|
||||
{
|
||||
$data = ['value' => $data];
|
||||
}
|
||||
if (!is_array($data) || !array_key_exists('value', $data))
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
switch($definition['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$data['value'] = Api\DateTime::to($data['value'], 'object');
|
||||
break;
|
||||
case 'float':
|
||||
$data['value'] = (double)$data['value'];
|
||||
break;
|
||||
case 'int':
|
||||
$data['value'] = round($data['value']);
|
||||
break;
|
||||
case 'select':
|
||||
if (is_scalar($data['value'])) $data['value'] = explode(',', $data['value']);
|
||||
$data['value'] = array_intersect(array_keys($definition['values']), $data['value']);
|
||||
$data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null;
|
||||
break;
|
||||
}
|
||||
$contact['#'.$name] = $data['value'];
|
||||
}
|
||||
// set not return cfs to null
|
||||
else
|
||||
{
|
||||
$contact['#'.$name] = null;
|
||||
}
|
||||
}
|
||||
// report not existing cfs to log
|
||||
if (($not_existing=array_diff(array_keys($cfs), array_keys($definitions))))
|
||||
{
|
||||
error_log(__METHOD__."() not existing/ignored custom fields: ".implode(', ', $not_existing));
|
||||
}
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return object of category-name(s) => true
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4
|
||||
* @param ?string $cat_ids comma-sep. cat_id's
|
||||
* @return true[]
|
||||
*/
|
||||
protected static function categories(?string $cat_ids)
|
||||
{
|
||||
$cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []);
|
||||
|
||||
return array_combine(array_map(static function ($cat_id)
|
||||
{
|
||||
return Api\Categories::id2name($cat_id);
|
||||
}, $cat_ids), array_fill(0, count($cat_ids), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse categories object
|
||||
*
|
||||
* @param array $categories category-name => true pairs
|
||||
* @return ?string comma-separated cat_id's
|
||||
* @todo make that generic, so JsContact & JSCalendar have not to overwrite it
|
||||
*/
|
||||
protected static function parseCategories(array $categories, bool $multiple=true)
|
||||
{
|
||||
static $bo=null;
|
||||
$cat_ids = [];
|
||||
if ($categories)
|
||||
{
|
||||
if (count($categories) > 1 && !$multiple)
|
||||
{
|
||||
throw new JsParseException("Only a single category is supported!");
|
||||
}
|
||||
if (!isset($bo)) $bo = new Api\Categories($GLOBALS['egw_info']['user']['account_id'], static::APP);
|
||||
foreach($categories as $name => $true)
|
||||
{
|
||||
if (!($cat_id = $bo->name2id($name)))
|
||||
{
|
||||
$cat_id = $bo->add(array('name' => $name, 'descr' => $name, 'access' => 'private'));
|
||||
}
|
||||
$cat_ids[] = $cat_id;
|
||||
}
|
||||
}
|
||||
return $cat_ids ? implode(',', $cat_ids) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch JsCard
|
||||
*
|
||||
* @param array $patches JSON path
|
||||
* @param array $jscard to patch
|
||||
* @param bool $create =false true: create missing components
|
||||
* @return array patched $jscard
|
||||
*/
|
||||
public static function patch(array $patches, array $jscard, bool $create=false)
|
||||
{
|
||||
foreach($patches as $path => $value)
|
||||
{
|
||||
$parts = explode('/', $path);
|
||||
$target = &$jscard;
|
||||
foreach($parts as $n => $part)
|
||||
{
|
||||
if (!isset($target[$part]) && $n < count($parts)-1 && !$create)
|
||||
{
|
||||
throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!");
|
||||
}
|
||||
$parent = $target;
|
||||
$target = &$target[$part];
|
||||
}
|
||||
if (isset($value))
|
||||
{
|
||||
$target = $value;
|
||||
}
|
||||
else
|
||||
{
|
||||
unset($parent[$part]);
|
||||
}
|
||||
}
|
||||
return $jscard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an integer
|
||||
*
|
||||
* @param int $value
|
||||
* @return int
|
||||
* @throws \TypeError
|
||||
*/
|
||||
public static function parseInt(int $value)
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an float value
|
||||
*
|
||||
* @param float $value
|
||||
* @return float
|
||||
* @throws \TypeError
|
||||
*/
|
||||
public static function parseFloat(float $value)
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map all kind of exceptions while parsing to a JsCalendarParseException
|
||||
*
|
||||
* @param \Throwable $e
|
||||
* @param string $type
|
||||
* @param ?string $name
|
||||
* @param mixed $value
|
||||
* @throws JsParseException
|
||||
*/
|
||||
protected static function handleExceptions(\Throwable $e, $type='JsCalendar', ?string $name, $value)
|
||||
{
|
||||
try {
|
||||
throw $e;
|
||||
}
|
||||
catch (\JsonException $e) {
|
||||
throw new JsParseException("Error parsing JSON: ".$e->getMessage(), 422, $e);
|
||||
}
|
||||
catch (\InvalidArgumentException $e) {
|
||||
throw new JsParseException("Error parsing $type attribute '$name': ".
|
||||
str_replace('"', "'", $e->getMessage()), 422);
|
||||
}
|
||||
catch (\TypeError $e) {
|
||||
$message = $e->getMessage();
|
||||
if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches))
|
||||
{
|
||||
$message = "$matches[1] expected, but got $matches[3]: ".
|
||||
str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
throw new JsParseException("Error parsing $type attribute '$name': $message", 422, $e);
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
throw new JsParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e);
|
||||
}
|
||||
}
|
||||
}
|
@ -19,12 +19,13 @@ use EGroupware\Api;
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc8984
|
||||
* @link https://jmap.io/spec-calendars.html
|
||||
*/
|
||||
class JsCalendar
|
||||
class JsCalendar extends JsBase
|
||||
{
|
||||
const APP = 'calendar';
|
||||
|
||||
const MIME_TYPE = "application/jscalendar+json";
|
||||
const MIME_TYPE_JSEVENT = "application/jscalendar+json;type=event";
|
||||
const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task";
|
||||
const MIME_TYPE_JSON = "application/json";
|
||||
|
||||
const TYPE_EVENT = 'Event';
|
||||
|
||||
@ -200,174 +201,6 @@ class JsCalendar
|
||||
return $event;
|
||||
}
|
||||
|
||||
const URN_UUID_PREFIX = 'urn:uuid:';
|
||||
const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i';
|
||||
|
||||
/**
|
||||
* Get UID with either "urn:uuid:" prefix for UUIDs or just the text
|
||||
*
|
||||
* @param string $uid
|
||||
* @return string
|
||||
*/
|
||||
protected static function uid(string $uid)
|
||||
{
|
||||
return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and optionally generate UID
|
||||
*
|
||||
* @param string|null $uid
|
||||
* @param string|null $old old value, if given it must NOT change
|
||||
* @param bool $generate_when_empty true: generate UID if empty, false: throw error
|
||||
* @return string without urn:uuid: prefix
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false)
|
||||
{
|
||||
if (empty($uid) || strlen($uid) < 12)
|
||||
{
|
||||
if (!$generate_when_empty)
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid));
|
||||
}
|
||||
$uid = \HTTP_WebDAV_Server::_new_uuid();
|
||||
}
|
||||
if (strpos($uid, self::URN_UUID_PREFIX) === 0)
|
||||
{
|
||||
$uid = substr($uid, strlen(self::URN_UUID_PREFIX));
|
||||
}
|
||||
if (isset($old) && $old !== $uid)
|
||||
{
|
||||
throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid));
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON options for errors thrown as exceptions
|
||||
*/
|
||||
const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE;
|
||||
|
||||
const AT_TYPE = '@type';
|
||||
|
||||
/**
|
||||
* Return EGroupware custom fields
|
||||
*
|
||||
* @param array $contact
|
||||
* @return array
|
||||
*/
|
||||
protected static function customfields(array $contact)
|
||||
{
|
||||
$fields = [];
|
||||
foreach(Api\Storage\Customfields::get('calendar') as $name => $data)
|
||||
{
|
||||
$value = $contact['#'.$name];
|
||||
if (isset($value))
|
||||
{
|
||||
switch($data['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$value = Api\DateTime::to($value, Api\DateTime::RFC3339);
|
||||
break;
|
||||
case 'float':
|
||||
$value = (double)$value;
|
||||
break;
|
||||
case 'int':
|
||||
$value = (int)$value;
|
||||
break;
|
||||
case 'select':
|
||||
$value = explode(',', $value);
|
||||
break;
|
||||
}
|
||||
$fields[$name] = array_filter([
|
||||
'value' => $value,
|
||||
'type' => $data['type'],
|
||||
'label' => $data['label'],
|
||||
'values' => $data['values'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom fields
|
||||
*
|
||||
* Not defined custom fields are ignored!
|
||||
* Not send custom fields are set to null!
|
||||
*
|
||||
* @param array $cfs name => object with attribute data and optional type, label, values
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseCustomfields(array $cfs)
|
||||
{
|
||||
$contact = [];
|
||||
$definitions = Api\Storage\Customfields::get('calendar');
|
||||
|
||||
foreach($definitions as $name => $definition)
|
||||
{
|
||||
$data = $cfs[$name];
|
||||
if (isset($data))
|
||||
{
|
||||
if (is_scalar($data))
|
||||
{
|
||||
$data = ['value' => $data];
|
||||
}
|
||||
if (!is_array($data) || !array_key_exists('value', $data))
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
switch($definition['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$data['value'] = Api\DateTime::to($data['value'], 'object');
|
||||
break;
|
||||
case 'float':
|
||||
$data['value'] = (double)$data['value'];
|
||||
break;
|
||||
case 'int':
|
||||
$data['value'] = round($data['value']);
|
||||
break;
|
||||
case 'select':
|
||||
if (is_scalar($data['value'])) $data['value'] = explode(',', $data['value']);
|
||||
$data['value'] = array_intersect(array_keys($definition['values']), $data['value']);
|
||||
$data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null;
|
||||
break;
|
||||
}
|
||||
$contact['#'.$name] = $data['value'];
|
||||
}
|
||||
// set not return cfs to null
|
||||
else
|
||||
{
|
||||
$contact['#'.$name] = null;
|
||||
}
|
||||
}
|
||||
// report not existing cfs to log
|
||||
if (($not_existing=array_diff(array_keys($cfs), array_keys($definitions))))
|
||||
{
|
||||
error_log(__METHOD__."() not existing/ignored custom fields: ".implode(', ', $not_existing));
|
||||
}
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return object of category-name(s) => true
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4
|
||||
* @param ?string $cat_ids comma-sep. cat_id's
|
||||
* @return true[]
|
||||
*/
|
||||
protected static function categories(?string $cat_ids)
|
||||
{
|
||||
$cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []);
|
||||
|
||||
return array_combine(array_map(static function ($cat_id)
|
||||
{
|
||||
return Api\Categories::id2name($cat_id);
|
||||
}, $cat_ids), array_fill(0, count($cat_ids), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse categories object
|
||||
*
|
||||
@ -421,29 +254,6 @@ class JsCalendar
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a date-time value in UTC
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc8984#section-1.4.4
|
||||
* @param null|string|\DateTime $date
|
||||
* @return string|null
|
||||
*/
|
||||
protected static function UTCDateTime($date)
|
||||
{
|
||||
static $utc=null;
|
||||
if (!isset($utc)) $utc = new \DateTimeZone('UTC');
|
||||
|
||||
if (!isset($date))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
$date = Api\DateTime::to($date, 'object');
|
||||
$date->setTimezone($utc);
|
||||
|
||||
// we need to use "Z", not "+00:00"
|
||||
return substr($date->format(Api\DateTime::RFC3339), 0, -6).'Z';
|
||||
}
|
||||
|
||||
const DATETIME_FORMAT = 'Y-m-d\TH:i:s';
|
||||
|
||||
/**
|
||||
@ -874,76 +684,6 @@ class JsCalendar
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch JsEvent
|
||||
*
|
||||
* @param array $patches JSON path
|
||||
* @param array $jsevent to patch
|
||||
* @param bool $create =false true: create missing components
|
||||
* @return array patched $jsevent
|
||||
*/
|
||||
public static function patch(array $patches, array $jsevent, bool $create=false)
|
||||
{
|
||||
foreach($patches as $path => $value)
|
||||
{
|
||||
$parts = explode('/', $path);
|
||||
$target = &$jsevent;
|
||||
foreach($parts as $n => $part)
|
||||
{
|
||||
if (!isset($target[$part]) && $n < count($parts)-1 && !$create)
|
||||
{
|
||||
throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!");
|
||||
}
|
||||
$parent = $target;
|
||||
$target = &$target[$part];
|
||||
}
|
||||
if (isset($value))
|
||||
{
|
||||
$target = $value;
|
||||
}
|
||||
else
|
||||
{
|
||||
unset($parent[$part]);
|
||||
}
|
||||
}
|
||||
return $jsevent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map all kind of exceptions while parsing to a JsCalendarParseException
|
||||
*
|
||||
* @param \Throwable $e
|
||||
* @param string $type
|
||||
* @param ?string $name
|
||||
* @param mixed $value
|
||||
* @throws JsCalendarParseException
|
||||
*/
|
||||
protected static function handleExceptions(\Throwable $e, $type='JsCalendar', ?string $name, $value)
|
||||
{
|
||||
try {
|
||||
throw $e;
|
||||
}
|
||||
catch (\JsonException $e) {
|
||||
throw new JsCalendarParseException("Error parsing JSON: ".$e->getMessage(), 422, $e);
|
||||
}
|
||||
catch (\InvalidArgumentException $e) {
|
||||
throw new JsCalendarParseException("Error parsing $type attribute '$name': ".
|
||||
str_replace('"', "'", $e->getMessage()), 422);
|
||||
}
|
||||
catch (\TypeError $e) {
|
||||
$message = $e->getMessage();
|
||||
if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches))
|
||||
{
|
||||
$message = "$matches[1] expected, but got $matches[3]: ".
|
||||
str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
throw new JsCalendarParseException("Error parsing $type attribute '$name': $message", 422, $e);
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
throw new JsCalendarParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \calendar_boupdate
|
||||
*/
|
||||
|
@ -18,7 +18,7 @@ use Throwable;
|
||||
*
|
||||
* @link * @link https://datatracker.ietf.org/doc/html/rfc8984
|
||||
*/
|
||||
class JsCalendarParseException extends \InvalidArgumentException
|
||||
class JsParseException extends \InvalidArgumentException
|
||||
{
|
||||
public function __construct($message = "", $code = 422, Throwable $previous = null)
|
||||
{
|
@ -19,12 +19,13 @@ use EGroupware\Api;
|
||||
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07 (newer, here implemented format)
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc7095 jCard (older vCard compatible contact data as JSON, NOT implemented here!)
|
||||
*/
|
||||
class JsContact
|
||||
class JsContact extends Api\CalDAV\JsBase
|
||||
{
|
||||
const APP = 'addressbook';
|
||||
|
||||
const MIME_TYPE = "application/jscontact+json";
|
||||
const MIME_TYPE_JSCARD = "application/jscontact+json;type=card";
|
||||
const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup";
|
||||
const MIME_TYPE_JSON = "application/json";
|
||||
|
||||
/**
|
||||
* Get jsCard for given contact
|
||||
@ -216,50 +217,6 @@ class JsContact
|
||||
return $contact;
|
||||
}
|
||||
|
||||
const URN_UUID_PREFIX = 'urn:uuid:';
|
||||
const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i';
|
||||
|
||||
/**
|
||||
* Get UID with either "urn:uuid:" prefix for UUIDs or just the text
|
||||
*
|
||||
* @param string $uid
|
||||
* @return string
|
||||
*/
|
||||
protected static function uid(string $uid)
|
||||
{
|
||||
return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and optionally generate UID
|
||||
*
|
||||
* @param string|null $uid
|
||||
* @param string|null $old old value, if given it must NOT change
|
||||
* @param bool $generate_when_empty true: generate UID if empty, false: throw error
|
||||
* @return string without urn:uuid: prefix
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false)
|
||||
{
|
||||
if (empty($uid) || strlen($uid) < 12)
|
||||
{
|
||||
if (!$generate_when_empty)
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid));
|
||||
}
|
||||
$uid = \HTTP_WebDAV_Server::_new_uuid();
|
||||
}
|
||||
if (strpos($uid, self::URN_UUID_PREFIX) === 0)
|
||||
{
|
||||
$uid = substr($uid, strlen(self::URN_UUID_PREFIX));
|
||||
}
|
||||
if (isset($old) && $old !== $uid)
|
||||
{
|
||||
throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid));
|
||||
}
|
||||
return $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON options for errors thrown as exceptions
|
||||
*/
|
||||
@ -390,123 +347,6 @@ class JsContact
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return EGroupware custom fields
|
||||
*
|
||||
* @param array $contact
|
||||
* @return array
|
||||
*/
|
||||
protected static function customfields(array $contact)
|
||||
{
|
||||
$fields = [];
|
||||
foreach(Api\Storage\Customfields::get('addressbook') as $name => $data)
|
||||
{
|
||||
$value = $contact['#'.$name];
|
||||
if (isset($value))
|
||||
{
|
||||
switch($data['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$value = Api\DateTime::to($value, Api\DateTime::RFC3339);
|
||||
break;
|
||||
case 'float':
|
||||
$value = (double)$value;
|
||||
break;
|
||||
case 'int':
|
||||
$value = (int)$value;
|
||||
break;
|
||||
case 'select':
|
||||
$value = explode(',', $value);
|
||||
break;
|
||||
}
|
||||
$fields[$name] = array_filter([
|
||||
'value' => $value,
|
||||
'type' => $data['type'],
|
||||
'label' => $data['label'],
|
||||
'values' => $data['values'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom fields
|
||||
*
|
||||
* Not defined custom fields are ignored!
|
||||
* Not send custom fields are set to null!
|
||||
*
|
||||
* @param array $cfs name => object with attribute data and optional type, label, values
|
||||
* @return array
|
||||
*/
|
||||
protected static function parseCustomfields(array $cfs)
|
||||
{
|
||||
$contact = [];
|
||||
$definitions = Api\Storage\Customfields::get('addressbook');
|
||||
|
||||
foreach($definitions as $name => $definition)
|
||||
{
|
||||
$data = $cfs[$name];
|
||||
if (isset($data))
|
||||
{
|
||||
if (is_scalar($data))
|
||||
{
|
||||
$data = ['value' => $data];
|
||||
}
|
||||
if (!is_array($data) || !array_key_exists('value', $data))
|
||||
{
|
||||
throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
switch($definition['type'])
|
||||
{
|
||||
case 'date-time':
|
||||
$data['value'] = Api\DateTime::to($data['value'], 'object');
|
||||
break;
|
||||
case 'float':
|
||||
$data['value'] = (double)$data['value'];
|
||||
break;
|
||||
case 'int':
|
||||
$data['value'] = round($data['value']);
|
||||
break;
|
||||
case 'select':
|
||||
if (is_scalar($data['value'])) $data['value'] = explode(',', $data['value']);
|
||||
$data['value'] = array_intersect(array_keys($definition['values']), $data['value']);
|
||||
$data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null;
|
||||
break;
|
||||
}
|
||||
$contact['#'.$name] = $data['value'];
|
||||
}
|
||||
// set not return cfs to null
|
||||
else
|
||||
{
|
||||
$contact['#'.$name] = null;
|
||||
}
|
||||
}
|
||||
// report not existing cfs to log
|
||||
if (($not_existing=array_diff(array_keys($cfs), array_keys($definitions))))
|
||||
{
|
||||
error_log(__METHOD__."() not existing/ignored custom fields: ".implode(', ', $not_existing));
|
||||
}
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return object of category-name(s) => true
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4
|
||||
* @param ?string $cat_ids comma-sep. cat_id's
|
||||
* @return true[]
|
||||
*/
|
||||
protected static function categories(?string $cat_ids)
|
||||
{
|
||||
$cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []);
|
||||
|
||||
return array_combine(array_map(static function ($cat_id)
|
||||
{
|
||||
return Api\Categories::id2name($cat_id);
|
||||
}, $cat_ids), array_fill(0, count($cat_ids), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse categories object
|
||||
*
|
||||
@ -1359,76 +1199,6 @@ class JsContact
|
||||
return $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch JsCard
|
||||
*
|
||||
* @param array $patches JSON path
|
||||
* @param array $jscard to patch
|
||||
* @param bool $create =false true: create missing components
|
||||
* @return array patched $jscard
|
||||
*/
|
||||
public static function patch(array $patches, array $jscard, bool $create=false)
|
||||
{
|
||||
foreach($patches as $path => $value)
|
||||
{
|
||||
$parts = explode('/', $path);
|
||||
$target = &$jscard;
|
||||
foreach($parts as $n => $part)
|
||||
{
|
||||
if (!isset($target[$part]) && $n < count($parts)-1 && !$create)
|
||||
{
|
||||
throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!");
|
||||
}
|
||||
$parent = $target;
|
||||
$target = &$target[$part];
|
||||
}
|
||||
if (isset($value))
|
||||
{
|
||||
$target = $value;
|
||||
}
|
||||
else
|
||||
{
|
||||
unset($parent[$part]);
|
||||
}
|
||||
}
|
||||
return $jscard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map all kind of exceptions while parsing to a JsContactParseException
|
||||
*
|
||||
* @param \Throwable $e
|
||||
* @param string $type
|
||||
* @param ?string $name
|
||||
* @param mixed $value
|
||||
* @throws JsContactParseException
|
||||
*/
|
||||
protected static function handleExceptions(\Throwable $e, $type='JsContact', ?string $name, $value)
|
||||
{
|
||||
try {
|
||||
throw $e;
|
||||
}
|
||||
catch (\JsonException $e) {
|
||||
throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e);
|
||||
}
|
||||
catch (\InvalidArgumentException $e) {
|
||||
throw new JsContactParseException("Error parsing $type attribute '$name': ".
|
||||
str_replace('"', "'", $e->getMessage()), 422);
|
||||
}
|
||||
catch (\TypeError $e) {
|
||||
$message = $e->getMessage();
|
||||
if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches))
|
||||
{
|
||||
$message = "$matches[1] expected, but got $matches[3]: ".
|
||||
str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR));
|
||||
}
|
||||
throw new JsContactParseException("Error parsing $type attribute '$name': $message", 422, $e);
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
throw new JsContactParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Api\Contacts
|
||||
*/
|
||||
@ -1441,4 +1211,4 @@ class JsContact
|
||||
}
|
||||
return $contacts;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EGroupware API - JsContact
|
||||
*
|
||||
* @link https://www.egroupware.org
|
||||
* @author Ralf Becker <rb@egroupware.org>
|
||||
* @package addressbook
|
||||
* @copyright (c) 2021 by Ralf Becker <rb@egroupware.org>
|
||||
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
|
||||
*/
|
||||
|
||||
namespace EGroupware\Api\Contacts;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Error parsing JsContact format
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07
|
||||
*/
|
||||
class JsContactParseException extends \InvalidArgumentException
|
||||
{
|
||||
public function __construct($message = "", $code = 422, Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code ?: 422, $previous);
|
||||
}
|
||||
}
|
1052
timesheet/src/ApiHandler.php
Normal file
1052
timesheet/src/ApiHandler.php
Normal file
File diff suppressed because it is too large
Load Diff
184
timesheet/src/JsTimesheet.php
Normal file
184
timesheet/src/JsTimesheet.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* EGroupware Timesheet - JsTimesheet
|
||||
*
|
||||
* @link https://www.egroupware.org
|
||||
* @author Ralf Becker <rb@egroupware.org>
|
||||
* @package calendar
|
||||
* @copyright (c) 2023 by Ralf Becker <rb@egroupware.org>
|
||||
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
|
||||
*/
|
||||
|
||||
namespace EGroupware\Timesheet;
|
||||
|
||||
use EGroupware\Api;
|
||||
|
||||
/**
|
||||
* Rendering events as JSON using new JsCalendar format
|
||||
*
|
||||
* @link https://datatracker.ietf.org/doc/html/rfc8984
|
||||
* @link https://jmap.io/spec-calendars.html
|
||||
*/
|
||||
class JsTimesheet extends Api\CalDAV\JsBase
|
||||
{
|
||||
const APP = 'timesheet';
|
||||
|
||||
const TYPE_TIMESHEET = 'timesheet';
|
||||
|
||||
/**
|
||||
* Get JsEvent for given event
|
||||
*
|
||||
* @param int|array $event
|
||||
* @param bool|"pretty" $encode true: JSON encode, "pretty": JSON encode with pretty-print, false: return raw data e.g. from listing
|
||||
* @param ?array $exceptions=null
|
||||
* @return string|array
|
||||
* @throws Api\Exception\NotFound
|
||||
*/
|
||||
public static function JsTimesheet(array $timesheet, $encode=true, array $exceptions=[])
|
||||
{
|
||||
static $bo = null;
|
||||
if (!isset($bo)) $bo = new \timesheet_bo();
|
||||
|
||||
if (isset($timesheet['ts_id']))
|
||||
{
|
||||
$timesheet = Api\Db::strip_array_keys($timesheet, 'ts_');
|
||||
}
|
||||
|
||||
$data = array_filter([
|
||||
self::AT_TYPE => self::TYPE_TIMESHEET,
|
||||
//'uid' => self::uid($timesheet['uid']),
|
||||
'id' => (int)$timesheet['id'],
|
||||
'title' => $timesheet['title'],
|
||||
'description' => $timesheet['description'],
|
||||
'start' => self::UTCDateTime($timesheet['start'], true),
|
||||
'duration' => (int)$timesheet['duration'],
|
||||
'quantity' => (double)$timesheet['quantity'],
|
||||
'unitprice' => (double)$timesheet['unitprice'],
|
||||
'category' => self::categories($timesheet['cat_id']),
|
||||
'owner' => self::account($timesheet['owner']),
|
||||
'created' => self::UTCDateTime($timesheet['created'], true),
|
||||
'modified' => self::UTCDateTime($timesheet['modified'], true),
|
||||
'modifier' => self::account($timesheet['modifier']),
|
||||
'pricelist' => (int)$timesheet['pl_id'] ?: null,
|
||||
'status' => $bo->status_labels[$timesheet['status']] ?? null,
|
||||
'egroupware.org:customfields' => self::customfields($timesheet),
|
||||
]);
|
||||
|
||||
if ($encode)
|
||||
{
|
||||
return Api\CalDAV::json_encode($data, $encode === "pretty");
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JsTimesheet
|
||||
*
|
||||
* @param string $json
|
||||
* @param array $old=[] existing contact for patch
|
||||
* @param ?string $content_type=null application/json no strict parsing and automatic patch detection, if method not 'PATCH' or 'PUT'
|
||||
* @param string $method='PUT' 'PUT', 'POST' or 'PATCH'
|
||||
* @return array with "ts_" prefix
|
||||
*/
|
||||
public static function parseJsTimesheet(string $json, array $old=[], string $content_type=null, $method='PUT')
|
||||
{
|
||||
try
|
||||
{
|
||||
$data = json_decode($json, true, 10, JSON_THROW_ON_ERROR);
|
||||
|
||||
// check if we use patch: method is PATCH or method is POST AND keys contain slashes
|
||||
if ($method === 'PATCH')
|
||||
{
|
||||
// apply patch on JsCard of contact
|
||||
$data = self::patch($data, $old ? self::getJsCalendar($old, false) : [], !$old);
|
||||
}
|
||||
|
||||
if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist
|
||||
|
||||
// check required fields
|
||||
if (!$old || !$method === 'PATCH')
|
||||
{
|
||||
static $required = ['title', 'start', 'duration'];
|
||||
if (($missing = array_diff_key(array_filter(array_intersect_key($data), array_flip($required)), array_flip($required))))
|
||||
{
|
||||
throw new Api\CalDAV\JsParseException("Required field(s) ".implode(', ', $missing)." missing");
|
||||
}
|
||||
}
|
||||
|
||||
$timesheet = $method === 'PATCH' ? $old : [];
|
||||
foreach ($data as $name => $value)
|
||||
{
|
||||
switch ($name)
|
||||
{
|
||||
case 'title':
|
||||
case 'description':
|
||||
$timesheet['ts_'.$name] = $value;
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
$timesheet['ts_start'] = Api\DateTime::server2user($value, 'ts');
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
$timesheet['ts_duration'] = self::parseInt($value);
|
||||
break;
|
||||
|
||||
case 'quantity':
|
||||
case 'unitprice':
|
||||
$timesheet['ts_'.$name] = self::parseFloat($value);
|
||||
break;
|
||||
|
||||
case 'owner':
|
||||
$timesheet['ts_owner'] = self::parseAccount($value);
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
$timesheet['cat_id'] = self::parseCategories($value, false);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
$timesheet['ts_status'] = self::parseStatus($value);
|
||||
break;
|
||||
|
||||
case 'egroupware.org:customfields':
|
||||
$timesheet += self::parseCustomfields($value);
|
||||
break;
|
||||
|
||||
case 'prodId':
|
||||
case 'created':
|
||||
case 'modified':
|
||||
case 'modifier':
|
||||
break;
|
||||
|
||||
default:
|
||||
error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
self::handleExceptions($e, 'JsTimesheet', $name, $value);
|
||||
}
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a status label into it's numerical ID
|
||||
*
|
||||
* @param string $value
|
||||
* @return int|null
|
||||
* @throws Api\CalDAV\JsParseException
|
||||
*/
|
||||
protected static function parseStatus(string $value)
|
||||
{
|
||||
static $bo=null;
|
||||
if (!isset($bo)) $bo = new \timesheet_bo();
|
||||
|
||||
if (($status_id = array_search($value, $bo->status_labels)) === false)
|
||||
{
|
||||
throw new Api\CalDAV\JsParseException("Invalid status value '$value', allowed '".implode("', '", $bo->status_labels)."'");
|
||||
}
|
||||
return $status_id;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user