Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion api/src/Db.php
Original file line number Diff line number Diff line change
Expand Up @@ -1724,7 +1724,12 @@ function column_data_implode($glue,$array,$use_key=True,$only=False,$column_defi
if (strpos($key, '.') !== false) list(, $col) = explode('.', $key);
if (!isset($column_definitions[$col]))
{
throw new Db\Exception\InvalidSql("db::column_data_implode('$glue',".print_r($array,True).",'$use_key',".print_r($only,True).",<pre>".print_r($column_definitions,True)."</pre><b>nothing known about column '$key'!</b>");
// the full arrays are valuable for debugging, but this exception message is echoed
// to REST/CalDAV clients, so keep the table schema and the given values out of it
error_log(__METHOD__."('$glue',".print_r($array,True).",'$use_key',".print_r($only,True).
") nothing known about column '$key', known columns: ".
implode(', ', array_keys((array)$column_definitions)));
throw new Db\Exception\InvalidSql("nothing known about column '$key'!");
}
}
$column_type = is_array($column_definitions) ? ($column_definitions[$col]['type'] ?? false) : False;
Expand Down
87 changes: 86 additions & 1 deletion calendar/inc/class.calendar_groupdav.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,85 @@ private function _created_updated_by_prop($user, $time)
return $props ?: '';
}

/**
* Apply the JSON/REST API filters to the calendar search parameters
*
* Unlike the CalDAV XML filters handled in _report_filters(), these arrive as a plain
* name => value array. filters[start] / filters[end] have already been turned into a
* synthetic time-range element under an integer key by Api\CalDAV::jsonIndex().
*
* Entries outside filters[start]/filters[end] keep the collection's default -100/+365 day
* window, same as an unfiltered listing.
*
* @param array $filters filters from the request
* @param array& $cal_filters
* @throws Api\Exception 400 for an invalid linked-filter
*/
private function jsonReportFilters(array $filters, array &$cal_filters)
{
$search = $linked_ids = null;

foreach($filters as $name => $value)
{
if (!is_string($name))
{
if (is_array($value) && ($value['name'] ?? null) === 'time-range')
{
if (!empty($value['attrs']['start']))
{
$cal_filters['start'] = $this->vCalendar->_parseDateTime($value['attrs']['start']);
}
if (!empty($value['attrs']['end']))
{
$cal_filters['end'] = $this->vCalendar->_parseDateTime($value['attrs']['end']);
}
}
continue;
}
switch($name)
{
case 'search':
// string: free-text over cal_title, cal_description and cal_location
// array: <db-column> => <value>, integer keys stripped as they'd be used as SQL
$search = is_array($value) ?
array_filter($value, static fn($key) => !is_int($key), ARRAY_FILTER_USE_KEY) : $value;
break;

case 'linked':
if (!preg_match('/^([a-z_]+):(\d+)$/i', $value, $matches) ||
!isset($GLOBALS['egw_info']['user']['apps'][$matches[1]]) || (int)$matches[2] <= 0)
{
throw new Api\Exception("Invalid linked-filter '$value', should be '<app-name>:<nummeric-ID>'!", 400);
}
// [0] to return nothing instead of everything, if there are no links
$linked_ids = Api\Link::get_links($matches[1], $matches[2], 'calendar') ?: [0];
break;

default:
$this->caldav->log(__METHOD__."() unknown filter '$name' --> ignored");
break;
}
}

// calendar_bo::search() only understands one "query": either a free-text string searched over
// title/description/location, or an array of <db-column> => <value>. sql_filter is not usable
// here, calendar_bo::search() deliberately overwrites it from its own 2nd argument.
if (isset($search) && isset($linked_ids) && !is_array($search))
{
throw new Api\Exception("filters[linked] can not be combined with a free-text filters[search] for calendar", 400);
}
if (isset($linked_ids))
{
$search = (array)($search ?? []);
// table-qualified, cal_id exists in egw_cal and egw_cal_user
$search['egw_cal.cal_id'] = $linked_ids;
}
if (isset($search))
{
$cal_filters['query'] = $search;
}
}

/**
* Process the filters from the CalDAV REPORT request
*
Expand All @@ -554,7 +633,13 @@ private function _created_updated_by_prop($user, $time)
*/
function _report_filters($options, &$cal_filters, $id, &$nresults)
{
if ($options['filters'])
// JSON/REST API: filters arrive as a plain name => value array, not as CalDAV XML elements,
// so they would all fall through the switch below and be silently ignored
if (Api\CalDAV::isJSON() && !empty($options['filters']) && is_array($options['filters']))
{
$this->jsonReportFilters($options['filters'], $cal_filters);
}
elseif ($options['filters'])
{
// unset default start & end
$cal_start = $cal_filters['start']; unset($cal_filters['start']);
Expand Down
27 changes: 21 additions & 6 deletions doc/openapi/addressbook.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"Addressbook"
],
"summary": "Search all addressbooks",
"description": "Search in all addressbooks the user has access to. Supports searching, filtering, pagination, and custom property selection.",
"description": "Search all addressbooks the user has access to, ordered by contact ID (oldest first); pass filters[order]=contact_modified DESC for newest first. Use filters[search] for a free-text pattern. To bound the response size, send sync-token= (empty) together with nresults=20; the response then carries a more-results flag and a sync-token for the next chunk.",
"operationId": "searchContacts",
"parameters": [
{
Expand All @@ -43,6 +43,9 @@
{
"$ref": "#/components/parameters/nresults"
},
{
"$ref": "#/components/parameters/filters_order"
},
{
"$ref": "#/components/parameters/filters_search"
},
Expand All @@ -52,7 +55,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -146,7 +149,7 @@
"get": {
"tags": ["Addressbook"],
"summary": "List or search contacts in the given users addressbook",
"description": "Returns all contacts in the user's addressbook. Supports filtering, pagination, and custom property selection.",
"description": "Returns contacts from the user's addressbook, ordered by contact ID (oldest first); pass filters[order]=contact_modified DESC for newest first. To bound the response size, send sync-token= (empty) together with nresults=20; the response then carries a more-results flag and a sync-token for the next chunk.",
"operationId": "listContacts",
"parameters": [
{
Expand All @@ -161,6 +164,9 @@
{
"$ref": "#/components/parameters/nresults"
},
{
"$ref": "#/components/parameters/filters_order"
},
{
"$ref": "#/components/parameters/filters_search"
},
Expand All @@ -170,7 +176,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": ["application/json", "application/pretty+json"]
Expand Down Expand Up @@ -273,7 +279,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": ["application/json", "application/pretty+json"]
Expand Down Expand Up @@ -485,7 +491,16 @@
"schema": {
"type": "integer"
},
"description": "Limit number of responses (only for sync-collection)"
"description": "Limit the number of contacts returned. Only honored together with the sync-token parameter: send sync-token= (empty) plus nresults=N to receive at most N contacts, a \"more-results\": true flag when further contacts exist, and a sync-token to request the next chunk. Sent on its own, nresults has no effect."
},
"filters_order": {
"name": "filters[order]",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Sort order as '<db-column>' optionally followed by ' ASC' or ' DESC'. Defaults to 'egw_addressbook.contact_id' (oldest first). A table-qualified column such as egw_addressbook.<column> is also accepted. Example: filters[order]=n_family ASC"
},
"filters_search": {
"name": "filters[search]",
Expand Down
26 changes: 14 additions & 12 deletions doc/openapi/calendar.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"Calendar"
],
"summary": "List all events of the user",
"description": "Returns all events in the user's calendar. Supports filtering, pagination, and custom property selection.",
"description": "Returns events from the user's calendar. Without filters[start]/filters[end] only events between 100 days in the past and 365 days in the future are returned. To bound the response size, send sync-token= (empty) together with nresults=20; the response then carries a more-results flag and a sync-token for the next chunk. If the question mentions any time period, resolve it to explicit YYYY-MM-DD dates and pass filters[start] and filters[end] as well.",
"operationId": "listEvents",
"parameters": [
{
Expand All @@ -58,7 +58,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -169,7 +169,7 @@
"get": {
"tags": ["Calendar"],
"summary": "List all events in a given users calendar",
"description": "Returns all events in the user's calendar. Supports filtering, pagination, and custom property selection.",
"description": "Returns events from the user's calendar. Without filters[start]/filters[end] only events between 100 days in the past and 365 days in the future are returned. To bound the response size, send sync-token= (empty) together with nresults=20; the response then carries a more-results flag and a sync-token for the next chunk. If the question mentions any time period, resolve it to explicit YYYY-MM-DD dates and pass filters[start] and filters[end] as well.",
"operationId": "listUserEvents",
"parameters": [
{
Expand Down Expand Up @@ -199,7 +199,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": ["application/json", "application/pretty+json"]
Expand Down Expand Up @@ -319,7 +319,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": ["application/json", "application/pretty+json"]
Expand Down Expand Up @@ -457,7 +457,7 @@
{
"name": "Accept",
"in": "header",
"required": true,
"required": false,
"schema": {
"type": "string",
"enum": ["application/json"]
Expand Down Expand Up @@ -536,25 +536,27 @@
"schema": {
"type": "integer"
},
"description": "Limit number of responses (only for sync-collection)"
"description": "Limit the number of events returned. Only honored together with the sync-token parameter: send sync-token= (empty) plus nresults=N to receive at most N events, a \"more-results\": true flag when further events exist, and a sync-token to request the next chunk. Sent on its own, nresults has no effect."
},
"filters_start": {
"name": "filters[start]",
"in": "query",
"required": false,
"schema": {
"type": "datetime"
"type": "string",
"format": "date"
},
"description": "Start date of search. Example: filters[start]=2026-01-01"
"description": "Only return entries overlapping this start date, as YYYY-MM-DD (a full ISO 8601 date-time is also accepted). REQUIRED whenever the question refers to a time period: resolve phrases like 'this week', 'today' or 'next month' to explicit dates and pass them. Example: filters[start]=2026-01-01"
},
"filters_end": {
"name": "filters[end]",
"in": "query",
"required": false,
"schema": {
"type": "datetime"
"type": "string",
"format": "date"
},
"description": "End date of search. Example: filters[start]=2026-02-01"
"description": "Only return entries overlapping this end date, as YYYY-MM-DD (a full ISO 8601 date-time is also accepted). Pass together with filters[start] for any time-bounded question. Example: filters[end]=2026-02-01"
},
"filters_search": {
"name": "filters[search]",
Expand All @@ -563,7 +565,7 @@
"schema": {
"type": "string"
},
"description": "Search pattern. Example: filters[search]=something"
"description": "Free-text pattern searched over event title, description and location. Cannot be combined with filters[linked]. Example: filters[search]=standup"
},
"filters_linked": {
"name": "filters[linked]",
Expand Down
Loading