Initial implementation

This commit is contained in:
Deon George 2022-03-21 15:39:52 +11:00
commit bcbde6359a
17 changed files with 890 additions and 0 deletions

21
composer.json Normal file
View File

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

294
src/API.php Normal file
View File

@ -0,0 +1,294 @@
<?php
namespace Trello;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Trello\Models\{Board,BoardList,Card,CustomField,Token};
use Trello\Models\CustomFields\{ListList,Options\Option};
use Trello\Response\Generic;
final class API
{
private const LOGKEY = 'API';
private const VERSION = 1;
private const CACHE_TIME = 600;
private const URL = 'https://api.trello.com';
// Our trello token to use
private $_token;
public function __construct(Token $o)
{
$this->_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'])));
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Trello\Exceptions;
class TrelloException extends \Exception
{
}

142
src/Models/Board.php Normal file
View File

@ -0,0 +1,142 @@
<?php
namespace Trello\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Trello\API;
use Trello\Models\CustomFields\ListList;
final class Board extends Model
{
protected $keyType = 'string';
protected const LOGKEY = 'MTB';
public Collection $customfields;
private API $api;
public ?BoardList $newopslist = NULL;
public const newopslist = 'New Ops';
/**
* Use the API to store/retrieve values
*
* @return API
* @todo This should be kept private, and we should call functions to retrieve the items we are interested in
*/
public function api(): API
{
return $this->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();
}
}

10
src/Models/BoardList.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace Trello\Models;
use Illuminate\Database\Eloquent\Model;
final class BoardList extends Model
{
protected $keyType = 'string';
}

58
src/Models/Card.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace Trello\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use App\Trello\Models\CustomFields\{Action,SalesStage};
final class Card extends Model
{
protected $keyType = 'string';
/**
* Get this card's custom field value.
*
* @param CustomField $o
* @return object|null
*/
public function getCustomFieldValue(CustomField $o): ?object
{
return collect($this->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;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Trello\Models;
use Trello\Models\CustomFields\{Base,Checkbox,Date,ListList,Number,Text};
abstract class CustomField extends Base
{
protected $keyType = 'string';
private const LOGKEY = 'TCF';
/**
* Resolve the data into a CustomField Object
*
* @param object $data
* @return mixed|Checkbox|Date|ListList|Number|Text
* @throws \Exception
*/
public static function factory(object $data) {
// See if the field name is one we have configured
if (collect(config('trello.customfields'))->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;
}

View File

@ -0,0 +1,28 @@
<?php
namespace Trello\Models\CustomFields;
abstract class Base
{
public object $attributes;
public const options = NULL;
public function __construct(object $attributes)
{
$this->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);
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Trello\Models\CustomFields;
use Trello\Models\CustomField;
class Checkbox extends CustomField
{
/* ABSTRACT METHODS */
public function set(string $value): array
{
// @todo TO TEST
return ['value'=>['checked'=>$value ? 'true' : 'false']];
}
public function value(object $key): ?string
{
// @todo TO TEST
return (string)$key->value->checked;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Trello\Models\CustomFields;
use Carbon\Carbon;
use Trello\Models\CustomField;
class Date extends CustomField
{
/* ABSTRACT METHODS */
public function set(string $value): array
{
// @todo TO TEST
return ['value'=>['date'=>Carbon::createFromTimeString($value)->toISOString()]];
}
public function value(object $key): ?string
{
// @todo TO TEST
return $key->value->date;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Trello\Models\CustomFields;
use Illuminate\Support\Collection;
use Trello\Models\CustomField;
use Trello\Models\CustomFields\Options\Option;
class ListList extends CustomField
{
public Collection $options;
public function __construct(object $attributes)
{
parent::__construct($attributes);
$this->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'));
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Trello\Models\CustomFields;
use Trello\Models\CustomField;
class Number extends CustomField
{
/* ABSTRACT METHODS */
public function set(string $value): array
{
return ['value'=>['number'=>$value]];
}
public function value(object $key): ?string
{
return (string)$key->value->number;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Trello\Models\CustomFields\Options;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
class Option extends Model
{
protected $keyType = 'string';
public function getNameAttribute(): ?string
{
return ($x=Arr::get($this->attributes,'value')) ? $x->text : NULL;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Trello\Models\CustomFields;
use Trello\Models\CustomField;
class Text extends CustomField
{
/* ABSTRACT METHODS */
public function set(string $value): array
{
return ['value'=>['text'=>$value]];
}
public function value(object $key): ?string
{
return $key->value->text;
}
}

20
src/Models/Token.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace Trello\Models;
final class Token
{
public string $key;
public string $token;
public function __construct(string $key,string $token)
{
$this->key = $key;
$this->token = $token;
}
public function token_hidden(): string
{
return '...'.substr($this->token,-5);
}
}

60
src/Response/Base.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace Trello\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
/**
* This parent class handles responses received from Trello
*
* @note: This class is used for events not specifically created.
*/
class Base implements \JsonSerializable
{
protected const LOGKEY = 'RB-';
/**
* Default Constructor Setup
*
* @param object $response
*/
public function __construct(array $response)
{
$this->_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;
}
}

11
src/Response/Generic.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Trello\Response;
/**
* This is a Generic Slack Response to API calls
*/
class Generic extends Base
{
protected const LOGKEY = 'RGE';
}