WIP InfoLog REST API

This commit is contained in:
ralf 2024-05-03 19:55:47 +02:00
parent f568930a90
commit 37ebc4b8e3
5 changed files with 401 additions and 31 deletions

View File

@ -1153,7 +1153,7 @@ class CalDAV extends HTTP_WebDAV_Server
{ {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
$is_addressbook = strpos($options['path'], '/addressbook') !== false; $is_addressbook = strpos($options['path'], '/addressbook') !== false;
$is_calendar = strpos($options['path'], '/calendar') !== false; $is_calendar = (bool)preg_match('#/(calendar|infolog)#', $options['path']);
$propfind_options = array( $propfind_options = array(
'path' => $options['path'], 'path' => $options['path'],
'depth' => 1, 'depth' => 1,

View File

@ -148,12 +148,13 @@ class JsBase
* Return EGroupware custom fields * Return EGroupware custom fields
* *
* @param array $contact * @param array $contact
* @param ?string $app default self::APP
* @return array * @return array
*/ */
protected static function customfields(array $contact) protected static function customfields(array $contact, ?string $app=null)
{ {
$fields = []; $fields = [];
foreach(Api\Storage\Customfields::get(static::APP) as $name => $data) foreach(Api\Storage\Customfields::get($app ?? static::APP) as $name => $data)
{ {
$value = $contact['#'.$name]; $value = $contact['#'.$name];
if (isset($value)) if (isset($value))
@ -191,12 +192,13 @@ class JsBase
* Not send custom fields are set to null! * Not send custom fields are set to null!
* *
* @param array $cfs name => object with attribute data and optional type, label, values * @param array $cfs name => object with attribute data and optional type, label, values
* @param ?string $app default self::APP
* @return array * @return array
*/ */
protected static function parseCustomfields(array $cfs) protected static function parseCustomfields(array $cfs, ?string $app=null)
{ {
$contact = []; $contact = [];
$definitions = Api\Storage\Customfields::get(static::APP); $definitions = Api\Storage\Customfields::get($app ?? static::APP);
foreach($definitions as $name => $definition) foreach($definitions as $name => $definition)
{ {

View File

@ -28,6 +28,7 @@ class JsCalendar extends JsBase
const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task"; const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task";
const TYPE_EVENT = 'Event'; const TYPE_EVENT = 'Event';
const TYPE_TASK = 'Task';
/** /**
* Get JsEvent for given event * Get JsEvent for given event
@ -206,6 +207,228 @@ class JsCalendar extends JsBase
return $event; return $event;
} }
/**
* Get JsEvent for given event
*
* @param int|array $entry
* @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 JsTask($entry, $encode=true, array $exceptions=[])
{
if (is_scalar($entry) && !($entry = self::getInfolog()->read($entry, false, 'object')))
{
throw new Api\Exception\NotFound();
}
$data = [
self::AT_TYPE => self::TYPE_TASK,
'prodId' => 'EGroupware InfoLog '.$GLOBALS['egw_info']['apps']['api']['version'],
'uid' => self::uid($entry['info_uid']),
'sequence' => $entry['info_etag'],
'created' => self::UTCDateTime($entry['info_created']),
'updated' => self::UTCDateTime($entry['info_modified']),
'title' => $entry['info_subject'],
'start' => $entry['info_startdate'] ? self::DateTime($entry['info_startdate'], Api\DateTime::$user_timezone->getName()) : null,
'showWithoutTime' => $no_time = Api\DateTime::to($entry['info_startdate'], 'H:i') === '00:00',
'timeZone' => Api\DateTime::$user_timezone->getName(),
'due' => $entry['info_enddate'] ? self::DateTime($entry['info_enddate'], Api\DateTime::$user_timezone->getName()) : null,
'duration' => $entry['info_used_time'] ?
self::Duration(0, $entry['info_used_time']*60) : null,
'estimatedDuration' => $entry['info_plannedtime'] ?
self::Duration(0, $entry['info_plannedtime']*60) : null,
'recurrenceRules' => isset($entry['#RRULE']) ? null : null,
'recurrenceOverrides' => null,
//'freeBusyStatus' => $entry['non_blocking'] ? 'free' : null, // default is busy
'description' => $entry['info_des'],
'participants' => self::Responsible($entry),
//'alerts' => self::Alerts($entry['alarm']),
'status' => in_array($entry['info_status'], ['deleted', 'cancelled']) ? 'cancelled' :
($entry['info_status'] === 'offer' ? 'tentative' : 'confirmed'),
'progress' => self::Progress($entry['info_status']),
'priority' => isset($entry['info_priority']) ? self::Priority($entry['info_priority']) : null,
'categories' => self::categories($entry['info_cat']),
'privacy' => $entry['info_access'],
'percentComplete' => (int)$entry['info_percent'],
'egroupware.org:type' => $entry['info_type'],
'egroupware.org:completed' => $entry['info_datecomplete'] ?
self::DateTime($entry['info_datecompleted'], Api\DateTime::$user_timezone->getName()) : null,
'egroupware.org:customfields' => self::customfields($entry, 'infolog'),
] + self::Locations(['location' => $entry['info_location'] ?? null]);
if (!empty($entry['##RRULE']))
{
$data = array_merge($data, self::cfRrule2recurrenceRules($entry));
}
$data = array_filter($data);
if ($encode)
{
return Api\CalDAV::json_encode($data, $encode === "pretty");
}
return $data;
}
/**
* Parse JsEvent
*
* We use strict parsing for "application/jscalendar+json" content-type, not for "application/json".
* Strict parsing checks objects for proper @type attributes and value attributes, non-strict allows scalar values.
*
* Non-strict parsing also automatic detects patch for POST requests.
*
* @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'
* @param ?int $calendar_owner owner of the collection
* @return array
*/
public static function parseJsTask(string $json, array $old=[], string $content_type=null, $method='PUT', int $calendar_owner=null)
{
try
{
$strict = !isset($content_type) || !preg_match('#^application/json#', $content_type);
$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' || !$strict && $method === 'POST' && array_filter(array_keys($data), static function ($key)
{
return strpos($key, '/') !== false;
}))
{
// apply patch on JsEvent
$data = self::patch($data, $old ? self::getJsTask($old, false) : [], !$old || !$strict);
}
if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist
$event = [];
foreach ($data as $name => $value)
{
switch ($name)
{
case 'uid':
$event['info_uid'] = self::parseUid($value, $old['info_uid'], !$strict);
break;
case 'title':
$event['info_subject'] = $value;
break;
case 'description':
$event['info_des'] = $value;
break;
case 'start':
case 'duration':
case 'timeZone':
case 'showWithoutTime':
if (!isset($event['start']))
{
$event += self::parseStartDuration($data);
}
break;
case 'participants':
$event += self::parseParticipants($value, $strict, $calendar_owner);
break;
case 'priority':
$event['priority'] = self::parsePriority($value);
break;
case 'privacy':
$event['info_public'] = $value;
break;
case 'recurrenceRules':
$event += self::parseRecuranceRules2cfRrule($data['recurrenceRules']);
break;
case 'categories':
$event['info_cat'] = (int)self::parseCategories($value);
break;
case 'egroupware.org:customfields':
$event = array_merge($event, self::parseCustomfields($value, $strict));
break;
case 'egroupware.org:completed':
$event['info_datecomplete'] = self::parseDateTime();
break;
case 'egroupware.org:type':
$event['info_type'] = $value;
case 'prodId':
case 'created':
case 'updated':
break;
default:
error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored');
break;
}
}
}
catch (\Throwable $e) {
self::handleExceptions($e, 'JsCalendar Event', $name, $value);
}
// if no participant given add current user as CHAIR to the event
if (empty($event['participants']))
{
$event['participants'][$calendar_owner ?? $GLOBALS['egw_info']['user']['account_id']] = 'ACHAIR';
}
return $event;
}
protected static $status2progress = [
'offer' => null,
'not-started' => 'needs-action',
'ongoing' => 'in-progress',
'done' => 'completed',
'cancelled' => 'cancelled',
'billed' => null,
'template' => null,
'nonactive' => null,
'archive' => null,
];
/**
* Convert an InfoLog status to a JsTask progress
*
* @link https://datatracker.ietf.org/doc/html/rfc8984#name-progress
* @param string $status
* @return void
*/
protected static function Progress(string $info_status)
{
return self::$status2progress[$info_status] ?? 'egroupware.org:'.$info_status;
}
/**
* @param string $progress
* @param string $info_type
* @return string known infolog status, or "not-started"
*/
protected static function parseProgress(string $progress, string $info_type=null)
{
if (!($status = array_search($progress, self::$status2progress)))
{
if (!str_starts_with('egroupware.org:', $progress) ||
($status = substr($progress, strlen('egroupware.org:'))) &&
isset($info_type) && !isset(self::getInfolog()->status[$info_type][$status]))
{
$status = 'not-started';
}
}
return $status;
}
/** /**
* Parse categories object * Parse categories object
* *
@ -267,6 +490,7 @@ class JsCalendar extends JsBase
* *
* @link https://datatracker.ietf.org/doc/html/rfc8984#name-localdatetime * @link https://datatracker.ietf.org/doc/html/rfc8984#name-localdatetime
* @param null|string|\DateTime $date * @param null|string|\DateTime $date
* @param string $timezone
* @return string|null * @return string|null
*/ */
protected static function DateTime($date, $timezone) protected static function DateTime($date, $timezone)
@ -293,7 +517,7 @@ class JsCalendar extends JsBase
* @param bool $whole_day * @param bool $whole_day
* @return string * @return string
*/ */
protected static function Duration($start, $end, bool $whole_day) protected static function Duration($start, $end, bool $whole_day=false)
{ {
$start = Api\DateTime::to($start, 'object'); $start = Api\DateTime::to($start, 'object');
$end = Api\DateTime::to($end, 'object'); $end = Api\DateTime::to($end, 'object');
@ -499,6 +723,61 @@ class JsCalendar extends JsBase
return $parsed; return $parsed;
} }
/**
* Return participants object of task aka Responsible
*
* We add info_owner (as owner), info_responsible (as attendee) and info_cc (as informational)
*
* @param array $entry
* @return array
*/
protected static function Responsible(array $entry)
{
$participants = [];
foreach(array_unique(array_merge((array)$entry['info_owner'], $entry['info_responsible'],
$entry['info_cc'] ? explode(',', $entry['info_cc']) : [])) as $uid)
{
if (is_numeric($uid))
{
$info = [
'name' => Api\Accounts::id2name($uid, 'account_fullname'),
'email' => Api\Accounts::id2name($uid, 'account_email'),
];
}
else
{
if (preg_match('/^(.*) <(.*)>$/', $uid, $matches))
{
$info = [
'name' => $matches[1],
'email' => $matches[2],
];
}
else
{
$info['email'] = $uid;
}
}
$participant = array_filter([
self::AT_TYPE => self::TYPE_PARTICIPANT,
'name' => $info['name'] ?? null,
'email' => $info['email'] ?? null,
'kind' => $info['kind'] ?? 'individual',
'roles' => array_filter([
'owner' => $uid == $entry['info_owner'],
//'chair' => $role === 'CHAIR',
'attendee' => is_numeric($uid) && ($uid != $entry['info_owner'] || in_array($uid, $entry['info_responsible']??[])),
//'optional' => $role === 'OPT-PARTICIPANT',
'informational' => !is_numeric($uid), // info_cc emails
]),
'participationStatus' => null,
]);
$participants[$uid] = $participant;
}
return $participants;
}
protected static function jscalRoles2role(array $roles=null, string $default_role=null) protected static function jscalRoles2role(array $roles=null, string $default_role=null)
{ {
$role = $default_role ?? 'REQ-PARTICIPANT'; $role = $default_role ?? 'REQ-PARTICIPANT';
@ -557,7 +836,7 @@ class JsCalendar extends JsBase
* @param int $priority * @param int $priority
* @return int * @return int
*/ */
protected static function Priority(int $priority) protected static function Priority(int $priority, bool $infolog=false)
{ {
static $priority_egw2jscal = array( static $priority_egw2jscal = array(
0 => 0, // undefined 0 => 0, // undefined
@ -565,7 +844,13 @@ class JsCalendar extends JsBase
2 => 5, // normal 2 => 5, // normal
3 => 1, // high 3 => 1, // high
); );
return $priority_egw2jscal[$priority]; static $infolog_priority_2egwjscal = array(
0 => 9, // low
1 => 5, // normal
2 => 3, // high
3 => 1, // urgent
);
return $infolog ? $infolog_priority_2egwjscal[$priority] : $priority_egw2jscal[$priority];
} }
/** /**
@ -574,7 +859,7 @@ class JsCalendar extends JsBase
* @param int $priority * @param int $priority
* @return int * @return int
*/ */
protected static function parsePriority(int $priority) protected static function parsePriority(int $priority, bool $infolog=false)
{ {
static $priority_jscal2egw = [ static $priority_jscal2egw = [
9 => 1, 8 => 1, 7 => 1, // low 9 => 1, 8 => 1, 7 => 1, // low
@ -582,7 +867,13 @@ class JsCalendar extends JsBase
3 => 3, 2 => 3, 1 => 3, // high 3 => 3, 2 => 3, 1 => 3, // high
0 => 0, // undefined 0 => 0, // undefined
]; ];
return $priority_jscal2egw[$priority] ?? throw new \InvalidArgumentException("Priority must be between 0 and 9"); static $infolog_priority_jscal2egw = [
9 => 0, 8 => 0, 7 => 0, // low
6 => 1, 5 => 1, 4 => 1, 0 => 1, // normal
3 => 2, 2 => 2, // high
1 => 3, // urgent
];
return ($infolog?$infolog_priority_jscal2egw:$priority_jscal2egw)[$priority] ?? throw new \InvalidArgumentException("Priority must be between 0 and 9");
} }
const TYPE_RECURRENCE_RULE = 'RecurrenceRule'; const TYPE_RECURRENCE_RULE = 'RecurrenceRule';
@ -596,20 +887,25 @@ class JsCalendar extends JsBase
* @param array $event * @param array $event
* @param array $data JSCalendar representation of event to calculate overrides * @param array $data JSCalendar representation of event to calculate overrides
* @param array $exceptions exceptions * @param array $exceptions exceptions
* @param ?array $rrule array with values for keys "FREQ", "INTERVAL", "UNTIL", ...
* @return array * @return array
*/ */
protected static function Recurrence(array $event, array $data, array $exceptions=[]) protected static function Recurrence(array $event, array $data, array $exceptions=[], ?array $rrule=null)
{ {
$overrides = []; $overrides = [];
if (!empty($event['recur_type'])) if (!empty($event['recur_type']) || isset($rrule))
{
if (!isset($rrule))
{ {
$rriter = \calendar_rrule::event2rrule($event, false); $rriter = \calendar_rrule::event2rrule($event, false);
$rrule = $rriter->generate_rrule('2.0'); $rrule = $rriter->generate_rrule('2.0');
}
$rule = array_filter([ $rule = array_filter([
self::AT_TYPE => self::TYPE_RECURRENCE_RULE, self::AT_TYPE => self::TYPE_RECURRENCE_RULE,
'frequency' => strtolower($rrule['FREQ']), 'frequency' => strtolower($rrule['FREQ']),
'interval' => $rrule['INTERVAL'] ?? null, 'interval' => $rrule['INTERVAL'] ?? null,
'until' => empty($rrule['UNTIL']) ? null : self::DateTime($rrule['UNTIL'], $event['tzid']), 'until' => empty($rrule['UNTIL']) ? null : self::DateTime($rrule['UNTIL'], $event['tzid']),
'count' => $rrule['COUNT'] ?? null ? (int)$rrule['COUNT'] : null,
]); ]);
if (!empty($GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts']) && if (!empty($GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts']) &&
$GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts'] !== 'Monday') $GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts'] !== 'Monday')
@ -658,6 +954,44 @@ class JsCalendar extends JsBase
]); ]);
} }
/**
* Convert Infolog RRULE stored in cfs to JsCalendar RecurrenceRules
*
* @param array $cfs
* @return array
*/
public static function cfRrule2recurrenceRules(array $cfs)
{
$rrule = [];
foreach(explode(';', $cfs['##RRULE']) as $pair)
{
[$name, $value] = explode('=', $pair);
$rrule[$name] = $value;
}
return self::Recurrence(['tzid' => Api\DateTime::$user_timezone->getName()], [], [], $rrule);
}
/**
* Parse RecurrenceRules to InfoLog cf stored RRULE
* @param array $recurenaceRules
* @return array
*/
public static function parseRecuranceRules2cfRrule(array $recurenaceRules=[])
{
$rrule = [];
foreach($recurenaceRules as $rule)
{
foreach($rule as $name => $value)
{
$rrule[] = $name.'='.$value;
}
break; // we support only one rule!
}
return [
'#RRULE' => $rrule ? implode(';', $rrule) : null,
];
}
/** /**
* Get patch from an event / recurrence compared to the master event * Get patch from an event / recurrence compared to the master event
* *
@ -808,4 +1142,17 @@ class JsCalendar extends JsBase
} }
return $calendar_bo; return $calendar_bo;
} }
/**
* @return \infolog_bo
*/
protected static function getInfolog()
{
static $infolog_bo=null;
if (!isset($infolog_bo))
{
$infolog_bo = new \infolog_bo();
}
return $infolog_bo;
}
} }

View File

@ -550,8 +550,9 @@ class infolog_bo
* TZID timezone name e.g. 'UTC' * TZID timezone name e.g. 'UTC'
* or NULL for timestamps in user-time * or NULL for timestamps in user-time
* or false for timestamps in server-time * or false for timestamps in server-time
* @param string $type 'ts' timestamp, 'object': DateTime objects
*/ */
function time2time(&$values, $fromTZId=false, $toTZId=null) function time2time(&$values, $fromTZId=false, $toTZId=null, $type='ts')
{ {
if ($fromTZId === $toTZId) return; if ($fromTZId === $toTZId) return;
@ -608,7 +609,7 @@ class infolog_bo
{ {
$time->setTimezone($toTZ); $time->setTimezone($toTZ);
} }
$values[$key] = Api\DateTime::to($time,'ts'); $values[$key] = Api\DateTime::to($time, $type);
} }
} }
//error_log(__METHOD__.'() --> values[info_enddate]='.date('Y-m-d H:i:s',$values['info_enddate'])); //error_log(__METHOD__.'() --> values[info_enddate]='.date('Y-m-d H:i:s',$values['info_enddate']));
@ -676,9 +677,9 @@ class infolog_bo
$this->link_id2from($data); $this->link_id2from($data);
} }
// convert server- to user-time // convert server- to user-time
if ($date_format == 'ts') if ($date_format !== 'server')
{ {
$this->time2time($data); $this->time2time($data, false, null, $date_format);
// pre-cache title and file access // pre-cache title and file access
self::set_link_cache($data); self::set_link_cache($data);

View File

@ -188,7 +188,7 @@ class infolog_groupdav extends Api\CalDAV\Handler
// check if we have to return the full calendar data or just the etag's // check if we have to return the full calendar data or just the etag's
if (!($filter['calendar_data'] = $options['props'] == 'all' && if (!($filter['calendar_data'] = $options['props'] == 'all' &&
$options['root']['ns'] == Api\CalDAV::CALDAV) && is_array($options['props'])) $options['root']['ns'] == Api\CalDAV::CALDAV || isset($_GET['download'])) && is_array($options['props']))
{ {
foreach($options['props'] as $prop) foreach($options['props'] as $prop)
{ {
@ -252,6 +252,7 @@ class infolog_groupdav extends Api\CalDAV\Handler
{ {
if ($this->debug) $starttime = microtime(true); if ($this->debug) $starttime = microtime(true);
$is_jstask = Api\CalDAV::isJSON();
if (($calendar_data = $filter['calendar_data'])) if (($calendar_data = $filter['calendar_data']))
{ {
$handler = self::_get_handler(); $handler = self::_get_handler();
@ -284,7 +285,7 @@ class infolog_groupdav extends Api\CalDAV\Handler
'order' => $order, 'order' => $order,
'sort' => $sort, 'sort' => $sort,
'filter' => $task_filter, 'filter' => $task_filter,
'date_format' => 'server', 'date_format' => $is_jstask ? 'object' : 'server',
'col_filter' => $filter, 'col_filter' => $filter,
'custom_fields' => true, // otherwise custom fields get NOT loaded! 'custom_fields' => true, // otherwise custom fields get NOT loaded!
'start' => 0, 'start' => 0,
@ -339,10 +340,19 @@ class infolog_groupdav extends Api\CalDAV\Handler
'displayname' => $task['info_subject'], 'displayname' => $task['info_subject'],
); );
if ($calendar_data) if ($calendar_data)
{
if ($is_jstask)
{
$content = Api\CalDAV\JsCalendar::JsTask($task, false);
$props['getcontentlength'] = bytes(Api\CalDAV::json_encode($content, $is_jstask));
$props['calendar-data'] = Api\CalDAV::mkprop(Api\CalDAV::CALDAV, 'calendar-data', $content);
}
else
{ {
$content = $handler->exportVTODO($task, '2.0', null); // no METHOD:PUBLISH for CalDAV $content = $handler->exportVTODO($task, '2.0', null); // no METHOD:PUBLISH for CalDAV
$props['getcontentlength'] = bytes($content); $props['getcontentlength'] = bytes($content);
$props[] = Api\CalDAV::mkprop(Api\CalDAV::CALDAV,'calendar-data',$content); $props['calendar-data'] = Api\CalDAV::mkprop(Api\CalDAV::CALDAV,'calendar-data',$content);
}
} }
if (++$yielded && isset($nresults) && $yielded > $nresults) if (++$yielded && isset($nresults) && $yielded > $nresults)
{ {
@ -585,8 +595,17 @@ class infolog_groupdav extends Api\CalDAV\Handler
return $task; return $task;
} }
$handler = $this->_get_handler(); $handler = $this->_get_handler();
// jsTask or iCal
if (($type=Api\CalDAV::isJSON($_SERVER['HTTP_ACCEPT'])) || ($type=Api\CalDAV::isJSON()))
{
$options['data'] = Api\CalDAV\JsCalendar::JsTask($task, $type);
$options['mimetype'] = Api\CalDAV\JsCalendar::MIME_TYPE_JSTASK.';charset=utf-8';
}
else
{
$options['data'] = $handler->exportVTODO($task, '2.0', null); // no METHOD:PUBLISH for CalDAV $options['data'] = $handler->exportVTODO($task, '2.0', null); // no METHOD:PUBLISH for CalDAV
$options['mimetype'] = 'text/calendar; charset=utf-8'; $options['mimetype'] = 'text/calendar; charset=utf-8';
}
header('Content-Encoding: identity'); header('Content-Encoding: identity');
header('ETag: "'.$this->get_etag($task).'"'); header('ETag: "'.$this->get_etag($task).'"');
return true; return true;
@ -762,7 +781,8 @@ class infolog_groupdav extends Api\CalDAV\Handler
*/ */
function read($id) function read($id)
{ {
return $this->bo->read(array(self::$path_attr => $id, "info_status!='deleted'"),false,'server'); return $this->bo->read(array(self::$path_attr => $id, "info_status!='deleted'"),false,
Api\CalDAV::isJson() ? 'object' : 'server');
} }
/** /**
@ -823,11 +843,11 @@ class infolog_groupdav extends Api\CalDAV\Handler
{ {
$info = $this->bo->read($info,true,'server'); $info = $this->bo->read($info,true,'server');
} }
if (!is_array($info) || !isset($info['info_id']) || !isset($info['info_datemodified'])) if (!is_array($info) || !isset($info['info_id']) || !isset($info['info_etag']) || !isset($info['info_datemodified']))
{ {
return false; return false;
} }
return $info['info_id'].':'.$info['info_datemodified']; return $info['info_id'].':'.$info['info_etag'].':'.Api\DateTime::to($info['info_datemodified'], 'ts');
} }
/** /**