Last active
August 29, 2015 14:10
-
-
Save blindman2k/5a416e76547e937bb9c5 to your computer and use it in GitHub Desktop.
Meeting minder using the latest Google Calendar API for PHP.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//------------------------------------------------------------------------------------------------ | |
const CALENDAR_URL = "http://devious-dorris.gopagoda.com/meeting_minder"; | |
const REFRESH_TIME = 60; // Once a minute | |
SESSION_TOKEN <- "token" in server.load() ? server.load().token : ""; | |
// server.log(SESSION_TOKEN); | |
function check_calendar(reschedule = true) { | |
local headers = {}; | |
headers["X-GCal-Session-Token"] <- SESSION_TOKEN; | |
headers["X-Imp-Agent"] <- http.agenturl(); | |
http.get(CALENDAR_URL + "/get", headers).sendasync(function (res) { | |
handle_response(res.body, reschedule); | |
}) | |
} | |
imp.wakeup(REFRESH_TIME - (time() % 60), check_calendar); | |
function handle_response(body, reschedule) { | |
local now = null; | |
local next = null; | |
try { | |
if (body == "") { | |
// Do nothing | |
} else if (body.find("uthenticat") != null) { | |
server.log("No longer authenticated. Click " + http.agenturl()); | |
device.send("display", "AUTH") | |
} else { | |
local json = http.jsondecode(body); | |
if (typeof json == "array") { | |
device.send("display", null) | |
} else if (typeof json == "table") { | |
now = ("now" in json) ? json.now : null; | |
next = ("next" in json) ? json.next : null; | |
device.send("display", {now=now, next=next}); | |
if ("now_desc" in json) server.log("Now: " + json.now_desc); | |
if ("next_desc" in json) server.log("Next: " + json.next_desc); | |
} | |
} | |
} catch (e) { | |
device.send("display", "AUTH") | |
if (body && body.len() > 0) server.log(body); | |
else server.error(e); | |
} | |
// Set the next wakeup, either after a fixed amount of time or on the next event, whichever is sooner | |
local next_wakeup = REFRESH_TIME - (time() % 60); | |
if (now != null && time() + next_wakeup > now) { | |
next_wakeup = now - time(); | |
} | |
if (reschedule) { | |
imp.wakeup(next_wakeup, check_calendar); | |
} | |
} | |
//------------------------------------------------------------------------------------------------ | |
device.on("command", function(command) { | |
server.log("Command '" + command + "' requested") | |
switch (command) { | |
case "ready": | |
check_calendar(false); | |
break; | |
case "end": | |
case "extend": | |
local headers = {}; | |
headers["X-GCal-Session-Token"] <- SESSION_TOKEN; | |
headers["X-Imp-Agent"] <- http.agenturl(); | |
http.get(CALENDAR_URL + "/" + command, headers).sendasync(function (res) { | |
if (res.statuscode == 200) { | |
handle_response(res.body, false); | |
} else { | |
device.send("error", command) | |
} | |
}); | |
break; | |
} | |
}) | |
//------------------------------------------------------------------------------------------------ | |
http.onrequest(function(req, res) { | |
switch (req.path) { | |
case "/": | |
res.header("Location", CALENDAR_URL + "/login?agent=" + http.agenturl()); | |
res.send(302, "Redirect"); | |
break; | |
case "/token": | |
// Might be worth adding an API key here for basic security. | |
SESSION_TOKEN = req.body; | |
server.save({"token":SESSION_TOKEN}) | |
res.send(200, "OK"); | |
server.log(SESSION_TOKEN ? ("New token received: " + SESSION_TOKEN) : "Token cleared"); | |
check_calendar(false); | |
return; | |
case "/sms": | |
// This is a Twilio HTTP GET request | |
res.header("Content-Type", "text/xml"); | |
local response = @"<?xml version=""1.0"" encoding=""UTF-8""?> | |
<Response> | |
<Message>Your meeting has been scheduled.</Message> | |
</Response>"; | |
res.send(200, response); | |
break; | |
default: | |
res.send(404, "Not found"); | |
return; | |
} | |
}) | |
//------------------------------------------------------------------------------------------------ | |
server.log("Agent booted") | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Holtek HT16K33 LED Controller Driver | |
// for Adafruit 7-segment i2c backpack | |
//------------------------------------------------------------------------------------------------ | |
class SevenSeg { | |
// Pixel layout | |
// 0x01 = TOP CENTER | |
// 0x02 = TOP RIGHT | |
// 0x04 = BOTTOM RIGHT | |
// 0x08 = BOTTOM CENTER | |
// 0x10 = BOTTOM LEFT | |
// 0x20 = TOP LEFT | |
// 0x40 = CENTER | |
static colon = 0x02; // : | |
static blank = 0x00; // _ | |
static number = [ 0x3F, // 0 | |
0x06, // 1 | |
0x5B, // 2 | |
0x4F, // 3 | |
0x66, // 4 | |
0x6D, // 5 | |
0x7D, // 6 | |
0x07, // 7 | |
0x7F, // 8 | |
0x6F, // 9 | |
0x00]; // 10 = null | |
static letter = { A = 0xF7, | |
u = 0x1C, | |
t = 0x78, | |
h = 0x74, | |
E = 0x79, | |
K = 0x76, | |
O = 0x3f, | |
R = 0x77, | |
r = 0x50, | |
"-": 0x40}; | |
// Commands | |
static OSC_OFF = "\x20"; | |
static OSC_ON = "\x21"; | |
static DISP_OFF = "\x80"; | |
static DISP_ON = "\x81"; | |
static DISP_BLINK_2HZ = "\x83"; | |
static DISP_BLINK_1HZ = "\x85"; | |
static DISP_BLINK_05HZ = "\x87"; | |
//Preconfigured I2C device | |
i2c = null; | |
// 8-bit base address | |
baseAddr = null; | |
// Name | |
name = null; | |
// The current display | |
when = null; | |
show_colon = true; | |
constructor(_i2c, _baseAddr, _name) { | |
this.i2c = _i2c; | |
this.baseAddr = _baseAddr; | |
this.name = _name; | |
write(OSC_ON); | |
write(DISP_ON); | |
setBrightness(0.6); | |
} | |
function write(str){ | |
local result = i2c.write(baseAddr, str.tostring()); | |
//server.log("Result ("+baseAddr+"): "+result); | |
} | |
//Float from 0 to 1.0 where 1.0 is max brightness | |
function setBrightness(b){ | |
if(b < 0){ b = 0.0;} | |
if(b > 1){ b = 1.0;} | |
write( (0xE0 | (b*15.0).tointeger()).tochar() ); | |
} | |
function formatTimeDiff(_time, roundup = true) { | |
local time = {}; | |
time.diff <- math.abs(::time() - _time); | |
time.hour <- time.diff / 3600; | |
time.min <- ((time.diff + (roundup ? 59 : 0)) / 60) % 60; // Round up | |
// server.log(format("%s: The difference between now (%d) and then (%d) is %d sec (%d:%02d).", name, ::time(), _time, time.diff, time.hour, time.min)) | |
return time; | |
} | |
function displayClear (){ | |
//Write enough bits to clear all of memory | |
write("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"); | |
} | |
function displayText(msg) { | |
local str = blob(11); | |
for (local i = 0; i < msg.len() && i < 4; i++) { | |
local ch = msg[i]; | |
if (ch-'0' in number) { | |
str.writen(0x00, 'b'); | |
str.writen(number[ch-'0'], 'b'); | |
// server.log("Number: " + (ch-'0')) | |
} else if (ch.tochar() in letter) { | |
str.writen(0x00, 'b'); | |
str.writen(letter[ch.tochar()], 'b'); | |
// server.log("Letter: " + ch.tochar()) | |
} else { | |
str.writen(0x00, 'b'); | |
str.writen(blank, 'b'); | |
// server.log("Blank: " + ch) | |
} | |
// Blank out the colon | |
if (i == 1) { | |
str.writen(0x00, 'b'); | |
str.writen(blank, 'b'); | |
} | |
} | |
str.writen(0x00, 'b'); | |
write(str); | |
} | |
function displayTime(hours, mins) { | |
local time = blob(11); | |
time.writen(0x00, 'b'); | |
time.writen(number[(hours/10) == 0 ? 10 : (hours/10)], 'b'); | |
time.writen(0x00, 'b'); | |
time.writen(number[(hours%10)], 'b'); | |
time.writen(0x00, 'b'); | |
time.writen(show_colon ? colon : blank, 'b'); | |
time.writen(0x00, 'b'); | |
time.writen(number[(mins/10).tointeger()], 'b'); | |
time.writen(0x00, 'b'); | |
time.writen(number[(mins%10)], 'b'); | |
time.writen(0x00, 'b'); | |
write(time); | |
} | |
function update() { | |
if (typeof when == "string") { | |
displayText(when); | |
} else if (when == null || time() > when) { | |
displayClear(); | |
} else { | |
local time = formatTimeDiff(when); | |
displayTime(time.hour, time.min); | |
} | |
// show_colon = !show_colon; | |
} | |
} | |
//------------------------------------------------------------------------------------------------ | |
enableblinkup <- false; | |
busy <- false; | |
last_green_button <- 1; | |
last_red_button <- 1; | |
waiting_for_depress <- false; | |
function button_press() { | |
// Debounce and read | |
imp.sleep(0.01); | |
local green_button = grn_btn.read(); | |
local red_button = red_btn.read(); | |
// Handle both buttons being down - turn on blinkup | |
if (green_button == 0 && red_button == 0) { | |
grn.when = "----"; | |
red.when = "----"; | |
agent.send("command", "ready"); | |
// server.log("Enable blinkup") | |
waiting_for_depress = true; | |
if (!enableblinkup) { | |
imp.enableblinkup(true); | |
enableblinkup = true; | |
imp.wakeup(600, function() { | |
imp.enableblinkup(false); | |
enableblinkup = false; | |
}) | |
} | |
} | |
// Handle just the green button | |
else if (green_button == 1 && red_button == 1 && last_green_button == 0 && last_red_button == 1 && !waiting_for_depress && !busy) { | |
// server.log("Make a new 15 minute event or extend the current event by 15 minutes") | |
grn.when = "----"; | |
agent.send("command", "extend"); | |
} | |
// Handle just the red button | |
else if (green_button == 1 && red_button == 1 && last_green_button == 1 && last_red_button == 0 && !waiting_for_depress && !busy) { | |
// server.log("Close the current event") | |
grn.when = "----"; | |
agent.send("command", "end"); | |
} | |
// All the buttons are up, restart the process | |
else if (green_button == 1 && red_button == 1 && waiting_for_depress) { | |
// server.log("Buttons reset to depressed positions") | |
waiting_for_depress = false; | |
} | |
// Record the new value of the buttons | |
last_green_button <- green_button; | |
last_red_button <- red_button; | |
// Make sure we don't get more events for a bit | |
if (!busy) { | |
busy = true; | |
imp.wakeup(0.1, function() { | |
busy = false; | |
}) | |
} | |
} | |
//------------------------------------------------------------------------------------------------ | |
server.log("Device booted"); | |
imp.enableblinkup(false); | |
hardware.i2c12.configure(CLOCK_SPEED_100_KHZ); | |
grn <- SevenSeg(hardware.i2c12, 0xE0, "Green"); | |
red <- SevenSeg(hardware.i2c12, 0xE2, "Red"); | |
grn_btn <- hardware.pin5; | |
red_btn <- hardware.pin7; | |
grn_btn.configure(DIGITAL_IN_PULLUP, button_press); | |
red_btn.configure(DIGITAL_IN_PULLUP, button_press); | |
//------------------------------------------------------------------------------------------------ | |
function update() { | |
imp.wakeup(1, update); | |
grn.update(); | |
red.update(); | |
} | |
update(); | |
//------------------------------------------------------------------------------------------------ | |
agent.on("display", function (clocks) { | |
if (clocks == null) { | |
grn.when = null; | |
red.when = null; | |
} else if (clocks == "AUTH") { | |
grn.when = "Auth"; | |
red.when = "Err"; | |
} else { | |
grn.when = ("now" in clocks) ? clocks.now : null; | |
red.when = ("next" in clocks) ? clocks.next : null; | |
} | |
}) | |
agent.on("error", function (command) { | |
if (command == "extend") { | |
grn.when = "ERR!"; | |
} else if (command == "end") { | |
red.when = "ERR!"; | |
} | |
}); | |
agent.send("command", "ready"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php if ( ! defined('BASEPATH')) exit('No direct script access allowed'); | |
// This is a vanilla CodeIgniter installation with the addition of the google API below. | |
require_once __DIR__ . '/../libraries/google-api-php-client/autoload.php'; | |
/* | |
* Create a Google project (https://console.developers.google.com/project) and give it access to the Calendar API. | |
* Finally get a Client ID for a Web application. | |
* You will need to put in the correct Redirect URI's for development and production. | |
* eg. https://devious-dorris.gopagoda.com/meeting_minder/oauth2callback | |
* You will also need the correct Javascript origins. | |
* eg. https://devious-dorris.gopagoda.com | |
*/ | |
define('G_CLIENT_ID', ''); | |
define('G_SECRET', ''); | |
class Meeting_Minder extends CI_Controller { | |
var $client = null; | |
var $gcal = null; | |
var $agenturl = null; | |
var $access_token = null; | |
//--------------------------------------------------------------------------------------------------------- | |
function __construct() { | |
parent::__construct(); | |
$this->load->library('session'); | |
$this->client = new Google_Client(); | |
$this->client->setApplicationName("Meeting Minder"); | |
$this->client->setClientId(G_CLIENT_ID); | |
$this->client->setClientSecret(G_SECRET); | |
$this->client->setRedirectUri(base_url("meeting_minder/oauth2callback")); | |
$this->client->setAccessType('offline'); | |
$this->client->setApprovalPrompt('force'); | |
$this->client->setScopes(array( | |
// 'https://www.googleapis.com/auth/plus.me', // These three are required for getting more details about the user | |
// 'https://www.googleapis.com/auth/userinfo.email', // Such as their name and email address. | |
// 'https://www.googleapis.com/auth/userinfo.profile', | |
'https://www.googleapis.com/auth/calendar', | |
'https://www.googleapis.com/auth/calendar.readonly' | |
)); | |
$this->gcal = new Google_Service_Calendar($this->client); | |
if (isset($_SERVER['HTTP_X_GCAL_SESSION_TOKEN'])) { | |
$this->access_token = $_SERVER['HTTP_X_GCAL_SESSION_TOKEN']; | |
} elseif ($this->session->userdata('token')) { | |
$this->access_token = $this->session->userdata('token'); | |
} | |
if ($this->access_token) { | |
$this->session->set_userdata(array("token" => $this->access_token)); | |
$this->client->setAccessToken($this->access_token); | |
} | |
if (isset($_SERVER['HTTP_X_IMP_AGENT'])) { | |
$this->agenturl = $_SERVER['HTTP_X_IMP_AGENT']; | |
} elseif ($this->input->get("agent")) { | |
$this->agenturl = $this->input->get("agent"); | |
} else { | |
$this->agenturl = $this->session->userdata("agent"); | |
} | |
if ($this->agenturl) { | |
$this->session->set_userdata(array("agent" => $this->agenturl)); | |
} | |
// Some stuff I worked out that may come in handy one day. | |
// Get info on the current logged in user | |
// $oauth2 = new Google_Service_Oauth2($this->client); | |
// $userinfo = $oauth2->userinfo->get(); | |
// Get the list of calendars | |
// $cals = $this->gcal->calendarList->listCalendarList(); | |
// Get the primary calendar | |
// $cal = $this->gcal->calendars->get("primary"); | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function index() | |
{ | |
echo "OK"; | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function oauth2callback() | |
{ | |
if ($this->input->get('code')) { | |
$this->client->authenticate($this->input->get('code')); | |
$this->access_token = $this->client->getAccessToken(); | |
$this->session->set_userdata(array("token" => $this->access_token)); | |
redirect($this->session->userdata('original')); | |
} | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function login() | |
{ | |
if (!$this->auth("login")) return null; | |
// Post the token back to the agent | |
if ($this->agenturl && $this->access_token) { | |
$result = $this->postTokenToAgent(); | |
if ($result == TRUE) { | |
echo "Authorised. You can close this window.<br/>To logout click <a href='logout'>here</a>."; | |
} else { | |
header("Content-Type: text/plain"); | |
print_r($result); | |
} | |
} else { | |
echo "Authorised but couldn't send to agent. You can close this window.<br/>To logout click <a href='logout'>here</a>."; | |
} | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function logout() | |
{ | |
$this->session->unset_userdata('token'); | |
$this->session->unset_userdata('agent'); | |
if ($this->agenturl) { | |
$result = $this->postTokenToAgent(true); | |
if ($result == TRUE) { | |
echo "You are logged out. You can close this window."; | |
} else { | |
print_r($result); | |
} | |
} else { | |
echo "You are logged out but couldn't send to agent. You can close this window."; | |
} | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function get() | |
{ | |
if (!$this->auth("get")) return null; | |
// Work out the now and next | |
$result = array(); | |
try { | |
$nownext = $this->getNowAndNext(); | |
if (isset($nownext['now'])) { | |
$result['now'] = $nownext['now']->endTime; | |
} | |
if (isset($nownext['next'])) { | |
$result['next'] = $nownext['next']->startTime; | |
} | |
} catch (Google_Service_Exception $e) { | |
$errs = $e->getErrors(); | |
// print_r($errs[0]); | |
$result['exception'] = $errs[0]["message"]; | |
} | |
header("Content-Type: application/json"); | |
echo json_encode($result); | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function end() | |
{ | |
if (!$this->auth("end")) return null; | |
// Work out the now and next | |
$result = array(); | |
try { | |
$nownext = $this->getNowAndNext(); | |
if (isset($nownext['now'])) { | |
// Set a new end time | |
$starttime = strtotime($nownext['now']->event->getStart()->getDateTime()); | |
if ($starttime > time()-100) $result['now'] = $starttime; | |
else $result['now'] = time()-100; | |
$nownext['now']->event->getEnd()->setDateTime(gmdate("Y-m-d\TH:i:s-00:00", $result['now'])); | |
$this->gcal->events->update("primary", $nownext['now']->event->getId(), $nownext['now']->event); | |
} | |
if (isset($nownext['next'])) { | |
$result['next'] = $nownext['next']->startTime; | |
} | |
} catch (Google_Service_Exception $e) { | |
$errs = $e->getErrors(); | |
// print_r($errs[0]); | |
$result['exception'] = $errs[0]["message"]; | |
} | |
header("Content-Type: application/json"); | |
echo json_encode($result); | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
public function extend() { | |
if (!$this->auth("extend")) return null; | |
// Work out the now and next | |
$result = array(); | |
try { | |
$nownext = $this->getNowAndNext(); | |
$command = "none"; | |
if (isset($nownext['now'])) { | |
$result['now'] = $nownext['now']->endTime + 900; | |
$command = "updateEventEnd"; | |
} else { | |
$result['now'] = time() + 900; | |
$command = "createEvent"; | |
} | |
// Limit the extension to the next meeting start time | |
if (isset($nownext['next'])) { | |
$result['next'] = $nownext['next']->startTime; | |
if ($result['now'] > $result['next']) { | |
$result['now'] = $result['next']; | |
} | |
} | |
if ($command == "updateEventEnd") { | |
$nownext['now']->event->getEnd()->setDateTime(gmdate("Y-m-d\TH:i:s-00:00", $result['now'])); | |
$this->gcal->events->update("primary", $nownext['now']->event->getId(), $nownext['now']->event); | |
} elseif ($command == "createEvent") { | |
$event = new Google_Service_Calendar_Event(); | |
$event->setSummary('Meeting added by Meeting Minder'); | |
$start = new Google_Service_Calendar_EventDateTime(); | |
$start->setDateTime(gmdate("Y-m-d\TH:i:s-00:00")); | |
$event->setStart($start); | |
$end = new Google_Service_Calendar_EventDateTime(); | |
$end->setDateTime(gmdate("Y-m-d\TH:i:s-00:00", $result['now'])); | |
$event->setEnd($end); | |
$this->gcal->events->insert('primary', $event); | |
} | |
if (isset($nownext['next'])) { | |
$result['next'] = $nownext['next']->startTime; | |
} | |
} catch (Google_Service_Exception $e) { | |
$errs = $e->getErrors(); | |
$result['exception'] = $errs[0]["message"]; | |
} | |
header("Content-Type: application/json"); | |
echo json_encode($result); | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
protected function redirect_to_google($original_url) | |
{ | |
$this->session->set_userdata(array("original" => base_url("meeting_minder/$original_url"))); | |
$authUrl = $this->client->createAuthUrl(); | |
echo "Click <a class='login' href='$authUrl'>here</a> to authenticate."; | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
protected function auth($original_url) | |
{ | |
if (!$this->client->getAccessToken()) { | |
// We don't have an access token, go get one. | |
$this->redirect_to_google($original_url); | |
return false; | |
} else if ($this->client->isAccessTokenExpired()) { | |
// We have a token but it has expired. | |
if ($this->client->getRefreshToken()) { | |
// We have a refresh token, let it refresh | |
return true; | |
} elseif ($original_url == "login") { | |
// Login should always go to google, not error out. | |
$this->redirect_to_google($original_url); | |
return false; | |
} else { | |
echo "The authentication token has expired. Please login to get a new one."; | |
return false; | |
} | |
} else { | |
// All good | |
return true; | |
} | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
protected function postTokenToAgent($erase = false) | |
{ | |
$ch = curl_init($this->agenturl . "/token"); | |
curl_setopt($ch, CURLOPT_HEADER, 0); | |
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | |
curl_setopt($ch, CURLOPT_POST, 1); | |
curl_setopt($ch, CURLOPT_POSTFIELDS, $erase ? "" : $this->access_token); | |
curl_setopt($ch, CURLOPT_FAILONERROR, 1); | |
$result = curl_exec($ch); | |
$info = curl_getinfo($ch); | |
$error = curl_error($ch); | |
curl_close($ch); | |
return $result == FALSE ? $error : TRUE; | |
} | |
//--------------------------------------------------------------------------------------------------------- | |
protected function getNowAndNext() | |
{ | |
$from = gmdate("Y-m-d\TH:i:s-00:00", strtotime("-1 day")); | |
$to = gmdate("Y-m-d\TH:i:s-00:00", strtotime("+1 day")); | |
$opts = array("orderBy" => "startTime", "singleEvents" => true, "timeMax" => $to, "timeMin" => $from); | |
$events = $this->gcal->events->listEvents("primary", $opts)->getItems(); | |
$results = array(); | |
$now = time(); | |
foreach ($events as $event) { | |
$starttime = strtotime($event->getStart()->getDateTime()); | |
$endtime = strtotime($event->getEnd()->getDateTime()); | |
$midnight = strtotime("23:59:59"); | |
if ($starttime <= $now && $now < $endtime) { | |
// We are in a meeting now | |
$results['now'] = (object) array("event" => $event, "startTime" => $starttime, "endTime" => $endtime); | |
} else if ($now < $starttime && $starttime <= $midnight) { | |
// We have a meeting later today | |
if (!isset($results['next']) || $starttime < $results['next']->startTime) { | |
$results['next'] = (object) array("event" => $event, "startTime" => $starttime, "endTime" => $endtime); | |
} | |
} | |
} | |
return $results; | |
} | |
} | |
/* End of file meeting_minder.php */ | |
/* Location: ./application/controllers/meeting_minder.php */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment