From ca443060f4e69e8b767a66fd5d810e4aa6e73a3c Mon Sep 17 00:00:00 2001 From: ralf Date: Thu, 1 Feb 2024 22:16:36 +0200 Subject: [PATCH] WIP timesheet REST API --- api/src/CalDAV/Handler.php | 11 + api/src/Storage/Base.php | 7 +- doc/REST-CalDAV-CardDAV/Addressbook.md | 2 +- doc/REST-CalDAV-CardDAV/README.md | 11 +- doc/REST-CalDAV-CardDAV/Timesheet.md | 265 +++++++++++++ timesheet/inc/class.timesheet_bo.inc.php | 3 +- timesheet/src/ApiHandler.php | 468 ++++------------------- timesheet/src/JsTimesheet.php | 1 + 8 files changed, 358 insertions(+), 410 deletions(-) create mode 100644 doc/REST-CalDAV-CardDAV/Timesheet.md diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index e6faa7b216..938b4366cb 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -254,6 +254,17 @@ abstract class Handler { $entry = $this->read($entry); } + return static::etag($entry); + } + + /** + * Get the etag for an entry-array, can be reimplemented for other algorithm or field names + * + * @param array $entry + * @return false|string + */ + public static function etag(array $entry) + { if (!is_array($entry) || !isset($entry['id']) || !(isset($entry['modified']) || isset($entry['etag']))) { // error_log(__METHOD__."(".array2string($entry).") Cant create etag!"); diff --git a/api/src/Storage/Base.php b/api/src/Storage/Base.php index 86bc9bf5e0..f1f43f471b 100644 --- a/api/src/Storage/Base.php +++ b/api/src/Storage/Base.php @@ -1257,13 +1257,12 @@ class Base */ public function search2criteria($_pattern,&$wildcard='',&$op='AND',$extra_col=null, $search_cols=[],$search_cfs=null) { - $pattern = trim($_pattern); // This function can get called multiple times. Make sure it doesn't re-process. - if (empty($pattern) || is_array($pattern)) return $pattern; - if(strpos($pattern, 'CAST(COALESCE(') !== false) + if (empty($_pattern) || is_array($_pattern) || strpos($_pattern, 'CAST(COALESCE(') !== false) { - return $pattern; + return $_pattern; } + $pattern = trim($_pattern); $criteria = array(); $filter = array(); diff --git a/doc/REST-CalDAV-CardDAV/Addressbook.md b/doc/REST-CalDAV-CardDAV/Addressbook.md index 2f3114e17a..5133a66095 100644 --- a/doc/REST-CalDAV-CardDAV/Addressbook.md +++ b/doc/REST-CalDAV-CardDAV/Addressbook.md @@ -3,7 +3,7 @@ Authentication is via Basic Auth with username and a password, or a token valid for: - either just the given user or all users - CalDAV/CardDAV Sync (REST API) -- Calendar application +- Addressbook application Following RFCs / drafts used/planned for JSON encoding of resources * [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact) diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 0f773bf559..91905e8f70 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -32,6 +32,7 @@ One can use the following URLs relative (!) to https://example.org/egroupware/gr - ```/(resources|locations)//calendar``` calendar of a resource/location, if user has rights to view - ```//(resource|location)-``` shared calendar from a resource/location - ```/mail/``` REST API only +- ```/timesheet/``` REST API only Shared addressbooks or calendars are only shown in the users home-set, if he subscribed to it via his CalDAV preferences! @@ -40,8 +41,14 @@ from the data of an ```allprop``` PROPFIND, allow browsing CalDAV/CardDAV tree w ## REST API: using EGroupware CalDAV/CardDAV server with JSON - [Addressbook](Addressbook.md) -- [Calendar](Calendar.md) (currently recurring events are readonly, they are returned but can not be created or modified) -- [Mail](Mail.md) (currently only sending mails, opening interactive compose windows and vacation handling) +- [Calendar](Calendar.md) + * currently recurring events are readonly, they are returned but can not be created or modified +- [Mail](Mail.md) + * currently only sending mails, + * opening interactive compose windows, + * view and reply to eml files and + * vacation handling +- [Timesheet](Timesheet.md) > For the REST API you always have to send an "Accept: application/json" header and for POST & PUT requests additionally > a "Content-Type: application/json" header, otherwise you talk to the CalDAV/CardDAV server and don't get the response you expect! diff --git a/doc/REST-CalDAV-CardDAV/Timesheet.md b/doc/REST-CalDAV-CardDAV/Timesheet.md new file mode 100644 index 0000000000..b880aaa8e4 --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/Timesheet.md @@ -0,0 +1,265 @@ +# EGroupware REST API for Timesheet + +Authentication is via Basic Auth with username and a password, or a token valid for: +- either just the given user or all users +- CalDAV/CardDAV Sync (REST API) +- Timesheet application + +Following schema is used for JSON encoding of timesheets +* @type: `timesheet` +* id: integer ID +* title: string +* description: string (multiple lines) +* start: UTCDateTime e.g. `2020-02-03T14:35:37Z` +* duration: integer in minutes +* quantity: double +* unitprice: double +* category: category object with a single(!) category-name e.g. `{"category name": true}` +* owner: string with either email or username or integer ID +* created: UTCDateTime e.g. `2020-02-03T14:35:37Z` +* modified: UTCDateTime e.g. `2020-02-03T14:35:37Z` +* modifier: string with either email or username or integer ID +* pricelist: integer ID of projectmanager pricelist item +* status: string +* egroupware.org:customfields: custom-fields object, see other types +* etag: string `":"` (double quotes are part of the etag!) + +### Supported request methods and examples + +* **GET** to collections with an ```Accept: application/json``` header return all timesheets (similar to WebDAV PROPFIND) +
+ Example: Getting all timesheets of a given user + +``` +curl https://example.org/egroupware/groupdav.php//timesheet/ -H "Accept: application/pretty+json" --user +{ + "responses": { + "//timesheet/1": { + "@type": "timesheet", + "id": 1, + "title": "Test", + "start": "2005-12-16T23:00:00Z", + "duration": 150, + "quantity": 2.5, + "unitprice": 50, + "category": { "other": true }, + "owner": "ralf@boulder.egroupware.org", + "created": "2005-12-16T23:00:00Z", + "modified": "2011-06-08T10:51:20Z", + "modifier": "ralf@boulder.egroupware.org", + "status": "genehmigt", + "etag": "1:1307537480" + }, + "//timesheet/140": { + "@type": "timesheet", + "id": 140, + "title": "Test Ralf aus PM", + "start": "2016-08-22T12:12:00Z", + "duration": 60, + "quantity": 1, + "owner": "ralf@boulder.egroupware.org", + "created": "2016-08-22T12:12:00Z", + "modified": "2016-08-22T13:13:22Z", + "modifier": "ralf@boulder.egroupware.org", + "egroupware.org:customfields": { + "auswahl": { + "value": [ + "3" + ], + "type": "select", + "label": "Auswählen", + "values": { + "3": "Three", + "2": "Two", + "1": "One" + } + } + }, + "etag": "140:1471878802" + }, +... +} +``` +
+ + Following GET parameters are supported to customize the returned properties: + - props[]= eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified) + Default for timesheet collections is to only return address-data (JsContact), other collections return all props. + - sync-token= to only request change since last sync-token, like rfc6578 sync-collection REPORT + - nresults=N limit number of responses (only for sync-collection / given sync-token parameter!) + this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk + + The GET parameter `filters` allows to filter or search for a pattern in timesheets of a user: + - `filters[search]=` searches for `` in the whole timesheet like the search in the GUI + - `filters[search][%23]=` filters by a custom-field value + - `filters[]=` filters by a DB-column name and value + +
+ Example: Getting just ETAGs and displayname of all timesheets of a user + +``` +curl -i 'https://example.org/egroupware/groupdav.php//timesheet/?props[]=getetag&props[]=displayname' -H "Accept: application/pretty+json" --user + +{ + "responses": { + "/ralf/timesheet/1": {"displayname":"Test","getetag":"\"1:1307537480\""}, + "/ralf/timesheet/140": {"displayname":"Test Ralf aus PM","getetag":"\"140:1471878802\""}, + } +} +``` +
+ +
+ Example: Start using a sync-token to get only changed entries since last sync + +#### Initial request with empty sync-token and only requesting 10 entries per chunk: +``` +curl 'https://example.org/egroupware/groupdav.php/timesheet/?sync-token=&nresults=10&props[]=displayname' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/timesheet/2050": "Frau Margot Test-Notifikation", + "/timesheet/2384": "Test Tester", + "/timesheet/5462": "Margot Testgedöns", + "/timesheet/2380": "Frau Test Defaulterin", + "/timesheet/5474": "Noch ein Neuer", + "/timesheet/5575": "Mr New Name", + "/timesheet/5461": "Herr Hugo Kurt Müller Senior", + "/timesheet/5601": "Steve Jobs", + "/timesheet/5603": "Ralf Becker", + "/timesheet/1838": "Test Tester" + }, + "more-results": true, + "sync-token": "https://example.org/egroupware/groupdav.php/timesheet/1400867824" +} +``` +#### Requesting next chunk: +``` +curl 'https://example.org/egroupware/groupdav.php/timesheet/?sync-token=https://example.org/egroupware/groupdav.php/timesheet/1400867824&nresults=10&props[]=displayname' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/timesheet/1833": "Default Tester", + "/timesheet/5597": "Neuer Testschnuffi", + "/timesheet/5593": "Muster Max", + "/timesheet/5628": "2. Test Contact", + "/timesheet/5629": "Testen Tester", + "/timesheet/5630": "Testen Tester", + "/timesheet/5633": "Testen Tester", + "/timesheet/5635": "Test4 Tester", + "/timesheet/5638": "Test Kontakt", + "/timesheet/5636": "Test Default" + }, + "more-results": true, + "sync-token": "https://example.org/egroupware/groupdav.php/timesheet/1427103057" +} +``` +
+ +
+ Example: Requesting only changes since last sync + +#### ```sync-token``` from last sync need to be specified (note the null for a deleted resource!) +``` +curl 'https://example.org/egroupware/groupdav.php/timesheet/?sync-token=https://example.org/egroupware/groupdav.php/timesheet/1400867824' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/timesheet/5597": null, + "/timesheet/5593": { + TODO +.... + } + }, + "sync-token": "https://example.org/egroupware/groupdav.php/timesheet/1427103057" +} +``` +
+ +* **GET** requests with an ```Accept: application/json``` header can be used to retrieve single resources / JsTimesheet schema +
+ Example: GET request for a single resource showcasing available fieldes + +``` +curl 'https://example.org/egroupware/groupdav.php/timesheet/140' -H "Accept: application/pretty+json" --user +{ + "@type": "timesheet", + "id": 140, + "title": "Test Ralf aus PM", + "start": "2016-08-22T12:12:00Z", + "duration": 60, + "quantity": 1, + "owner": "ralf@boulder.egroupware.org", + "created": "2016-08-22T12:12:00Z", + "modified": "2016-08-22T13:13:22Z", + "modifier": "ralf@boulder.egroupware.org", + "egroupware.org:customfields": { + "auswahl": { + "value": [ + "3" + ], + "type": "select", + "label": "Auswählen", + "values": { + "3": "Three", + "2": "Two", + "1": "One" + } + } + }, + "etag": "140:1471878802" +} +``` +
+ +* **POST** requests to collection with a ```Content-Type: application/json``` header add new entries in timesheet collections + (Location header in response gives URL of new resource) +
+ Example: POST request to create a new resource + +``` +cat </timesheet/' -X POST -d @- -H "Content-Type: application/json" --user +{ + TODO +} +EOF + +HTTP/1.1 201 Created +Location: https://example.org/egroupware/groupdav.php//timesheet/1234 +``` +
+ +* **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources (requires to specify all attributes!) + +
+ Example: PUT request to update a resource + +``` +cat </timesheet/1234' -X PUT -d @- -H "Content-Type: application/json" --user +{ + TODO +} +EOF + +HTTP/1.1 204 No Content +``` + +
+ + +* **PATCH** request with a ```Content-Type: application/json``` header allow to modify a single resource by only specifying changed attributes as a [PatchObject](https://www.rfc-editor.org/rfc/rfc8984.html#type-PatchObject) + +
+ Example: PATCH request to modify a contact with partial data + +``` +cat </timesheet/1234' -X PATCH -d @- -H "Content-Type: application/json" --user +{ + TODO +} +EOF + +HTTP/1.1 204 No content +``` +
+ +* **DELETE** requests delete single resources + +> one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API \ No newline at end of file diff --git a/timesheet/inc/class.timesheet_bo.inc.php b/timesheet/inc/class.timesheet_bo.inc.php index ba9ffb4f2c..7f89035cc1 100644 --- a/timesheet/inc/class.timesheet_bo.inc.php +++ b/timesheet/inc/class.timesheet_bo.inc.php @@ -151,6 +151,7 @@ class timesheet_bo extends Api\Storage * Name of the timesheet table storing custom fields */ const EXTRA_TABLE = 'egw_timesheet_extra'; + const TABLE = 'egw_timesheet'; /** * Columns to search when user does a text search @@ -166,7 +167,7 @@ class timesheet_bo extends Api\Storage function __construct() { - parent::__construct(TIMESHEET_APP,'egw_timesheet',self::EXTRA_TABLE,'','ts_extra_name','ts_extra_value','ts_id'); + parent::__construct(TIMESHEET_APP,self::TABLE,self::EXTRA_TABLE,'','ts_extra_name','ts_extra_value','ts_id'); $this->config_data = Api\Config::read(TIMESHEET_APP); $this->quantity_sum = $this->config_data['quantity_sum'] == 'true'; diff --git a/timesheet/src/ApiHandler.php b/timesheet/src/ApiHandler.php index aa38442c2d..9bfe6195ee 100644 --- a/timesheet/src/ApiHandler.php +++ b/timesheet/src/ApiHandler.php @@ -37,7 +37,7 @@ class ApiHandler extends Api\CalDAV\Handler */ function __construct($app, Api\CalDAV $caldav) { - parent::__construct($app, $caldav); + parent::__construct('timesheet', $caldav); self::$path_extension = ''; $this->bo = new \timesheet_bo(); @@ -64,415 +64,16 @@ class ApiHandler extends Api\CalDAV\Handler { $user = $GLOBALS['egw_info']['user']['account_id']; } - else - { - $prefix = '/'.Api\Accounts::id2name($user); - if (str_starts_with($path, $prefix)) $path = substr($path, strlen($prefix)); - if ($user != $GLOBALS['egw_info']['user']['account_id']) - { - throw new \Exception("/mail is NOT available for users other than the one you authenticated!", 403); - } - } header('Content-Type: application/json'); try { - if (str_starts_with($path, '/mail/attachments/')) - { - return self::storeAttachment($path, $options['stream'] ?? $options['content']); - } - elseif (preg_match('#^/mail(/(\d+))?/vacation/?$#', $path, $matches)) - { - return self::updateVacation($user, $options['content'], $matches[2]); - } - elseif (preg_match('#^/mail(/(\d+))?/view/?$#', $path, $matches)) - { - return self::viewEml($user, $options['stream'] ?? $options['content'], $matches[2]); - } - elseif (preg_match('#^/mail(/(\d+))?(/compose)?#', $path, $matches)) - { - $ident_id = $matches[2] ?? self::defaultIdentity($user); - $do_compose = (bool)($matches[3] ?? false); - if (!($data = json_decode($options['content'], true))) - { - throw new \Exception('Error decoding JSON: '.json_last_error_msg(), 422); - } - // ToDo: check required attributes - - $preset = array_filter(array_intersect_key($data, array_flip(['to', 'cc', 'bcc', 'replyto', 'subject', 'priority']))+[ - 'body' => $data['bodyHtml'] ?? null ?: $data['body'] ?? '', - 'mimeType' => !empty($data['bodyHtml']) ? 'html' : 'plain', - 'identity' => $ident_id, - ]+self::prepareAttachments($data['attachments'] ?? [], $data['attachmentType'] ?? 'attach', - $data['shareExpiration'], $data['sharePassword'], $do_compose)); - - // for compose we need to construct a URL and push it to the client (or give an error if the client is not online) - if ($do_compose) - { - if (!Api\Json\Push::isOnline($user)) - { - $account_lid = Api\Accounts::id2name($user); - throw new \Exception("User '$account_lid' (#$user) is NOT online", 404); - } - $push = new Api\Json\Push($user); - $push->call('egw.open', '', 'mail', 'add', ['preset' => $preset], '_blank', 'mail'); - echo json_encode([ - 'status' => 200, - 'message' => 'Request to open compose window sent', - //'data' => $preset, - ], self::JSON_RESPONSE_OPTIONS); - return true; - } - $acc_id = Api\Mail\Account::read_identity($ident_id)['acc_id']; - $mail_account = Api\Mail\Account::read($acc_id); - // check if the mail-account requires a user-context / password and then just send the mail with an smtp-only account NOT saving to Sent folder - if (empty($mail_account->acc_imap_password) || $mail_account->acc_smtp_auth_session && empty($mail_account->acc_smtp_password)) - { - $acc_id = Api\Mail\Account::get_default(true, true, true, false); - $compose = new \mail_compose($acc_id); - $compose->mailPreferences['sendOptions'] = 'send_only'; - $warning = 'Mail NOT saved to Sent folder, as no user password'; - } - else - { - $compose = new \mail_compose($acc_id); - } - $preset = array_filter([ - 'mailaccount' => $acc_id, - 'mailidentity' => $ident_id, - 'identity' => null, - 'add_signature' => true, // add signature in send, independent what preference says - ]+$preset); - if ($compose->send($preset, $acc_id)) - { - echo json_encode(array_filter([ - 'status' => 200, - 'warning' => $warning ?? null, - 'message' => 'Mail successful sent', - //'data' => $preset, - ]), self::JSON_RESPONSE_OPTIONS); - return true; - } - throw new \Exception($compose->error_info); - } - - throw new \Exception('Not Found', 404); + throw new \Exception('Not Implemented', 501); } catch (\Throwable $e) { return self::handleException($e); } } - /** - * Get vacation array from server - * - * @param Api\Mail\Imap $imap - * @param ?int $user - * @return array - */ - protected static function getVacation(Api\Mail\Imap $imap, int $user=null) - { - if ($GLOBALS['egw']->session->token_auth) - { - return $imap->getVacationUser($user ?: $GLOBALS['egw_info']['user']['account_id']); - } - $sieve = new Api\Mail\Sieve($imap); - return $sieve->getVacation()+['script' => $sieve->script]; - } - - /** - * Update vacation message/handling with JSON data given in $content - * - * @param int $user - * @param array $content - * @param int|null $identity - * @return bool - * @throws Api\Exception\AssertionFailed - * @throws Api\Exception\NotFound - */ - protected static function updateVacation(int $user, string $content, int $identity=null) - { - $account = self::getMailAccount($user, $identity); - $vacation = $account->imapServer()->getVacationUser($user); - if (!($update = json_decode($content, true, 3, JSON_THROW_ON_ERROR))) - { - throw new \Exeception('Invalid request: no content', 400); - } - // Sieve class stores them as timestamps - foreach(['start', 'end'] as $name) - { - if (isset($update[$name])) - { - $vacation[$name.'_date'] = (new Api\DateTime($update[$name]))->format('ts'); - if (empty($update['status'])) $update['status'] = 'by_date'; - } - elseif (array_key_exists($name, $update)) - { - $vacation[$name.'_date'] = null; - if (empty($update['status'])) $update['status'] = 'off'; - } - unset($update[$name]); - } - // Sieve class stores them as comma-separated string - if (array_key_exists('forwards', $update)) - { - $vacation['forwards'] = implode(',', self::parseAddressList($update['forwards'] ?? [], 'forwards')); - unset($update['forwards']); - } - if (array_key_exists('addresses', $update)) - { - $update['addresses'] = self::parseAddressList($update['addresses'] ?? [], 'addresses'); - } - static $modi = ['notice+store', 'notice', 'store']; - if (isset($update['modus']) && !in_array($update['modus'], $modi)) - { - throw new \Exception("Invalid value '$update[modus]' for attribute modus, allowed values are: '".implode("', '", $modi)."'", 400); - } - if (($invalid=array_diff(array_keys($update), ['start','end','status','modus','text','addresses','forwards','days']))) - { - throw new \Exception("Invalid attribute: ".implode(', ', $invalid), 400); - } - $vacation_rule = null; - $vacation = array_merge([ // some defaults - 'status' => 'on', - 'addresses' => [Api\Accounts::id2name($user, 'account_email')], - 'days' => 3, - ], $vacation, $update); - // for token-auth we have to use the admin connection - if ($GLOBALS['egw']->session->token_auth) - { - if (!$account->imapServer()->setVacationUser($user, $vacation)) - { - throw new \Exception($account->imapServer()->error ?: 'Error updating sieve-script'); - } - } - else - { - $sieve = new Api\Mail\Sieve($account->imapServer()); - $sieve->setVacation($vacation, null, $vacation_rule, true); - } - echo json_encode(array_filter([ - 'status' => 200, - 'message' => 'Vacation handling updated', - 'vacation_rule' => $vacation_rule, - 'vacation' => self::returnVacation(self::getVacation($account->imapServer(), $user)), - ]), self::JSON_RESPONSE_OPTIONS); - return true; - } - - /** - * Parse array of email addresses - * - * @param string[] $_addresses - * @param string $name attribute name for exception - * @return string[] - * @throws \Exception if there is an invalid email address - */ - protected static function parseAddressList(array $_addresses, $name=null) - { - $parsed = iterator_to_array(Api\Mail::parseAddressList($_addresses)); - - if (count($parsed) !== count($_addresses) || - array_filter($parsed, static function ($addr) - { - return !$addr->valid; - })) - { - throw new \Exception("Error parsing email-addresses in attribute $name: ".json_encode($_addresses)); - } - return array_map(static function($addr) - { - return $addr->mailbox.'@'.$addr->host; - }, $parsed); - } - - /** - * Store uploaded attachment and return token - * - * @param string $path - * @param string|stream $content - * @return string HTTP status - * @throws \Exception on error - */ - protected static function storeAttachment(string $path, $content) - { - $attachment_path = tempnam($GLOBALS['egw_info']['server']['temp_dir'], 'attach--'. - (str_replace('/', '-', substr($path, 18)) ?: 'no-name').'--'); - if (is_resource($content) ? - stream_copy_to_stream($content, $fp=fopen($attachment_path, 'w')) : - file_put_contents($attachment_path, $content)) - { - if (isset($fp)) fclose($fp); - $location = '/mail/attachments/'.substr(basename($attachment_path), 8); - // allow to suppress location header with an "X-No-Location: true" header - if (($location_header = empty($_SERVER['HTTP_X_NO_LOCATION']))) - { - header('Location: '.Api\Framework::getUrl(Api\Framework::link('/groupdav.php'.$location))); - } - $ret = $location_header ? '201 Created' : '200 Ok'; - echo json_encode([ - 'status' => (int)$ret, - 'message' => 'Attachment stored', - 'location' => $location, - ], self::JSON_RESPONSE_OPTIONS); - return $ret; - } - throw new \Exception('Error storing attachment'); - } - - /** - * View posted eml file - * - * @param int $user - * @param string|stream $content - * @param ?int $acc_id mail account to import in Drafts folder - * @return string HTTP status - * @throws \Exception on error - */ - protected static function viewEml(int $user, $content, int $acc_id=null) - { - if (empty($acc_id)) - { - $acc_id = self::defaultIdentity($user); - } - - // check and bail, if user is not online - if (!Api\Json\Push::isOnline($user)) - { - $account_lid = Api\Accounts::id2name($user); - throw new \Exception("User '$account_lid' (#$user) is NOT online", 404); - } - - // save posted eml to a temp-dir - $eml = tempnam($GLOBALS['egw_info']['server']['temp_dir'], 'view-eml-'); - if (!(is_resource($content) ? - stream_copy_to_stream($content, $fp = fopen($eml, 'w')) : - file_put_contents($eml, $content))) - { - throw new \Exception('Error storing attachment'); - } - if (isset($fp)) fclose($fp); - - // import mail into drafts folder - $mail = Api\Mail::getInstance(false, $acc_id); - $folder = $mail->getDraftFolder(); - $mailer = new Api\Mailer(); - $mail->parseFileIntoMailObject($mailer, $eml); - $mail->openConnection(); - $message_uid = $mail->appendMessage($folder, $mailer->getRaw(), null, '\\Seen'); - - // tell browser to view eml from drafts folder - $push = new Api\Json\Push($user); - $push->call('egw.open', \mail_ui::generateRowID($acc_id, $folder, $message_uid, true), - 'mail', 'view', ['mode' => 'display'], '_blank', 'mail'); - - // respond with success message - echo json_encode([ - 'status' => 200, - 'message' => 'Request to open view window sent', - ], self::JSON_RESPONSE_OPTIONS); - - return true; - } - - /** - * Get default identity of user - * - * @param int $user - * @return int ident_id - * @throws Api\Exception\WrongParameter - * @throws \Exception (404) if user has no IMAP account - */ - protected static function defaultIdentity(int $user) - { - foreach(Api\Mail\Account::search($user,false) as $acc_id => $account) - { - // do NOT add SMTP only accounts as identities - if (!$account->is_imap(false)) continue; - - foreach($account->identities($acc_id) as $ident_id => $identity) - { - return $ident_id; - } - } - throw new \Exception("No IMAP account found for user #$user", 404); - } - - /** - * Convert an attachment name into an upload array for mail_compose::compose - * - * @param string[] $attachments either "/mail/attachments/" / file in temp_dir or VFS path - * @param ?string $attachmentType "attach" (default), "link", "share_ro", "share_rw" - * @param ?string $expiration "YYYY-mm-dd" or e.g. "+2days" - * @param ?string $password optional password for the share - * @param bool $compose true: for compose window, false: to send - * @return array with values for keys "file", "name", "filemode", "expiration" and "password" - * @throws Exception if file not found or unreadable - */ - protected static function prepareAttachments(array $attachments, string $attachmentType=null, string $expiration=null, string $password=null, bool $compose=true) - { - $ret = []; - foreach($attachments as $attachment) - { - if (preg_match('#^/mail/attachments/(([^/]+)--[^/.-]{6,})$#', $attachment, $matches)) - { - if (!file_exists($path=$GLOBALS['egw_info']['server']['temp_dir'].'/attach--'.$matches[1])) - { - throw new \Exception("Attachment $attachment NOT found", 400); - } - if ($compose) - { - $ret['file'][] = $path; - $ret['name'][] = $matches[2]; - } - else - { - $ret['attachments'][] = [ - 'name' => $matches[2], - 'type' => Api\Vfs::mime_content_type($path), - 'file' => $path, - 'size' => filesize($path), - ]; - } - } - else - { - if (!Api\Vfs::is_readable($attachment)) - { - throw new \Exception("Attachment $attachment NOT found", 400); - } - if ($compose) - { - $ret['file'][] = Api\Vfs::PREFIX.$attachment; - $ret['name'][] = Api\Vfs::basename($attachment); - } - else - { - $ret['attachments'][] = [ - 'name' => Api\Vfs::basename($attachment), - 'type' => Api\Vfs::mime_content_type($attachment), - 'file' => Api\Vfs::PREFIX.$attachment, - 'size' => filesize(Api\Vfs::PREFIX.$attachment), - ]; - } - } - } - if ($ret) - { - $ret['filemode'] = $attachmentType ?? 'attach'; - if (!in_array($ret['filemode'], $valid=['attach', 'link', 'share_ro', 'share_rw'])) - { - throw new \Exception("Invalid value '$ret[filemode]' for attachmentType, must be one of: '".implode("', '", $valid)."'", 422); - } - // EPL share password and expiration - $ret['password'] = $password ?: null; - if (!empty($expiration)) - { - $ret['expiration'] = (new Api\DateTime($expiration))->format('Y-m-d'); - } - } - return $ret; - } - /** * Handle propfind in the timesheet folder / get request on the collection itself * @@ -671,6 +272,69 @@ class ApiHandler extends Api\CalDAV\Handler } } + /** + * Process filter GET parameter: + * - filter[]= + * - filter[%23 + * - filter[search]= with string pattern like for search in the UI + * - filter[search][%23 + * - filter[search][]= + * + * @param array $filter + * @return array + */ + protected function filter2col_filter(array $filter) + { + $cols = []; + foreach($filter as $name => $value) + { + switch($name) + { + case 'search': + $cols = array_merge($cols, $this->bo->search2criteria($value)); + break; + case 'category': + case 'pricelist': + $cols[$name === 'pricelist' ? 'pl_id' : 'cat_id'] = $value; + break; + case 'status': + $value = array_map(function ($val) use ($value) + { + if (!is_numeric($val) || (string)(int)$val !== $val) + { + $val = array_search($val, $this->bo->status_labels, true); + } + elseif (isset($this->status_labels[$val])) + { + $val = (int)$val; + } + else + { + $val = false; + } + if ($val === false) + { + throw new Api\CalDAV\JsParseException("Invalid status filter value ".json_encode($value)); + } + return (int)$val; + }, (array)$value); + $cols['ts_status'] = count($value) <= 1 ? array_pop($value) : $value; + break; + default: + if ($name[0] === '#') + { + $cols[$name] = $value; + } + else + { + $cols['ts_'.$name] = $value; + } + break; + } + } + return $cols; + } + /** * Process the filters from the CalDAV REPORT request * @@ -685,7 +349,7 @@ class ApiHandler extends Api\CalDAV\Handler // in case of JSON/REST API pass filters to report if (Api\CalDAV::isJSON() && !empty($options['filters']) && is_array($options['filters'])) { - $filters += $options['filters']; // using += to no allow overwriting existing filters + $filters += $this->filter2col_filter($options['filters']); // using += to not allow overwriting existing filters } elseif (!empty($options['filters'])) { diff --git a/timesheet/src/JsTimesheet.php b/timesheet/src/JsTimesheet.php index 05d68980e9..02382783a6 100644 --- a/timesheet/src/JsTimesheet.php +++ b/timesheet/src/JsTimesheet.php @@ -62,6 +62,7 @@ class JsTimesheet extends Api\CalDAV\JsBase 'pricelist' => (int)$timesheet['pl_id'] ?: null, 'status' => $bo->status_labels[$timesheet['status']] ?? null, 'egroupware.org:customfields' => self::customfields($timesheet), + 'etag' => ApiHandler::etag($timesheet) ]); if ($encode)