commit bcbde6359a32196b07b5653c677877971d49ba09 Author: Deon George Date: Mon Mar 21 15:39:52 2022 +1100 Initial implementation diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ecace03 --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "leenooks/trello", + "description": "Trello API", + "keywords": ["laravel","leenooks","trello"], + "authors": [ + { + "name": "Deon George", + "email": "deon@leenooks.net" + } + ], + "require": { + }, + "require-dev": { + }, + "autoload": { + "psr-4": { + "Trello\\": "src" + } + }, + "minimum-stability": "dev" +} diff --git a/src/API.php b/src/API.php new file mode 100644 index 0000000..9ae8d06 --- /dev/null +++ b/src/API.php @@ -0,0 +1,294 @@ +_token = $o; + + Log::debug(sprintf('%s:Trello API with token [%s]',static::LOGKEY,$this->_token ? $this->_token->token_hidden() : NULL)); + } + + public function createCard(Card $card): Card + { + Log::debug(sprintf('%s:Create Card [%s] on list [%s]',static::LOGKEY,$card->name,$card->idList)); + + return (new Card)->forceFill((array)$this->execute('cards',array_merge($card->toArray(),['method'=>'POST']))) ; + } + + public function createCustomField(Board $board,string $name,string $type): CustomField + { + Log::debug(sprintf('%s:Create CustomField [%s] in Boards [%s]',static::LOGKEY,$name,$board->id)); + if (! in_array($type,['checkbox','list','number','text','date'])) + throw new \Exception('Invalid type: '.$type); + + // Invalidate any cache + Cache::forget(md5(sprintf('boards/%s/customFields',$board->id).serialize([]))); + + return CustomField::factory($this->execute('customFields',[ + 'name'=>$name, + 'type'=>$type, + 'idModel'=>$board->id, + 'display_cardFront'=>'true', + 'modelType'=>'board', + 'method'=>'POST' + ])); + } + + public function createCustomFieldOptions(ListList $field,Collection $options): ListList + { + Log::debug(sprintf('%s:Create CustomField Options to [%s] (%s)',static::LOGKEY,$field->name,$options->pluck('value.text')->join('|'))); + + // Invalidate any cache + Cache::forget(md5(sprintf('boards/%s/customFields',$field->idModel).serialize([]))); + + foreach ($options as $option) { + Log::debug(sprintf('%s: Adding (%s)',static::LOGKEY,Arr::get($option,'value.text'))); + + $field->addOption((new Option)->forceFill((array)$this->execute( + sprintf('customFields/%s/options',$field->id), + array_merge( + $option, + ['method'=>'POST'] + )))); + } + + return $field; + } + + /** + * Create a list on a board + * + * @param Board $board + * @param string $name + * @return BoardList + * @throws \Exception + */ + public function createList(Board $board,string $name): BoardList + { + Log::debug(sprintf('%s:Create List [%s] in Boards [%s]',static::LOGKEY,$name,$board->id)); + + // Invalidate any cache + Cache::forget(md5(sprintf('boards/%s/lists',$board->id).serialize([]))); + + return (new BoardList)->forceFill((array)$this->execute('lists',['name'=>$name,'idBoard'=>$board->id,'method'=>'POST']))->syncOriginal(); + } + + /** + * Call the Slack API + * + * @param string $path // @todo this should really be called $path, since it isnt the HTTP method + * @param array $parameters + * @return object|array + * @throws \Exception + */ + private function execute(string $path,array $parameters=[]) + { + $url = sprintf('%s/%d/%s',self::URL,self::VERSION,$path); + $method = Arr::get($parameters,'method','GET'); + + if ($parameters) + Arr::forget($parameters,'method'); + + // If we are passed an array, we'll do a normal post. + switch ($method) { + case 'GET': + $request = $this->prepareRequest( + $url, + $parameters, + [ + 'Content-Type: application/json; charset=utf-8', + sprintf('Authorization: OAuth oauth_consumer_key="%s", oauth_token="%s"',$this->_token->key,$this->_token->token), + ] + ); + break; + + case 'POST': + $request = $this->prepareRequestPost( + $url, + $parameters, + [ + 'Content-Type: application/json; charset=utf-8', + sprintf('Authorization: OAuth oauth_consumer_key="%s", oauth_token="%s"',$this->_token->key,$this->_token->token), + ] + ); + break; + + case 'PUT': + $request = $this->prepareRequestPut( + $url, + $parameters, + [ + 'Content-Type: application/json; charset=utf-8', + sprintf('Authorization: OAuth oauth_consumer_key="%s", oauth_token="%s"',$this->_token->key,$this->_token->token), + ] + ); + break; + + default: + throw new \Exception(sprintf('Unknown method: %s',$method)); + } + + $key = md5($path.serialize($parameters)); + + //Cache::forget($key); + $result = Cache::remember($key,self::CACHE_TIME,function() use ($request,$url) { + try { + $response = curl_exec($request); + + switch($x=curl_getinfo($request,CURLINFO_HTTP_CODE)) { + case 400: + case 404: + dump([$xx=curl_getinfo($request),'response'=>$response]); + + throw new \Exception(sprintf('CURL exec returned %d: %s (%s)',$x,curl_error($request),serialize($xx))); + } + + curl_close($request); + return json_decode($response); + + } catch (\Exception $e) { + Log::error(sprintf('%s:Got an error while posting to [%s] (%s)',static::LOGKEY,$url,$e->getMessage()),['m'=>__METHOD__]); + + curl_close($request); + throw new \Exception($e->getMessage()); + } + }); + + return $result; + } + + public function getBoards(): Collection + { + Log::debug(sprintf('%s:Get Boards',static::LOGKEY)); + + return Board::hydrate($this->execute('members/me/boards')); + } + + public function getCard(string $id): Card + { + Log::debug(sprintf('%s:Get Card [%s]',static::LOGKEY,$id)); + + return (new Card)->forceFill((array)$this->execute(sprintf('cards/%s?%s',$id,http_build_query(['all','customFieldItems'=>'true']))))->syncOriginal(); + } + + public function getCustomFields(Board $board): Collection + { + Log::debug(sprintf('%s:Get CustomFields from [%s]',static::LOGKEY,$board->id)); + + $result = collect(); + foreach ($this->execute(sprintf('boards/%s/customFields',$board->id)) as $cf) + $result->push(CustomField::factory($cf)); + + return $result; + } + + /** + * Get a list of Cards for a Board + * + * @param Board $board + * @return Collection + * @throws \Exception + */ + public function getCards(Board $board): Collection + { + Log::debug(sprintf('%s:Get Board Cards [%s]',static::LOGKEY,$board->id)); + + return Card::hydrate($this->execute(sprintf('boards/%s/cards',$board->id))); + } + + public function getLists(Board $board): Collection + { + Log::debug(sprintf('%s:Get Board Lists [%s]',static::LOGKEY,$board->id)); + + return BoardList::hydrate($this->execute(sprintf('boards/%s/lists',$board->id))); + } + + /** + * Setup the API call + * + * @param $url + * @param array $parameters + * @param array $headers + * @return resource + */ + private function prepareRequest($url,array $parameters=[],array $headers=[]) + { + $request = curl_init(); + + curl_setopt($request,CURLOPT_URL,$url.($parameters ? '?'.http_build_query($parameters) : '')); + curl_setopt($request,CURLOPT_RETURNTRANSFER,TRUE); + if ($headers) + curl_setopt($request,CURLOPT_HTTPHEADER,$headers); + curl_setopt($request,CURLINFO_HEADER_OUT,TRUE); + curl_setopt($request,CURLOPT_SSL_VERIFYPEER,FALSE); + curl_setopt($request,CURLOPT_HTTPGET,TRUE); + + return $request; + } + + private function prepareRequestPost($url,$parameters='',$headers=[]) + { + $request = $this->prepareRequest($url,[],$headers); + curl_setopt($request,CURLOPT_POST,TRUE); + curl_setopt($request,CURLOPT_POSTFIELDS,json_encode($parameters)); + + return $request; + } + + private function prepareRequestPut($url,$parameters='',$headers=[]) + { + $request = $this->prepareRequest($url,[],$headers); + //curl_setopt($request,CURLOPT_PUT,TRUE); + curl_setopt($request,CURLOPT_CUSTOMREQUEST,'PUT'); + curl_setopt($request,CURLOPT_POSTFIELDS,json_encode($parameters)); + + return $request; + } + + /** + * Update a card's value + * + * @param Card $card + * @param CustomField $cf + * @param string $value + * @return Generic + * @throws \Exception + */ + public function setFieldValue(Card $card,CustomField $cf,array $value) + { + if (! $card->id) + throw new \Exception('Card doesnt have an id?'); + + $x = $this->execute(sprintf('card/%s/customField/%s/item',$card->id,$cf->id),array_merge($value,['method'=>'PUT'])); + + return new Generic((array)$x); + } + + public function updateCard(Card $card): Card + { + Log::debug(sprintf('%s:Update Card [%s] on list [%s]',static::LOGKEY,$card->name,$card->idList)); + + $x = $this->execute(sprintf('cards/%s',$card->id),array_merge($card->getDirty(),['method'=>'PUT'])); + + return (new Card)->forceFill((array)$this->execute(sprintf('cards/%s',$card->id),array_merge($card->getDirty(),['method'=>'PUT']))); + } +} \ No newline at end of file diff --git a/src/Exceptions/TrelloException.php b/src/Exceptions/TrelloException.php new file mode 100644 index 0000000..17b1c60 --- /dev/null +++ b/src/Exceptions/TrelloException.php @@ -0,0 +1,7 @@ +api; + } + + /** + * Retrieve a specific custom field + * + * @param string $name + * @return CustomField + * @throws \Exception + */ + public function customField(string $name): ?CustomField + { + return $this->customFields()->filter(function($item) use ($name) { return $item->name == $name;})->pop(); + } + + /** + * Get this boards custom fields. + * + * @return Collection + * @throws \Exception + */ + public function customFields(): Collection + { + if (! $this->customfields->count()) { + $cf = $this->api->getCustomFields($this); + + // Check all our customFields are of the types we need: + foreach ($cf as $key => $field) { + if ($x=config('trello.customfields.'.$field->name)) { + + if ($field instanceof $x) { + dump(['m'=>__METHOD__,'EXISTS','x'=>$x,'field'=>$field,'listlsit'=>$field instanceof ListList]); + + // If it is a list item, we need to check that the list has all the options + if ($field instanceof ListList) { + if (! $field->hasAllOptions()) { + $this->api->createCustomFieldOptions($field,collect($field->missingOptions())->transform(function($item) { return ['pos'=>'bottom','value'=>['text'=>$item]]; })); + //dd(['m'=>__METHOD__,'has failed?'=>collect($field->missingOptions())->transform(function($item,$key) { return ['pos'=>$key,'value'=>['text'=>$item]]; }),'x'=>$x]); + } + } + + } else { + Log::error(sprintf('%s:Field [%s] is not of the right type [%s] (%s)',self::LOGKEY,$field->name,$field->type,$x)); + + throw new \Exception(sprintf('Invalid field type: %s (%s), should be [%s]',$field->name,get_class($field),$x)); + } + + // Not one of our fields + } else { + Log::debug(sprintf('%s:Ignoring custom field [%s]',self::LOGKEY,$field->name)); + + $cf->forget($key); + } + } + + // Any missing fields we need to create. + foreach (collect(config('trello.customfields'))->filter(function($item,$key) use ($cf) { return $cf->pluck('attributes.name')->search($key) === FALSE; }) as $key => $field) { + Log::debug(sprintf('%s:Creating field [%s] (%s)',self::LOGKEY,$key,$field)); + + $o = new $field(new \stdClass); + dump(['o'=>$o]); + $cf->push($x=$this->api->createCustomField($this,$key,$o->trello_type)); + //dd(['M'=>__METHOD__,'addkey'=>$key,'addfield'=>$field,'x'=>$x]); + + // If it is a list, we'll need to create our options too + if ($x instanceof ListList) { + $this->api->createCustomFieldOptions($x,collect($x->missingOptions())->transform(function($item) { return ['pos'=>'bottom','value'=>['text'=>$item]]; })); + } + } + + $this->customfields = $cf; + } + + return $this->customfields; + } + + /** + * Retrieve the list that we use to create new cards in + * + * @return BoardList + */ + public function listNewOps(): BoardList + { + if (! $this->newopslist) { + // Find our "New Opps" List in Trello + $newoplist = $this->api + ->getLists($this) + ->filter(function($item) { return $item->name == self::newopslist; }); + + // If list doesnt exist, we'll create it. + if (! $newoplist->count()) { + //$newoplist = $api->createList($b,$newopslist); + dd([__METHOD__=>$newoplist,'todo'=>'create this']); + + } else { + $this->newoplist = $newoplist->pop(); + } + } + + return $this->newoplist; + } + + /** + * Record the API that this board is stored/retrieved with + * + * @param API $api + * @return void + */ + public function setAPI(API $api): void + { + $this->api = $api; + $this->customfields = collect(); + } +} \ No newline at end of file diff --git a/src/Models/BoardList.php b/src/Models/BoardList.php new file mode 100644 index 0000000..9efaa7a --- /dev/null +++ b/src/Models/BoardList.php @@ -0,0 +1,10 @@ +customFieldItems)->filter(function($item) use ($o) { return $item->idCustomField == $o->id; })->pop(); + } + + /** + * Determine if this card should be ignored. + * + * @param Board $b + * @return bool + * @throws \Exception + */ + public function shouldIgnore(Board $b): bool + { + // Check Stages + $cf = $b->customField('Stage'); + $y = ''; + + if (($x=$this->getCustomFieldValue($cf)) && (! in_array($y=$cf->value($x),SalesStage::options))) { + Log::info(sprintf('Card [%s] Stage value is not one of ours [%s]',$this->id,$y)); + + return TRUE; + } + Log::debug(sprintf('Card [%s] Stage value is [%s]',$this->id,$y ?: 'Not Set')); + + // Check Actions + $cf = $b->customField('Action'); + $y = ''; + + if (($x=$this->getCustomFieldValue($cf)) && (! in_array($y=$cf->value($x),Action::options)) || ($y === 'Ignore')) { + Log::info(sprintf('Card [%s] Action value is not one of ours [%s]',$this->id,$y)); + + return TRUE; + } + Log::debug(sprintf('Card [%s] Action value is [%s]',$this->id,$y ?: 'Not Set')); + + return FALSE; + } +} \ No newline at end of file diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php new file mode 100644 index 0000000..f9db59a --- /dev/null +++ b/src/Models/CustomField.php @@ -0,0 +1,68 @@ +has($x=object_get($data,'name'))) { + $xx = config('trello.customfields.'.$x); + $o = new $xx($data); + + if ($o->trello_type !== ($y=object_get($data,'type'))) + throw new \Exception(sprintf('%s:! ERROR - Custom field [%s] (%s) is not the right type [%s]',self::LOGKEY,$x,$o->trello_type,$y)); + + return $o; + } + + switch ($x=object_get($data,'type')) { + case 'checkbox': + return new Checkbox($data); + + case 'date': + return new Date($data); + + case 'list': + return new ListList($data); + + case 'number': + return new Number($data); + + case 'text': + return new Text($data); + + default: + dump($data); + throw new \Exception('Unknown data type: '.$x); + } + } + + /** + * Set a customfield's value + * + * @param string $value + * @return array + */ + abstract public function set(string $value): array; + + /** + * Get a customfield's value + * + * @param object $key + * @return string|null + */ + abstract public function value(object $key): ?string; +} \ No newline at end of file diff --git a/src/Models/CustomFields/Base.php b/src/Models/CustomFields/Base.php new file mode 100644 index 0000000..5cbc9f5 --- /dev/null +++ b/src/Models/CustomFields/Base.php @@ -0,0 +1,28 @@ +attributes = $attributes; + } + + public function __get($key) + { + switch ($key) { + case 'trello_type': + return object_get($this->attributes,'type'); + + case 'type': + return get_class($this); + + default: + return object_get($this->attributes,$key); + } + } +} \ No newline at end of file diff --git a/src/Models/CustomFields/Checkbox.php b/src/Models/CustomFields/Checkbox.php new file mode 100644 index 0000000..1cf67c9 --- /dev/null +++ b/src/Models/CustomFields/Checkbox.php @@ -0,0 +1,22 @@ +['checked'=>$value ? 'true' : 'false']]; + } + + public function value(object $key): ?string + { + // @todo TO TEST + return (string)$key->value->checked; + } +} \ No newline at end of file diff --git a/src/Models/CustomFields/Date.php b/src/Models/CustomFields/Date.php new file mode 100644 index 0000000..555315f --- /dev/null +++ b/src/Models/CustomFields/Date.php @@ -0,0 +1,23 @@ +['date'=>Carbon::createFromTimeString($value)->toISOString()]]; + } + + public function value(object $key): ?string + { + // @todo TO TEST + return $key->value->date; + } +} \ No newline at end of file diff --git a/src/Models/CustomFields/ListList.php b/src/Models/CustomFields/ListList.php new file mode 100644 index 0000000..d26d446 --- /dev/null +++ b/src/Models/CustomFields/ListList.php @@ -0,0 +1,70 @@ +options = collect(); + + foreach (object_get($attributes,'options',[]) as $option) + $this->addOption((new Option)->forceFill((array)$option)->syncOriginal()); + + unset($this->attributes->options); + } + + /* ABSTRACT METHODS */ + + public function set(string $value): array + { + return ['idValue'=>$this->id($value)]; + } + + /** + * Get the value, given a customfield key + * + * @param object $key + * @return string|null + */ + public function value(object $key): ?string + { + return ($x=$this->options->filter(function($item) use ($key) { return $item->id == $key->idValue; })->pop()) ? $x->value->text : NULL; + } + + /* METHODS */ + + public function addOption(Option $option): void + { + $this->options->push($option); + } + + /** + * Make sure our list has all the option values + * + * @return bool + */ + public function hasAllOptions(): bool + { + return $this->missingOptions()->count() == 0; + } + + private function id(string $name): ?string + { + return ($x=$this->options->filter(function($item) use ($name) { return $item->name == $name; })->pop()) ? $x->id : NULL; + } + + public function missingOptions(): Collection + { + return collect(static::options)->diff($this->options->pluck('name')); + } + +} \ No newline at end of file diff --git a/src/Models/CustomFields/Number.php b/src/Models/CustomFields/Number.php new file mode 100644 index 0000000..5de0fb1 --- /dev/null +++ b/src/Models/CustomFields/Number.php @@ -0,0 +1,20 @@ +['number'=>$value]]; + } + + public function value(object $key): ?string + { + return (string)$key->value->number; + } +} \ No newline at end of file diff --git a/src/Models/CustomFields/Options/Option.php b/src/Models/CustomFields/Options/Option.php new file mode 100644 index 0000000..06bef15 --- /dev/null +++ b/src/Models/CustomFields/Options/Option.php @@ -0,0 +1,16 @@ +attributes,'value')) ? $x->text : NULL; + } +} \ No newline at end of file diff --git a/src/Models/CustomFields/Text.php b/src/Models/CustomFields/Text.php new file mode 100644 index 0000000..3c8e2c1 --- /dev/null +++ b/src/Models/CustomFields/Text.php @@ -0,0 +1,20 @@ +['text'=>$value]]; + } + + public function value(object $key): ?string + { + return $key->value->text; + } +} \ No newline at end of file diff --git a/src/Models/Token.php b/src/Models/Token.php new file mode 100644 index 0000000..83e4f0f --- /dev/null +++ b/src/Models/Token.php @@ -0,0 +1,20 @@ +key = $key; + $this->token = $token; + } + + public function token_hidden(): string + { + return '...'.substr($this->token,-5); + } +} \ No newline at end of file diff --git a/src/Response/Base.php b/src/Response/Base.php new file mode 100644 index 0000000..e268c49 --- /dev/null +++ b/src/Response/Base.php @@ -0,0 +1,60 @@ +_data = $response; + + // This is only for child classes + if (get_class($this) == Base::class) { + Log::debug(sprintf('%s:Trello RESPONSE Initialised [%s]',static::LOGKEY,get_class($this)),['m'=>__METHOD__]); + + if (App::environment() == 'dev') + file_put_contents('/tmp/response',print_r($this,TRUE),FILE_APPEND); + } + } + + /** + * Enable getting values for keys in the response + * + * @note: This method is limited to certain values to ensure integrity reasons + * @note: Classes should return: + * + channel_id, + * + team_id, + * + ts, + * + user_id + */ + public function __get($key) + { + switch ($key) { + case 'id': + return object_get($this->_data,$key); + } + } + + /** + * When we json_encode this object, this is the data that will be returned + */ + public function jsonSerialize() + { + return $this->_data ? $this->_data : new \stdClass; + } +} \ No newline at end of file diff --git a/src/Response/Generic.php b/src/Response/Generic.php new file mode 100644 index 0000000..52a9e08 --- /dev/null +++ b/src/Response/Generic.php @@ -0,0 +1,11 @@ +