WIP REST Api for Timesheet app

This commit is contained in:
ralf 2023-11-29 15:47:27 +02:00
parent 0ad1729fe0
commit 2aedd7f5ef
9 changed files with 1638 additions and 530 deletions

View File

@ -19,7 +19,7 @@ use EGroupware\Api\CalDAV\Principals;
// explicit import non-namespaced classes // explicit import non-namespaced classes
require_once(__DIR__.'/WebDAV/Server.php'); require_once(__DIR__.'/WebDAV/Server.php');
use EGroupware\Api\Contacts\JsContactParseException; use EGroupware\Api\CalDAV\JsParseException;
use HTTP_WebDAV_Server; use HTTP_WebDAV_Server;
use calendar_hooks; use calendar_hooks;
@ -1156,7 +1156,9 @@ class CalDAV extends HTTP_WebDAV_Server
'address-data' => self::mkprop(self::CARDDAV, 'address-data', '') 'address-data' => self::mkprop(self::CARDDAV, 'address-data', '')
] : ($is_calendar ? [ ] : ($is_calendar ? [
'calendar-data' => self::mkprop(self::CALDAV, 'calendar-data', ''), 'calendar-data' => self::mkprop(self::CALDAV, 'calendar-data', ''),
] : 'all'), ] : [
'data' => self::mkprop(self::CALDAV, 'data', '')
]),
'other' => [], 'other' => [],
); );
@ -1262,7 +1264,7 @@ class CalDAV extends HTTP_WebDAV_Server
// check if this is a property-object // check if this is a property-object
elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val'])) 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 else
{ {
@ -2557,7 +2559,7 @@ class CalDAV extends HTTP_WebDAV_Server
if (self::isJSON()) if (self::isJSON())
{ {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
if (is_a($e, JsContactParseException::class)) if (is_a($e, JsParseException::class))
{ {
$status = '422 Unprocessable Entity'; $status = '422 Unprocessable Entity';
} }

View File

@ -561,7 +561,7 @@ abstract class Handler
*/ */
function get_path($entry) 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
View 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);
}
}
}

View File

@ -19,12 +19,13 @@ use EGroupware\Api;
* @link https://datatracker.ietf.org/doc/html/rfc8984 * @link https://datatracker.ietf.org/doc/html/rfc8984
* @link https://jmap.io/spec-calendars.html * @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 = "application/jscalendar+json";
const MIME_TYPE_JSEVENT = "application/jscalendar+json;type=event"; const MIME_TYPE_JSEVENT = "application/jscalendar+json;type=event";
const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task"; const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task";
const MIME_TYPE_JSON = "application/json";
const TYPE_EVENT = 'Event'; const TYPE_EVENT = 'Event';
@ -200,174 +201,6 @@ class JsCalendar
return $event; 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 * Parse categories object
* *
@ -421,29 +254,6 @@ class JsCalendar
return $value; 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'; const DATETIME_FORMAT = 'Y-m-d\TH:i:s';
/** /**
@ -874,76 +684,6 @@ class JsCalendar
return $alerts; 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 * @return \calendar_boupdate
*/ */

View File

@ -18,7 +18,7 @@ use Throwable;
* *
* @link * @link https://datatracker.ietf.org/doc/html/rfc8984 * @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) public function __construct($message = "", $code = 422, Throwable $previous = null)
{ {

View File

@ -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/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!) * @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 = "application/jscontact+json";
const MIME_TYPE_JSCARD = "application/jscontact+json;type=card"; const MIME_TYPE_JSCARD = "application/jscontact+json;type=card";
const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup"; const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup";
const MIME_TYPE_JSON = "application/json";
/** /**
* Get jsCard for given contact * Get jsCard for given contact
@ -216,50 +217,6 @@ class JsContact
return $contact; 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 * JSON options for errors thrown as exceptions
*/ */
@ -390,123 +347,6 @@ class JsContact
return $contact; 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 * Parse categories object
* *
@ -1359,76 +1199,6 @@ class JsContact
return $members; 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 * @return Api\Contacts
*/ */

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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;
}
}