PDA

View Full Version : Calendar API Question



salesnav
11-Jul-2017, 03:28 PM
I am trying to figure out the correct format to create a calendar event. Once I get one thing working, I'll get the rest of it. I have

"https://www.googleapis.com/auth/calendar as the scope. The login appears, it gets authorized and I get a token. You documentation and examples made that fairly easy. Thank you Mike Peat for that.

The operation calendarList returns just fine although I noticed the L needs to be capitalized and the c lower case.
The content is the part that gets me. The docs say you must provide 3 pieces of information but not how to format them.

You need the calendarId: or Id:, the start: and stop: times as a minimum. The Id is your gmail email address or just primary.
It says here https://developers.google.com/google-apps/calendar/v3/reference/calendarList/insert how to use the insert command and the format referred to as the "body" The body is using a typical PHP type format. Just exactly what should be typed into the content area and the operation to get it to create a calendar event? Has anyone done this yet? Thanks ahead of time.:confused:

Mike Peat
12-Jul-2017, 03:20 AM
Hi Salesnav

I have not actually worked with the Google Calendar API, but have done a lot with the DriveApi. To be fair to Google, I think their docs make it pretty easy to do this kind of thing, but when I do it in my OAuth/REST courses I direct my students to the Google API Explorer (I'm deliberately not including a link to that, because every time I need it - as in those courses - I just Google it! <g>). From there I just go to the API I want (the one I use in the course is the Drive API, but I'm a bit behind and the current version is v3, so I click the "All Versions" link on the left and pick the Drive v2 one I am familiar with). You would want the Calendar API. Then I think you want the calendar.events.insert method, so click on that, put in your gmail address (Note: I use mjpeat@gmail.com (mjpeat@gmai.coml) day to day, but signed up as mjpeat@googlemail.com originally, and that's the one it wants) , the start and end (Note: they are the wrong way round, logically, being lexically sorted: "e" before "s") dates (or dateTimes), maybe another property below, say description, then click the "Authorize and execute".

It will then show you the JSON request to send to do that and the response you will get back. Example (I just did this):



Request

POST https://www.googleapis.com/calendar/v3/calendars/mjpeat%40gmail.com/events?key={YOUR_API_KEY} {
"end": {
"date": "2017-07-14"
},

"start": {
"date": "2017-07-13"
},

"description": "AcccuServ meeting at Unicorn"

}

Response

200 - Show headers -
{
https://support.dataaccess.com/Forums/image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAAC prHcmAAAANUlEQVR42mNgGNqgHYj/Y8HTsClmBOIlaAo3AjEzLtPZgHgvVOEJIOYi5Bx IN4GxNKDNLgAp6IN47aYlEUAAAAASUVORK5CYII=
"kind": "calendar#event",
"etag": "\"2999690546402000\"",
"id": "90v06b6vkmc1g51nfiirl2o454",
"status": "confirmed",
"htmlLink": "https://www.google.com/calendar/event?eid=OTB2MDZiNnZrbWMxZzUxbmZpaXJsMm80NTQgbWpw ZWF0QG0",
"created": "2017-07-12T07:41:13.000Z",
"updated": "2017-07-12T07:41:13.253Z",
"description": "AcccuServ meeting at Unicorn",
"creator": {
"email": "mjpeat@gmail.com",
"displayName": "Mike Peat",
"self": true

},

"organizer": {
"email": "mjpeat@gmail.com",
"displayName": "Mike Peat",
"self": true

},

"start": {
"date": "2017-07-13"
},

"end": {
"date": "2017-07-14"
},

"iCalUID": "90v06b6vkmc1g51nfiirl2o454@google.com",
"sequence": 0,
"reminders": {
"useDefault": false,
"overrides": [
{
"method": "email",
"minutes": 10

},

{
"method": "popup",
"minutes": 30

}


]


}


}



Can I ask what version of DataFlex you are using? IMO v19.0 makes this much easier, because of the cJsonObjects it introduces (Thanks Harm! <g>).

Mike

Mike Peat
12-Jul-2017, 03:54 AM
I found that created an untitled event in my calendar, so you might try (just a guess - I have not looked at any doc) adding "title": "WhateverYouWantToCallIt" just after the description in the JSON.

Mike

salesnav
12-Jul-2017, 09:41 AM
Hi Mike, thanks for responding. I have successfully used that tool to create a calendar event but I'm not figuring out the translation part. Also, they give you the layout of the different event parameters but not the code for the most important ones like the calendar ID. I've been using your test bed, the google test. It's the operation field and the content field I'm having trouble with. First can I even use the operation and content fields? What would you enter there. For the operation I've been using calendar/v3/users/me/calendarList. The scope is http://www.googleapis.com/auth/calendar. That works fine if you don't touch anything else, just execute. The question comes in how exactly do you feed the rest of the information to create the event in the calendar. I'm thinking you can't just use the operation and content fields and format something for them , it's not enough. I don't know your code well enough yet. I am thinking I need to tear into the packages today that you created to see what's in there. I'm not getting the way to do a JSON request quite yet. Like, is it a string, some other thing. I will work on it today. Any help is appreciated. Like.....
in google drive, after you make the connection using your testing tool, what code are you using to send the JSON request exactly. I'd be happy to post what works out here when I'm done. I would think there is a lot of us VDF programmers that could use to attach a calendar, a free one to our apps. BTW, I'm still on 18.2. I usually wait til a full release. Thanks again

Mike Peat
13-Jul-2017, 02:58 AM
Salesnav

As I said, I have not done anything with the Google Calendar API, so I'm as much in the dark about that as you are. I wish I had more time to help, but I'm a bit snowed under ATM.

Sorry!

Mike

salesnav
13-Jul-2017, 10:18 AM
Bummer. As usual, bumping down the hack at it Sam road again. Thanks Mike, you got it this far. I'll post what I find out when I find out.

salesnav
13-Jul-2017, 03:27 PM
Hi Mike, been studying this all day. I have the token, I see the exact http: I have to send. I have to add parameters which is the JSON code in the header

POST https://www.googleapis.com/calendar/v3/calendars/{email@gmail.com}/events?key={tokenAPI_Key}

}
"end": {
"dateTime": "2017-07-13T09:45:00.0z"
},
"start": {
"dateTime": "2017-07-13T09:30:00.0z"
},
"summary": "Test Event"
}

The above works using the APIs Explorer for calendars.events.insert

These parameters need to be put into a struct. got that, understood

2 questions and I don't need for you to figure this out for me, just point me in the right direction
1. How do I add the convert the struct to JSON?
2. How do I add that to the Header?

Maybe this is a simple question but it's just not spelled out anywhere I can find

This is the url

POST https://www.googleapis.com/calendar/v3/calendars/{email@gmail.com}/events?key={tokenAPI_Key}

How do the parameters get added to that command line above in laymen's terms

Thanks

Mike Peat
14-Jul-2017, 04:03 AM
Salesnav

First, the JSON needs to go in the HTTP Body, not the Header.

Second, getting data from your structs into JSON. Well here is why I think you should consider DataFlex v19.0. In it Harm has introduced JSON objects, which make this simple:



Struct tGoogleCalCreateEvent
DateTime end
DateTime start
String summary
End_Struct

Struct tGoogleCalCreateEventResponse
// I'm leaving this for you to do based on the response JSON from API Explorer (or use RestGen - attached)
End_Struct

Class cMyHttpTransfer is a cHttpTransfer

Procedure Construct_Object
Property UChar[] pucaData
Property String psContentType
End_Procedure

Procedure OnDataReceived string sContentType string sData
UChar[] ucaData

Get pucaData to ucaData
Move (AppendArray(ucaData, StringToUCharArray(sData))) to ucaData
Set pucaData to ucaData
Set psDataType to sContentType
End_Procedure

End_Class


Function SendMyJsonStuff tGoogleCalCreateEvent tData Returns tGoogleCalCreateEventResponse
Handle hoJson hoHttp hoResp
UChar[] ucaData ucaResp
Boolean bOK
Integer iStat
String sPath
tGoogleCalCreateEventResponse tResp

Get Create (RefClass(cJsonObject)) to hoReq

Send DataTypeToJson of hoJson tData
Get StringifyUTF8 of hoJson to ucaData
Send Destroy of hoJson

Get Create (RefClass(cMyHttpTransfer)) to hoHttp
Set psRemoteHost of hoHttp to "www.googleapis.com"
Move "calendar/v3/calendars/{YOUR_GMAIL_ADDRESS}/events?key={YOUR_API_KEY}" to sPath
Get HttpVerbAddrRequest of hoHttp sPath (AddressOf(ucaData)) (SizeOfArray(ucaData)) False "POST" to bOK

If iOK Begin
Get ResponseStatusCode of hoHttp to iStat

If ((iStat >= 200) and (iStat < 300)) Begin
Move (pucaData(hoHttp)) to ucaResp
Get Create (RefClass(cJsonObject)) to hoResp
Get ParseUtf8 of hoResp ucaResp to bOK

If bOK Begin
Get JsonToDataType of hoResp to tResp
End
Else Error 999 "JSON parse failed"

Send Destroy of hoResp
End
Else Error 999 ("HTTP status" * String(iStat))

End
Else Error 999 "HTTP request failed"

Send Destroy of hoHttp
Function_Return tResp
End_Function


(Note: I wrote this directly into the post - it has not been tested!!!)

Writing the structs is difficult/tedious, but I have a little program which helps, generating them into package files from sample JSON: RestGen (attached).

If you want to do it in 18.2 it is harder, but RestGen will generate code to help you do it in those packages (absolutely no warranty though!).

Mike

Mike Peat
14-Jul-2017, 04:33 AM
PS - when using RestGen, add it to your Studio Tools menu with the parameter <workspace> which will let it figure out where to put things.

Also, maybe look at my Microsoft Office365 API here: https://support.dataaccess.com/Forums/attachment.php?attachmentid=9278&d=1443028243

Mike

salesnav
14-Jul-2017, 10:07 AM
When I have this done, I will modify your GoogleTest.wo to include getting the calendar to work and send you all of it. Thank you very very much. It's exactly what I needed. You're a good man.

Mike Peat
15-Jul-2017, 05:20 AM
Happy to help! :)

salesnav
19-Jul-2017, 01:10 PM
Hi Mike, I still have a couple questions. I used your Restgen.exe program. Thank you, great program. Lot of code to generate. I weas wondering if you could elaborate a hair on the code you sent me. Regstgen created 3 pkg files, tEvent, tEvenEnd and tEventStart. They are pretty straightforward. The end and start pkg files are for the elements that have subelements in JSON, no problem there. I am trying to follow the example code in oOauth2.pkg that obviously is working although it's still using your google codes. First question is can you look up in Google credential area https://console.developers.google.com/apis/credentials/oauthclient/247862654986-i72fr480fu0iruqrnvbfdnsaqg51fo7n.apps.googleuserco ntent.com?project=webnav-calendar and tell me what you enetered in the redirect urls area. I am also trying ChilKat's dll to do the same thing but I'm getting redirect errors, apparently a common error to get.

Now back to your code. In the tEvent pkg, there is the tEvent struct, the class cStructHandler_tEvent which has the properties in it. It's slightly different than the oAuth2.pkg. What is the syntax one would use to load these properties. Do I use the

procedure StructoJson tEvent strValue tJsonNode Byref strJson

I get that in the above tEevnt is the struct. I'm guessing the strValue is the contents of tEvent. What is tJsonNode, is there anything I need to do the load that or what is it? and what is strJson.

With all this code, there are only 5 pieces of information to load, the start and end times, the id which is the user's email, a summary and a description.

Once they are loaded, you gave me a function called

function SendMyJsonStuff tGoogleCalCreateEvent tData returns tGoogleCalCreateEventResponse

I'm getting an error on compile for hoReq. I'm missing something there, is hoReq supposed to be an oAuth2 similar to the object oAuth in the original googletest.wo?
I'm also getting an error on a forward ref to sReportId. I am clueless to exactly how to use the function and actually send the URL to google with the calendar create request. I am trying to use

Get psFunction sReportID "Addressblock" to sFunctionBody

I appreciate all your help. never been down this path before.
Thanks again.

Mike Peat
20-Jul-2017, 03:05 AM
Hi James (see? my spies found out who you really are! :))

First: hoReq - sorry, I told you not to trust that code! I just typed it in by hand (while in a customer meeting, forsooth!) to get you started. hoReq should be one of the handles declared at the start of the function. Mea culpa!

On how to use the generated struct packages, in your code (not the package code, which you should leave untouched, as you may want to regenerate it at some point, which would overwrite any changes) you would use use:


Send StructToJsonString of oStructHandler_tEvent (&tYourVarName) (&sYourJsonString)


From your main code to get data in a struct variable (YourVarName) into a string (YourJsonString) for passing in your HTTP call. (This is the old way, pre-19.0, and uses strings, with all the problems associated with them. I have a 19.0 version of RestGen, but it hasn't really been tested enough to release yet.)

The example code I gave you was for 19.0 (which I've been using for this kind of stuff for over six months now). Doing it in 18.* is harder, which is why I suggested using 19.0. With 19.0 you can still use the older RestGen program to generate the structs (which can be a very tedious and error-prone exercise when there are large structs involved), but you don't need to use the code in there unless there are names in the JSON which can't be used exactly as DataFlex struct member names (which is what all the pasOriginalName and pasReplacedNames stuff is about).

Redirect URL: this should point to an essentially empty HTML file on the machine your code is running on (your "server", even if that is your development machine ATM - just use "localhost" for testing). It has to be in the same host as your web app, otherwise the JavaScript side of the OAuth2 component can't read the data in its query string. There is a good case for storing this in the database somewhere and reading it before you make your call (for ease of deployment to a "real" server, when it would need to change to point to that machine.

For a fuller explanation, see my slides about this which I presented at EDUC in Berlin last year: https://docs.google.com/presentation/d/1jEgZ3O5RGwp1IerBmxMNnNIZXWiIDipo-UBw-n6qUjs/edit?usp=sharing.

Mike

salesnav
24-Jul-2017, 04:29 PM
This is new for me, all the struct stuff. Ok, I have 19.0, I have the Restgen pkg files, look like the same in your Outlook code. In that code, you have Function

GetEvents Returns Boolean
String sCal sParams sResults
Boolean bOK
Integer i iMax
tO365CalEvents tEvents

This one mystifies me at the moment

tO365CalEvents tEvents

The tO365CalEvents is the struct in the tO365CalEvent.pkg
You are using it like a command here, not sure how this works.
My struct and package is tevent

Don't you load the struct with data like move "email@gmail.com" to tEvent
Then in the tEvent pkg that restgen creates, it says tO365CalEvent.pkg

//Declare a struct variable: "tO365CalEvent tYourVarName"
// and a string: "String sYourJsonString"
//
// to populate the struct with the JSON data.

is that this tO365CalEvents tEvents ?

if so, what is the tEvents above represent, where is that being built or do you redeclare a new struct somewhere?


Then
// To generate a JSON string, do:
//
// "Send StructToJsonString of oStructHandler_tO365CalEvent (&tYourVarName) (&sYourJsonString)"

(&tYourVarName) (&sYourJsonString) The YourVarName must be "tevents" but what exactly is the &sYourJsonString? Where is that declared and loaded or is it just there.


I really appreciate your help, sorry, I have been writing for a long, long time, but never had to do all of this new struct/array type stuff yet so there are some obvious things to you I'm sure that I should know and don't.

Mike Peat
27-Jul-2017, 04:24 AM
My apologies - I may have muddied the waters for you. Extended explanation coming up...

Pre v19, there was no easy way in DataFlex to handle JSON data. However Sture Andersen had a nice little JSON functions package and a mechanism in his VDFXRay program which would analyse the structs in your program and generate "Struct Handler" code for you to call that would allow you to (relatively) easily translate JSON data (in a string) into them and transform date out of them into JSON strings.

When first working with JSON-based services, I wrote the structs by hand from the sample JSON provided, or as returned by test calls to services, then used Sture's utility to generate handlers for those structs and used those going forward.

However after presenting on that kind of stuff at Synergy in Seattle, I realised that it should be possible to cut out the step where I had to laboriously create the structs to match the JSON by hand, so I wrote RESTGen. The code it generates is mostly based on Sture's work (it calls his JSON functions stuff all the time) and I just wrote stuff to output the same kind of thing Sture's VDFXRay generator did (often without even understanding exactly why).

So RESTGen was massively useful to me prior to v19 (when building the Office 365 interface, for instance).

It also did one extra thing. Because in some cases JSON APIs use JSON member names which can't be directly used as DataFlex struct member names. One example taken from Google's (v2: v3 is much more concise <g>) drive.files.list response:


"exportLinks": {
"application/rtf": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=rtf",
"application/vnd.oasis.opendocument.text": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=odt",
"text/html": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=html",
"application/pdf": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=pdf",
"application/epub+zip": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=epub",
"application/zip": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=zip",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=docx",
"text/plain": "https://docs.google.com/feeds/download/documents/export/Export?id=1Bz5H2lEAr7yoPVFszcsV609cHV8IZXvUds_8k48 2jKk&exportFormat=txt"
}

None of those member names can be used as DataFlex struct member names because they all contain a "/" character. The same is true of Microsoft's Office 365 API, where they have JSON member names such as "@odata.id" - neither the "@" nor the "." are valid in DataFlex struct member names (actually, looking at it now, I'm not so sure about the "@", but I did substantial testing at the time so it may be right... I'm certain about the "." though).

To get around this, I built a translation mechanism into RESTGen - not all that efficient: it just does Replaces() on the relevant JSON strings in and out - but it does work.

Now with v19 we have JSON objects in the product, so all of the stuff RESTGen (or Sture's VDFXRay) does generating handler mechanisms is rendered redundant, however the incompatible member names problem still remains.

For the course I gave at Synergy in Atlanta this Spring, I rewrote RESTGen to use JSON objects. That version no longer generates the handler code, because JSON objects deal with doing that, but it still generates the structs - saving you a pile of tedious and error-prone manual typing - and the translation mechanism, although now that works quite differently, by removing JSON members with invalid names from the JSON object and inserting new ones with the translated names (and visa-versa going the other way). Unfortunately I have not had the time (or, frankly, energy) to test it properly. I only got it working the day before I flew to Atlanta and its only outing was at the course I gave there, so I just don't trust it enough yet to inflict it on an unsuspecting public.

The old (pre-19) version can still generate the structs for you, so still has utility, but for everything else, if you are in v19, you should use DataFlex's own JSON Objects and ignore the classes, methods and objects RESTGen generates.

I realise that your question had more to do with Google's calendar API, but there I can't really be much help, not having used it at all.

Mike

salesnav
7-Aug-2017, 04:30 PM
Thank you Mike, that helped me understand. I've been working with ChilKat and there's something wrong that probably used to work desktop or v17, but it no longer works in v18 and v19. You get a token but it's not where it's supposed to be. Chilkat would have made this all easier or so I thought. I needed to persure bot avenues. In the meantime, I'm now using v19.0 I was wondering if you could take a look at this code and tell me what I'm doing wrong. Your code works generating the token, then you had an example of retrieving a calendarlist that worked well but it wasn't sending any Content (JSON) along with it. I'm not asking you to write nthis for me, just wonder if you see something out of whack. The JSON format reported on showln looks good. This is just a modified Procedure GoogleOp from your example code. This is using the new v19.0 JSON commands



Procedure GoogleOp
String sOp sPath sVerb sContent sToken sResp
Integer iOK

WebGet wpsAccessToken of oOAuth to sToken

If (sToken = "") Begin
Send ShowInfoBox "You need to log into Google before testing operations" "Error"
Procedure_Return
End

WebGet psValue of oOperation to sOp

// Create JSON
Handle hojJson hojDetail1 hojDetail2
String sjJson


Get Create (RefClass(cJsonObject)) to hojJson
Send InitializeJsonType of hojJson jsonTypeObject


Send SetMemberValue of hojJson "summary" jsonTypeString "Test Event"


// Initialize detail object
Get Create (RefClass(cJsonObject)) to hojDetail1
Send InitializeJsonType of hojDetail1 jsonTypeObject
Send SetMemberValue of hojDetail1 "dateTime" jsonTypeString "2017-08-07T10:00:00.0z"
Send SetMember of hojJson "start" hojDetail1
Send Destroy of hojDetail1


Get Create (RefClass(cJsonObject)) to hojDetail2
Send InitializeJsonType of hojDetail2 jsonTypeObject
Send SetMemberValue of hojDetail2 "dateTime" jsonTypeString "2017-08-07T10:30:00.0z"
Send SetMember of hojJson "end" hojDetail2
Send Destroy of hojDetail2


// Generate JSON string
Set peWhiteSpace of hoJJson to jpWhitespace_Spaced
Get Stringify of hoJJson to sjJson


Showln sjJson
Send Destroy of hoJJson

Move (Trim(sOp)) to sOP
WebGet psValue of oVerb to sVerb
//WebGet psValue of oContent to sContent
Move sjJson to sContent //This is pretty much all that was changed

If (Pos("?", sOp) > 0) Begin
Move (sOp + "&access_token=" + sToken) to sPath
End
Else Begin
Move (sOp + "?access_token=" + sToken) to sPath
End

WebSet psValue of oPath to (If((peTransferFlags(oGglHttp(Self)) iand ifSecure), "https://", "http://") + ;
psRemoteHost(oGglHttp(Self)) + "/" + sPath)


Send Reset of oGglHttp

Get HttpVerbAddrRequest of oGglHttp sPath (AddressOf(sContent)) (Length(sContent)) False sVerb to iOK

If iOK Begin
Get psData of oGglHttp to sResp
Move 0 to WindowIndex

WebSet pbRender of oResponseText to True
WebSet pbRender of oResponseHTML to False
WebSet psValue of oResponseText to sResp
End
Else Begin
WebSet pbRender of oResponseText to True
WebSet pbRender of oResponseHTML to False
WebSet psValue of oResponseText to "HTTP request failed"
End

WebSet psValue of oRemains to (GrantLeft(oOAuth(Self)))
End_Procedure


Here is the correct JSON taken from the Google API Explorer



//{
// "end": {
// "dateTime": "2017-07-11T09:30:00.0z"
// },
// "start": {
// "dateTime": "2017-07-11T09:00:00.0z"
// },
// "summary": "Big Event",
//}



I also modified this window and this is tested in the API explorer, only works with %40 for the @ sign



Object oOperation is a cWebForm
Set piColumnSpan to 0
Set psLabel to "Operation:"
Set piMaxLength to 1000
//Set psValue to "calendar/v3/users/me/calendarList"
Set psValue to "calendar/v3/users/calendars/leadgusher%40gmail.com/events"
End_Object


Thanks again, it's majorly appreciated. Not many folks to discuss all this with you know?

salesnav
22-Aug-2017, 09:52 AM
This was a major project and with some great help from Sean Bamforth, we got this working very, very well. It was quite a process, took several weeks or research to find out what didn't work and what did. Mike Peat's examples were a great start, but they only get you an access code, but not the refresh code, authorization code and everything else. What's out here from all sources doesn't get you very far as it turns out, and flat out, unfortunately with 18.2 and 19.0, Chilkat which is supposed to support these calls does not work properly. We spent money and too much time on that as well. So this was all done in 19.0. It ended up costing a bit. I don't know how many people are watching this thread and want to get Google Calendar and frankly with this code example any other Google product to work using Google API JSON calls. I also don't mind helping those that want to get this working wrapping your mind around the whole thing. If anyone is interested, please contact me at james@accountcraft.com or on skype as "salesnav". By the way, Sean is brilliant.

Mike Peat
23-Aug-2017, 02:59 AM
Well done! And kudos to Sean. :)

Mike