Skip to content

Instantly share code, notes, and snippets.

@jthurteau
Last active August 22, 2016 02:24
Show Gist options
  • Save jthurteau/587ef87b81e1491f64c2736c24b68068 to your computer and use it in GitHub Desktop.
Save jthurteau/587ef87b81e1491f64c2736c24b68068 to your computer and use it in GitHub Desktop.
pattern examples
// example of how this would get called:
// during bootstrap:
// configuration specifies a "Messenger" resource with property enabled as true/false
class Bootstrap {
protected function _initMessenger($config){
Messenger_Root::init($config); // values from config are strings, hence the Saf_Filter_Truthy::filter call in ::init
}
}
// ...
// also during boot strap, used to keep the numbers used in auto-generating sequence values lower
Saf_Ical::setBaseTime(Ems::getMinTimestamp());
// ...
// when sending a message with ical data
Messenger_Root::get()->sendIcal($reservationCode, $username,
'NCSU Libraries Room Reservation, [[$room]] for [[$date]] [[$year]]' ,
'This message is to confirm your reservation for:
[[$location]]
at [[$userStart]] - [[$userEnd]]
on [[$date]] [[$year]]
If you need to modify or delete this reservation,
please return to the room reservation system:
[[$url]]
A calendar invitation is attached to this email, which you can add to your Google Calendar and share with others.
'
);
/*
* Encapsulates our business logic behind using the EMS System, Proxy and Facade to the API
*/
class Ems {
public static function getBookingInfo($bookingId, $username)
{
$userId = self::getUserId($username);
$booking = self::$_api->getBooking($bookingId);
if ($booking) {
$filteredBookings = self::tidyBookings(array($bookingId => $booking));
return $filteredBookings[$bookingId];
} else {
return NULL;
}
}
public static function tidyBookings($bookings, $sortOrder = NULL)
{
foreach($bookings as $bookingId => $bookingData) {
if (!array_key_exists('code', $bookings[$bookingId])) {//#NOTE try to avoid filtering twice
$bookings[$bookingId]['code'] = "{$bookingData['reservationId']}/{$bookingId}";
$bookings[$bookingId]['date'] =
Saf_Time::modify(
strtotime($bookings[$bookingId]['date']),
Saf_Time::MODIFIER_START_DAY
);
$bookings[$bookingId]['originalStart'] = $bookings[$bookingId]['start'];
$bookings[$bookingId]['originalEnd'] = $bookings[$bookingId]['end'];
$bookings[$bookingId]['dateString'] =
date(Ems::EMS_DATE_FORMAT, $bookings[$bookingId]['date']);
$bookings[$bookingId]['userDate'] =
date(Ems::EMS_USER_DATECALENDAR_FORMAT, $bookings[$bookingId]['date']);
$bookings[$bookingId]['userYear'] =
date('Y', $bookings[$bookingId]['date']);
$bookings[$bookingId]['userMonth'] =
date('M', $bookings[$bookingId]['date']);
$bookings[$bookingId]['userDay'] =
date('j', $bookings[$bookingId]['date']);
$bookings[$bookingId]['startString'] =
substr(
$bookings[$bookingId]['start'],
strpos($bookings[$bookingId]['start'], 'T') + 1
);
$bookings[$bookingId]['fullStart'] =
strtotime($bookings[$bookingId]['start']);
$bookings[$bookingId]['userStart'] = date(
Ems::EMS_USER_TIME_FORMAT,
$bookings[$bookingId]['fullStart']
);
$bookings[$bookingId]['start'] =
Saf_Time::hourStringToStamp($bookings[$bookingId]['start']);
$bookings[$bookingId]['endString'] =
substr(
$bookings[$bookingId]['end'],
strpos($bookings[$bookingId]['end'], 'T') + 1
);
$bookings[$bookingId]['fullEnd'] =
strtotime($bookings[$bookingId]['end']);
$bookings[$bookingId]['userEnd'] = date(
Ems::EMS_USER_TIME_FORMAT,
$bookings[$bookingId]['fullEnd'] + 1
);
$bookings[$bookingId]['truncatedEnd'] =
Saf_Time::hourStringToStamp($bookings[$bookingId]['end']);
$bookings[$bookingId]['crossesMidnight'] =
$bookings[$bookingId]['fullEnd'] - $bookings[$bookingId]['date']
> Saf_Time::MAX_HOUR_STAMP;
$bookings[$bookingId]['end'] =
$bookings[$bookingId]['crossesMidnight']
? $bookings[$bookingId]['truncatedEnd'] + Saf_Time::MAX_HOUR_STAMP + 1
: $bookings[$bookingId]['truncatedEnd'];
$bookings[$bookingId]['userEventType'] =
self::getUserEventTypeLabel($bookings[$bookingId]['eventType']);
$bookings[$bookingId]['userStatus'] =
self::getUserStatusLabel($bookings[$bookingId]['status']);
$bookings[$bookingId]['userRoom'] =
self::getUserRoomLabel($bookings[$bookingId]['roomId']);
$bookings[$bookingId]['userLocation'] =
self::getUserLocationLabel($bookings[$bookingId]['roomId']);
$bookings[$bookingId]['userFloor'] =
self::getRoomDetails($bookings[$bookingId]['roomId'], 'floor');
$bookings[$bookingId]['userBuilding'] =
self::getUserBuildingLabel($bookings[$bookingId]['buildingId']);
$bookings[$bookingId]['roomNumber'] =
self::getRoomNumber($bookings[$bookingId]['roomId']);
$bookings[$bookingId]['roomType'] =
self::getRoomDetails($bookings[$bookingId]['roomId'], 'type');
$bookings[$bookingId]['roomCapacity'] =
self::getRoomDetails($bookings[$bookingId]['roomId'], 'capacity');
$bookings[$bookingId]['userEmail'] = 'not loaded';
$bookings[$bookingId]['userId'] = 'not loaded';
$bookings[$bookingId]['userSortName'] = 'not loaded';
$bookings[$bookingId]['username'] = 'not loaded';
$bookings[$bookingId]['userLastname'] = 'not loaded';
$bookings[$bookingId]['userFirstname'] = 'not loaded';
$bookings[$bookingId]['userFullname'] = 'not loaded';
}
}
return
is_null($sortOrder)
? $bookings
: self::chronoSortBookings($bookings, $sortOrder);
}
}
/*
* Implements interface to the EMS API and mediates requests to optimize performance between API calls and cache.
*/
class Ems_Api {
public function getBooking($bookingId)
{
$params = array('bookingId' => $bookingId);
$responseKeys = array(
'date' => 'BookingDate',
'start' => 'TimeEventStart',
'end' => 'TimeEventEnd',
'groupId' => 'GroupID',
'description' => 'EventName',
'reservationId' => 'ReservationID',
'eventType' => 'EventTypeID',
'status' => 'StatusID',
'statusType' => 'StatusTypeID',
'roomId' => 'RoomID',
'buildingId' => 'BuildingID',
'templateId' => 'WebProcessTemplateID'
);
return $this->_simpleSend('getBooking', $responseKeys, self::API_KEY_SINGLETON, $params);
}
private function _simpleSend($call, $keys = NULL, $idKey = self::API_KEY_DEFAULT, $params = NULL)
{
if (is_null($idKey)) {
$idKey = self::API_KEY_DEFAULT;
}
$currentKey =
$idKey === self::API_KEY_INTERATIVE
|| $idKey === self::API_KEY_SINGLETON
? 0
: $idKey;
$response = array();
$message = new Saf_Message_Template($call);
$messageParams = (is_null($params))? array(): $params;
$messageParams['auth'] = $this->getAuthString();
$dereferenceConfig = array(
'params' => $messageParams
);
$dereferencedMessage = $message->get($dereferenceConfig);
$rawResponse = $this->send($dereferencedMessage);
Saf_Profile::action('API', $call, array('size' => $rawResponse['length']));
$finalPattern =
$idKey === self::API_KEY_SINGLETON
? self::API_PARSE_PATTERN_SINGLETON
: self::API_PARSE_PATTERN_NONE;
$payload = $this->parseResponse($rawResponse, $finalPattern);
if ($payload) {
if ($finalPattern == self::API_PARSE_PATTERN_SINGLETON) {
$payload = array($payload);
}
foreach ($payload as $item) {
if (
(
is_array($item)
|| !$keys
) && (
$idKey === self::API_KEY_SINGLETON
|| $idKey === self::API_KEY_INTERATIVE
|| array_key_exists($currentKey, $item)
)
) {
$itemKey =
$idKey === self::API_KEY_SINGLETON
|| $idKey === self::API_KEY_INTERATIVE
? $currentKey
: $item[$currentKey];
$response[$itemKey] = $keys ? array() : $item;
if ($keys) {
foreach($keys as $keyTransform => $key){
$targetKey = is_numeric($keyTransform) ? $key : $keyTransform;
$response[$itemKey][$targetKey] =
array_key_exists($key, $item)
? $item[$key]
: NULL;
}
}
if ($idKey === self::API_KEY_INTERATIVE) {
$currentKey++;
}
}
}
}
return
$finalPattern == self::API_PARSE_PATTERN_SINGLETON
? (array_key_exists(0, $response) ? $response[0] : NULL)
: $response;
}
}
/*
* Model object that knows how to generate a simple representation (memento) of an event
*/
class Event_Model {
public static function get($identifier, $parameters = array())
{
$codeParts = explode('/',$identifier, 2);
if ($codeParts && count($codeParts) == 2) {
$bookingId = $codeParts[1];
$username =
array_key_exists($parameters, $parameters)
? $parameters['username']
: NULL;
$reservation = Ems::getBookingInfo($bookingId, $username);
if (!$reservation) {
throw new Ems_Exception_Reservation_404("Unable to load booking {$bookingId}");
}
$screened = Ems::screenBookings(
array($bookingId => $reservation),
$username
);
$reservation = $screened[$bookingId];
}
return $reservation;
}
}
/*
* Class to build an iCal document from an map of data representing an event.
* This is used for both generating e-mail attachments, and documents served as
* a web service.
*/
class Saf_Ical {
const METHOD_REQUEST = 'Request';
const METHOD_PUBLISH = 'Publish';
const METHOD_CANCEL = 'Cancel';
const OUTPUT_COL_MAX = 70;
const OUTPUT_ESCAPE_CHARS = ':;,\\';
const OUTPUT_REMOVE_CHARS = "";
const OUTPUT_DATESTAMP = 'Ymd\THis\Z';
const AUTO_SEQUENCE = '*';
const MIME_TYPE = 'text/calendar';
protected $_ical = '';
protected $_attachment = '';
protected $_baseTime = NULL;
protected $_timeInterval = 60;
protected $_method = self::METHOD_REQUEST;
protected $_id = NULL;
protected $_filename = 'event.ics';
protected $_hostId = '';
protected $_productId = '';
protected $_lang = 'EN';
protected $_version = '2.0';
protected static $_systemBaseTime = 0;
protected static $_itemProps = array(
'VEVENT' => array(
'UID' => 1,
'ORGANIZER' => '?',
'ATTENDEE[]' => '*',
'DTSTART' => 1,
'DTEND' => 1,
'DTSTAMP' => 1,
'LOCATION' => '?',
'SEQUENCE' => '?',
'LAST-MODIFIED' => '?',
'SUMMARY' => 1,
'DESCRIPTION' => '?',
'URL' => '?',
'STATUS' => '?',
'CATEGORIES' => '?'
)
);
protected static $_propHelperMap = array(
'fullStart' => 'DTSTART',
'start' => 'DTSTART',
'fullEnd' => 'DTEND',
'end' => 'DTEND',
'now' => 'DTSTAMP',
'modified' => 'LAST-MODIFIED',
'title' => 'SUMMARY',
'sequence' => 'SEQUENCE',
'name' => 'SUMMARY',
'description' => 'SUMMARY',
'userEmail' => 'ATTENDEE',
'attendeeEmail' => 'ATTENDEE',
'ownerEmail' => 'ORGANIZER',
'userLocation' => 'LOCATION',
);
public function __construct($config = array())
{
$this->_hostId = Saf_Array::extract('hostId', $config, APPLICATION_ENV . '.' . APPLICATION_HOST);
$this->_productId = Saf_Array::extract('productId', $config, APPLICATION_ID . '.' . APPLICATION_INSTANCE);
$this->_baseTime = (int)Saf_Array::extract('baseTime', $config, self::$_systemBaseTime);
$this->_timeInterval = (int)Saf_Array::extract('timeInterval', $config, $this->_timeInterval);
$this->_method = Saf_Array::extractOptional('method', $config);
$this->_id = Saf_Array::extractOptional('id', $config);
switch ($this->_method) {
case self::METHOD_REQUEST:
$this->setMethodRequest();
break;
case self::METHOD_PUBLISH:
$this->setMethodPublish();
break;
case self::METHOD_CANCEL:
$this->setMethodCancel();
break;
}
if (!is_null($this->_id) && (!is_array($this->_id) || count($this->_id) > 0)) {
$this->generate($config);
}
}
public function __toString()
{
return $this->getIcal();
}
public function setMethodRequest()
{
$this->_method = self::METHOD_REQUEST;
}
public function setMethodPublish()
{
$this->_method = self::METHOD_PUBLISH;
}
public function setMethodCancel()
{
$this->_method = self::METHOD_PUBLISH;
}
public function generate($data)
{
if(is_array($this->_id)) {
$this->_generateMulti($data);
} else {
$this->_generate($data);
}
}
public function getIcal()
{
return $this->_ical;
}
public function getAttachment()
{
return $this->_attachment;
}
protected function _getHeader()
{
$method = strtoupper($this->_method);
$product = self::escapeText("{$this->_productId}//{$this->_hostId}");
$url = '';
return "BEGIN:VCALENDAR\n"
. "METHOD:{$method}\n"
. "PRODID:-//{$product}//{$this->_lang}\n"
. "VERSION:{$this->_version}\n"
. $url;
}
protected function _getFooter()
{
return "END:VCALENDAR";
}
protected function _generate($data, $id = NULL)
{
$type = 'VEVENT'; //#TODO #2.0.0 support other types
$itemData = $data;
$this->_ical =
(is_null($id) ? $this->_getHeader() : '')
. "BEGIN:{$type}\n"
. $this->_renderItem($type, is_null($id) ? $this->_id : $id , $itemData)
. "END:{$type}\n"
. (is_null($id) ? $this->_getFooter() : '');
$this->_generateAttachment();
}
protected function _generateMulti($data)
{
$items = array();
foreach($this->_id as $id => $item) {
$items[] = $this->_generate($item, $id);
}
$this->_ical =
$this->_getHeader()
. implode("\n", $items)
. $this->_getFooter();
$this->_generateAttachment();
}
protected function _renderItem($type, $id, $data)
{
$out = '';
$outType = strtolower(substr($type, 1));
$outProp = "$prop:";
$dateStamp = gmdate(self::OUTPUT_DATESTAMP, Saf_Time::time());
$version = $this->getVersion($data);
$atProps = 'CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE'; //or just ROLE=REQ-PARTICIPANT;
$uid = "{$outType}{$id}@{$this->_hostId}";
//#TODO #2.0.0 see what more advanced attendee properties we can support...
foreach(self::$_itemProps[$type] as $prop => $card) {
$value = NULL;
$propKey = array_key_exists($prop, $data)
? $prop
: $this->_resolvePropKey($prop, $data);
$outProp = "$prop:";
switch ($prop){
case 'ORGANIZER':
case 'ATTENDEE':
$attendeeMail =
$propKey
&& array_key_exists($propKey, $data)
? $data[$propKey]
: NULL;
$cnSource =
array_key_exists('USER:CN', $data)
? $data['USER:CN']
: (
array_key_exists('userFullname', $data)
? $data['userFullname']
: NULL
);
if (is_array($attendeeMail)) {
foreach($attendeeMail as $email) {
$outProp = array();
if (
is_array($cnSource) && array_key_exists($email, $cnSource)
) {
$cn = ";CN={$cnSource[$email]}";
} else {
$cn = '';
}
$outProp = 'ATTENDEE;';
$value[] = "{$atProps}{$cn}:MAILTO:{$email}";
}
} else if (!is_null($attendeeMail)) {
$attendeeCn =
is_array($cnSource)
? ''
: ";CN={$cnSource}";
$outProp = 'ATTENDEE;';
$value = "{$atProps}{$attendeeCn}:MAILTO:{$attendeeMail}";
}
break;
case 'DTSTAMP':
case 'DTSTART':
case 'DTEND':
case 'LAST-MODIFIED':
$value =
$propKey
&& array_key_exists($propKey, $data)
? $data[$propKey]
: NULL;
if (!is_null($value)) {
if (Saf_Time::isTimeStamp($value)) {
$value = gmdate(self::OUTPUT_DATESTAMP, $value);
} else {
//#TODO #2.0.0 detect non-GMT and convert
}
} else if ($prop == 'DTSTAMP') {
$value = gmdate(self::OUTPUT_DATESTAMP, Saf_Time::time());
}
break;
case 'UID':
$value = $uid;
break;
case 'SEQUENCE':
if (
$propKey
&& array_key_exists($propKey, $data)
&& $data[$propKey] == '*'
) {
$value = Saf_Time::time() - $this->_baseTime;
} else if (
$propKey
&& array_key_exists($propKey, $data)
) {
$value = (int)$data[$propKey];
}
break;
case 'STATUS':
switch (strtoupper($this->_method)) {
case 'CANCEL' :
$value =
$propKey
&& array_key_exists($propKey, $data)
? strtoupper($data[$propKey])
: NULL;
break;
case 'PUBLISH' :
case 'REQUEST' :
default:
$value =
$propKey
&& array_key_exists($propKey, $data)
? strtoupper($data[$propKey])
: 'CONFIRMED';
}
break;
default:
if ($propKey && array_key_exists($propKey, $data)) {
$value = self::escapeText($data[$propKey]);
}
}
if ($card === '+' || $card === 1) {
if (is_null($value)) {
throw new Exception("Required value {$prop} missing to generate iCal for {$uid}");
}
} else if ($card === '?' || $card === 1) {
if (is_array($value)) {
throw new Exception("Too many values provided for {$prop} to generate iCal for {$uid}");
}
}
if (is_array($value) && count($value) > 0) {
foreach($value as $subValue) {
$out .= "{$outProp}{$subValue}\n";
}
} else if (!is_array($value) && !is_null($value)) {
$out .= "{$outProp}{$value}\n";
}
}
return $out;
}
protected function _resolvePropKey($key, $data)
{
foreach(self::$_propHelperMap as $dataKey => $propKey){
if (
array_key_exists($dataKey, $data)
&& self::$_propHelperMap[$dataKey] == $key
) {
return $dataKey;
}
}
return NULL;
}
protected function _generateAttachment()
{
$this->_attachment = new Zend_Mime_Part($this->_ical);
$method = strtoupper($this->_method);
$this->_attachment->type = "text/calendar; method={$method}";
$this->_attachment->disposition = Zend_Mime::DISPOSITION_INLINE;
$this->_attachment->encoding = Zend_Mime::ENCODING_8BIT;
$this->_attachment->filename = $this->_filename;
}
public static function escapeText($string)
{
$removeChars = str_split(self::OUTPUT_REMOVE_CHARS);
$filtered = addcslashes(
str_replace($removeChars, '', $string),
self::OUTPUT_ESCAPE_CHARS
);
$lines = str_split($filtered, self::OUTPUT_COL_MAX);
if (strlen($lines[count($lines) - 1]) == 0) { //#TODO #2.0.0 research if this is needed
unset($lines[count($lines) - 1]);
}
return implode("\n ", $lines);
}
public function getVersion()
{
return $this->_version;
}
public function getTimedVersion()
{
$versionTime = time() - $this->_baseTime;
return (
$versionTime - ($versionTime % $this->_timeInterval)
) / $this->_timeInterval;
}
public static function setBaseTime($timestamp)
{
self::$_systemBaseTime = $timestamp;
}
}
/*
* Mediator class that can create and handle different message types.
*/
class Messenger
{
protected $_name = NULL;
protected $_address = NULL;
public __construct($config)
{
$name =
Saf_Array::keyExistsAndNotBlank('name', $config)
? trim($config['name'])
: NULL;
$this->setName($name);
$address =
Saf_Array::keyExistsAndNotBlank('address', $config)
? trim($config['address'])
: '';
$this->setAddress($address);
}
public function setName($name)
{
if ($name) {
$this->_name = $name;
return TRUE;
}
return FALSE;
public function setAddress($address)
{
if ($address && !Saf_Filter_Email::filter($config['address'])) {
throw new Exception('Invalid Sender Address');
} else if($address){
$this->_address = $address;
return TRUE;
}
return FALSE;
}
public static function sendIcal($event, $username, $subject, $message, $isUpdate = FALSE)
{
$this->_assertReady();
if (!is_array($event)) {
$reservationCode = $event;
$event = Saf_Model::convert($event, 'event', array('username' => $username));
if ($event['userEmail'] != "{$username}@ncsu.edu") {
throw new Ems_Exception_Reservation_403("Attempting to mail reservation {$reservationCode} to wrong user.");
}
}
$email = $event['userEmail'];
$name = $event['userFullname'];
if ($isUpdate && !array_key_exists('modified', $event) {
$event['modified'] = Saf_Time::time();
}
$icalEvent = array_merge(
$event,
array(
'method' => Saf_Ical::METHOD_REQUEST,
'id' => str_replace('/', '_', $event['code']),
'sequence' => Saf_Ical::AUTO_SEQUENCE
)
);
$ical = new Saf_Ical($icalEvent);
$url = Zend_Registry::get('siteUrl') . 'reservation';
$message = str_replace(
array(
'[[$location]]',
'[[$userStart]]',
'[[$userEnd]]',
'[[$date]]',
'[[$year]]',
'[[$url]]'
), array(
$event['userLocation'],
$event['userStart'],
$event['userEnd'],
$event['userDate'],
$event['userYear'],
$url
), $message
);
$subject = str_replace(
array(
'[[$room]]',
'[[$date]]',
'[[$year]]'
), array(
$event['userRoom'],
$event['userDate'],
$event['userYear']
), $subject
);
$this->sendMail(array($email, $name), $subject, $message, $ical->getAttachment());
return $this;
}
public function sendMail($to, $subject, $message, $attachment = NULL)
{
$this->_assertReady();
$mail = new Zend_Mail();
$mail->setBodyText($message);
$mail->setFrom($this->_name, $this->_address);
if (is_array($to)) {
$mail->addTo($to, $name);
} else {
$mail->addTo($to);
}
$mail->setSubject($subject);
if (!is_null($attachment)) {
$mail->addAttachment($attachment);
}
$mail->send();
return $this;
}
protected _assertReady()
{
if (!$this->_name || !$this->_address) {
throw new Exception("Attempting to send mail with no sender specified: \"{$this->_name}, {$this->_address}\"");
}
return $this;
}
}
/*
* Singleton for a root messenger that can be globally enabled or disabled (e.g. disabled on development).
* ::init() gets called during bootstrap, determining if the instance will send any messages or not.
*/
abstract class Messenger_Root
{
const NEWLINE = "\r\n";
protected static $_enabled = FALSE;
protected static $_instance = NULL;
public static function init($config)
{
if (!is_null(self::$_instance) {
return;
}
if (
$config && array_key_exists('enabled', $config)
&& !Saf_Filter_Truthy::filter($config['enabled'])
) {
self::$_enabled = TRUE;
Saf_Profiler::flag('+Messaging');
} else {
Saf_Profiler::flag('-Messaging');
return;
}
$rootMailSender =
Saf_Array::keyExistsAndNotBlank('name', $config)
? trim($config['name'])
: NULL;
if (!$rootMailSender) {
throw new Saf_Exception_Startup('No Messenger Sender Name');
}
$rootMailAddress =
Saf_Array::keyExistsAndNotBlank('address', $config)
? trim($config['address'])
: NULL;
if (!$rootMailAddress) {
throw new Saf_Exception_Startup('No Messenger Sender Address');
}
try{
self::$_instance = new Messenger($config);
} catch (Exception $e) {
if (!is_a($e, 'Saf_Exception_Startup') {
throw new Saf_Exception_Startup('Failed to create Messenger', 0, $e);
}
throw $e;
}
}
public static function get()
{
return self::$_instance;
}
public static function isEnabled()
{
return self::$_enabled;
}
}
/*
* class to decouple resolution to a model type from the generation of the memento
*/
Saf_Model {
public static function convert($identifier, $model, $parameters = array())
{
$className = Saf_Filter_ValidClass(ucfirst($model) . '_Model');
if ($className) {
$reflector = new ReflectionClass($className);
return $reflector->getMethod('get')->invokeArgs(NULL, array($identifier, $parameters));
} else {
throw new Saf_Exception_Reflection("Unable to resolve model: {$model}");
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment