Posts
My small team relies on automation to make things run. Part of our PD process is a Google Form workflow that kicks off calendar events, document creation, and email notifications. Since we've moved to online learning, we wanted to update that process to automatically add a Google Meet link for any PD that doesn't have to be in person.
This is important for us so we have consistency and maintainability built in. Taking variability out of event creation and management allows us, as a team of two, to make sure all PD in the district is accessible from anywhere, recordings archvied for later, and a full record of all events run in a given time period.
There are some official Google guides that show how to attach a Meet event to the Calendar, but nothing from the perspective of Apps Script specifically, so that's what I'll show here.
The Setup
Before you start, this relies on the advanced Calendar service. So, you'll need to go into your project, click on Resources > Advanced Google Services and then enable the Calendar v3 API in the project and the cloud console.
Enabling the advance service will give you access to the Calendarobject which can take object arguments, which is what you need to for the Meet attachment.
Data Structure
We use a Google Form to populate a spreadsheet of events. I won't go into how that data is collected or processed, so let's assume we have an object which represents values we'll use in the Calendar event:
const obj = {
"eventId": "abc123",
"title": "Some event",
"description": "Let's do stuff in a Meet",
"start": "2020-06-01T13:00:00",
"end": "2020-06-01T14:00:00"
}
To create an event with the Calendar advanced service, we need to build a payload object to send with the request with details for the calendar event:
const payload = {
"start": {
"dateTime": obj.start,
"timeZone": "America/Indiana/Indianapolis",
},
"end": {
"dateTime": obj.end,
"timeZone": "America/Indiana/Indianapolis",
},
"conferenceData": {
"createRequest": {
"conferenceSolutionKey": {
"type": "hangoutsMeet"
},
"requestId": obj.eventId,
},
},
"summary": obj.title,
"description": obj.description
}
The only required parameters are the start and end keys. I also defined my time zone instead of manually adding a time offset, but that's for another post.
Notice the new confereceData ket in the middle of the payload. We define a new request for a Meet and set the conferenceSolutionKey.type value to hangoutsMeet. Other valid values are eventHangout for consumers and eventNamedHangout if you haven't enabled Google Meet in your GSuite domain.
Each request needs a requestId value, so I just use the eventId I already have generated, but it can be any valid string.
Finally, we need to set one more argument before we send the request: { "conferenceDataVersion": 1}. This allows that attached Meet to be modified if necessary.
Create the Event
This method uses the insert endpoint on the Calendar service. This takes three arguments: your payload, a Google Calendar ID (as a string), and any additional arguments. You can programatically get Calendar IDs, but we only post to one calendar, so I copied/pasted from the account we're using and set it as a variable.
In your Apps Script project, you can now do something like this:
function demo() {
const payload = { ... }
const arg = { "conferenceDataVersion": 1 }
try {
const response = Calendar.Events.insert(payload, "yourCalendarID", args)
console.log(`Success! ${response}`)
} catch(e) {
console.log(`Oh no: ${e.message}`)
}
}
If all goes well, you should now have a calendar event with an attached Meet.
In a Canvas course, you can quickly check the number of missing assignments for single students relatively quickly. You can also message groups of students missing specific assignments from the analytics page (or the gradebook). What you can't do is get a list of all students in a course and their missing assignments in a CSV for quick analysis.
In my never ending exploration of the Canvas API, I've got a Python script that creates a missing assignments report for a course, broken down by section.
What it does
The script uses UCF's canvasapi library to handle all of the endpoints. Make sure to pip install before you try to run the script. The Canvas object makes it easy to pass course and section references around for processing.
Because each student has to be individualy looked up, it uses multiple threads to speed it up. There isn't much compute, just API calls and data wrangling, so multithreading worked better than multiprocessing.
For each section, the script calls for each students' submissions, looking for workflow_state="unsubmitted" specifically to handle filtering on the Canvas servers.
From this filtered list, it creates a final list by checking the submission history and any excused flags. A list is then returned to the main worker and the section is written as a whole to keep the processes thread-safe.
When the script is finished, you'll have a CSV report on your filesystem (in the same directory as the script itself) that you can use.
Improvements
Currently, missing assigments are joined as a single string in the final cell, so those could be broken out into individual columns. I found that the resulting sheet is nicer when the number of columns is consistent, but there could be some additional processing added to sort assignments by name to keep order similar.
Canvas is also implementing GraphQL endpoints so you can request specific bits of data. The REST endpoints are hepful, but you get a lot of data back. Cleaning up the number of bytes of return data will also help it run faster.
While schools are closed, we've moved much of our long term staff development material into Canvas. We have one long-running course with all staff split into site-based sections that has worked as a model for others. We needed a way to essentially duplicate the template course enrollments into new training courses.
Ignorance is bliss (sometimes) and I didn't know of a good way to make this happen. I looked at some of the provisioning reports, but I couldn't select a single course to run a report on. So, I reached for Python and the UCF Open canvasapi library to make it happen.
At the end of this process, I ended with a brand new course, populated with teachers enrolled in their specific sections. I was also able to disable the new registration email and set their course status to active by default.
from config import KEY, URL
from canvasapi import Canvas
template_course_id = ''
new_course_id = ''
canvas = Canvas(URL, KEY)
template_course = canvas.get_course(template_course_id)
new_course = canvas.get_course(new_course_id)
template_sections = template_course.get_sections()
new_sections = [section.name for section in new_course.get_sections()]
for section in template_sections:
enrollments = section.get_enrollments()
if not section.name in new_sections:
print(f'Creating section {section.name}')
new_sections.append(section.name)
course_section = {
"name": section.name,
}
new_section = new_course.create_course_section(course_section=course_section)
count = 0
for enrollment in enrollments:
student = enrollment.user['id']
print(f'Enrolling {enrollment.user["name"]}')
count += 1
args = {
"course_section_id": new_section.id,
"notify": False,
"enrollment_state": "active"
}
try:
new_course.enroll_user(student, "StudentEnrollment", enrollment=args)
except Exception as e:
print(e)
print(f'Enrolled {count} users in {new_section.name}')
It's definitely brute force, but it saved us from having to copy and paste nearly 1,300 users into the new course by hand from a spreadsheet.
Why force enroll at all?
I think this highlights one of the barriers for really taking Canvas to the next level for staff support. There is no good way to enroll non-student users in courses for required development. In our case, it's to fulfill a required training for staff and using Canvas makes sense as a lot is done through application and reflection.
The public course index in Canvas could be used, but without a great way to expose the course to instructional staff only (I know we could use some JavaScript and edit the template, but that's just another thing to manage) it could lead to students joining courses either by accident or maliciously.
We've also toyed around with making a custom self-signup process on an internal website where staff are forwarded directly to the enroll page, but it's another system to manage and another site for teachers to use. The most hands-off approach for all involved is to do something like this in the background as needed to get people where they need to be effectively and efficiently.
tl;dr: The DocuTube addon will be removed from the Marketplace at the end of June, 2020. If you're a programmer and you want to take over ownership of the project, let me know.
Three years ago, I wrote a proof of concept Google Docs app that found YouTube links and displayed videos embedded in a sidebar. It was fun to do and showed some powerful uses of Apps Script in a real way.
I'd made some incremental updates here and there and then worked on a major update over the fall and winter of 2019. During that time, Google updated the platform the code runs on which has been a little hit or miss in terms of usability.
I've had a love/hate relationship with the addon. For instance, it won't work for someone who doesn't have edit rights to the doc. So, what was meant to help teachers and students work effectively isn't a good solution because of the way addons run.
Second, there have been some very strange errors that I have no control over, mainly within Google's app engine. There are service outages and access errors that I cannot prevent or handle well. This has meant frustration for users and for me.
In the end, it showed that Apps Script on it's own can be a great place to build simple utilities that help add functionality and usability to systems. But the fact of the matter is that I cannot solve many of the main issues with DocuTube and I'm at a point where I can't commit more time to it. Rather than leave a buggy, unmaintained app in the Marketplace, I'll be removing it from the store at the end of June once everyone is out of school.
If you're a programmer or hobbyist and you want to take up maintenance, get in touch because I'd be happy to transfer the codebase over to you and you can republish with all the same assets if you'd like.
If you were a user, thanks. If you wrote a review, thanks. If you've never heard of it, not a big deal.
This is a technical post. Read on for code and commentary.
In moving to online, we've tried to streamline all of our communication through Canvas. The goal is to cut down on disconnected email threads and encourage students to use submission comments to keep questions and feedback in context.
The Problem
Many students had already turned off email notifications for most communications in Canvas, preferring not to have any notices, which reduces their responsibility for teacher prompting and revision. Notifications are a user setting and the Canvas admin panel doesn't provide a way to define a default set of notification levels for users. However, with the API, we were able to write a Python program that sets notification prefs by combining the as_user_id query param as an admin that sets user notification preferences.
API Endpoints
- GET user communication channel IDs: /api/v1/users/:user_id/communication_channels
- PUT channel preferences for user: api/v1/users/self/communication_channels/{channel_id}/notification_preferences/{msg_type}
Params
- Int user_id
- Int channel_id
- String frequency
Get User IDs
There is no easy way to programmatically get user IDs at the account or subaccount levels without looping each course and pulling enrollments. Instead, we opted to pull a CSV of all enrollments using the Provisioning report through the Admin panel. We configured separate files using the current term as the filter. This CSV included teacher, student, and observer roles. The script limits the notification updates to student enrollments.
Script Details
The full program is available in a GitHub gist. Here is an annotated look at the core functions.
main handles the overall process in a multi-threaded context. We explicitly define a number of workers in the thread pool because the script would hang without a defined number. Five seemed to work consistently and ran 1500 records (a single subaccount) in about 7 minutes.
The CSV includes all enrollments for each student ID, so we created a set to isolate a unique list of student account IDs (lines 9-10 below).
To track progress, we wrapped the set in tqdm. This prints a status bar in the terminal while the process is running which shows the number of processed records out of the total length. This is not part of the standard library, so it needs to be installed from PyPI before you can import it.
def main():
"""
Update Canvas user notification preferences as an admin.
"""
unique = set()
data = []
with open('your.csv', 'r') as inp:
for row in csv.reader(inp):
if re.search("student", row[4]):
unique.add(int(row[2]))
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
with tqdm(total=len(unique)) as progress:
futures = []
for student in unique:
future = executor.submit(process_student_id, student)
future.add_done_callback(lambda p: progress.update())
futures.append(future)
results = [future.result() for future in futures
process_student_id is called by the context manager for each student ID in the set. Canvas breaks communication methods into "channels:" email, push, Twitter, etc (line 3). Each channel has a unique ID for each user, so we needed to call each user's communication channels and then pass the ID for emails to a setter function.
def process_student_id(student):
channel_id = get_channel_id(student)
try:
update = update_prefs(student, channel_id)
return update
except Exception as e:
print(e)
GET communication_channels
def get_channel_id(student_id):
url = f"https://yourURL.instructure.com/api/v1/users/{student_id}/communication_channels"
resp = requests.request("GET", url, headers=headers)
for channel in resp.json():
if channel['type'] == 'email':
return channel['id']
PUT communication_channels/:channel_id/notification_preferences/:message_type[frequency]
The communication channel can receive several types of communications. We wanted to set the student notifications to "immediately" for new announcements, submission comments, and conversation messages. You can define others as well as their frequencies by modifying the values on lines 3-4.
The communication types are not well documented, so we used our own channel preferences to find the notification strings: GET /users/self/communication_channels/:channel_id/notification_preferences.
The crux of this step is to make the request using the Masquerading query param available to the calling user. Make sure the account which generated the API key can masquerade or else the script will return an unauthorized error.
def update_prefs(student_id, channel_id):
types = ["new_announcement", "submission_comment", "conversation_message"]
frequency = "immediately"
responses = []
for msg_type in types:
url = f"https://elkhart.test.instructure.com/api/v1/users/self/communication_channels/{channel_id}/notification_preferences/{msg_type}?as_user_id={student_id}¬ification_preferences[frequency]={frequency}"
resp = requests.request("PUT", url, headers=headers)
responses.append(resp)
return responses
Final Thoughts
Updating a user's personal preferences isn't something I was thrilled about doing, but given our current circumstances, it was preferable to the alternative of continuing to struggle to help students move forward in their coursework. Further improvements would be to call each CSV in the file system incrementally, cutting down on the time someone has to log in and run the script. Hopefully, this only needs to be done once and does not become a recurring task.
Second, there is an endpoint in the API to update multiple communication preferences at once, but it isn't well documented and I wasn't able to get it working reliably. For just one channel and three specific types of messages, the performance improvements probably would have been negligible (at least that's what I'm telling myself).
I started a small project to clean up my personal photo hosting. I'm currently using an app written in PHP, but it's way over my head and does way more than I want or need. So, I'm taking this opportunity to roll my own backend API for photo storage and serving.
I'm building it in Flask and using the Flask-RESTful extension to help with routing, HTTP methods, and overall structure. I'm hoping that if I have a functional backend, I can learn a frontend framework like Vue in a more formal way.
It's nothing fancy, but the bones are starting to grow a little here and there. Right now, I have it returning all references in a database as well as querying by ID directly. Next, I'm going to add some methods to update the photo title and then I'll move on to uploading files directly.
Source code is in GitHub if you want to look. No pretty pictures because all it serves right now is data.
I just finished packing up our first house, to be put on the market tomrorow morning after living here for eight years. As I moved from room to room making small repairs this morning, I thought back on the time, sweat, and energy we've poured into it.
We bought the house after ignoring it for months because the outside was...interesting. My father-in-law convinced us to pull over and look into windows. That's the first time we met the neighbor, Dan, who with his wife Vicki, are the best neighbors in the world.
Yes, they're better than your neighbors. I will die on this hill.
All four of our kids were born in this house. My wife's family lived down the road and my parents were able to come and stay with us every couple of months. The neighbors became adopted grandparents by all of the kids. Small group meetings, brothers driving through, visits with cousins and other friends happened here. Our lives were shared in these rooms.
Sometimes there just isn't enough room on the couch.
This is about a year before the latest baby was born in June 2019
Since living here, I've learned how to frame, tile, plumb, and do my own drywall and electrical. I've installed a septic system. I've converted the house from electric heat to a gas furnace. We took a large bedroom and made it into two. Both bathrooms have had remakes...and those are just the large projects.
Plumbing an entire bathroom by myself was terrifying. So terrifying that I installed the shower mount upside down to remind myself to let things go. The shower still works.
Repairing a septic field isn't hard, necessarily, but it's a huge task.
I never would have imagined learning how to do all of those things (with my father-in-law's help) and now I can't imaging not having learned those things.
Lindsey's dad (in grey) is a contractor and taught me 99% of what I've learned in the last eight years.
The spring brought new planing and the summers were full of wild raspberries, vegetables from the garden, and more recently, armloads of cosmos, ranunculus, and roses.
We watched the bats wake up during dusk and wondered how loud the cicadas could get during the day.
These things won't stop. I'm pretty sure cicadas live in Michigan, too.
These guys look way meaner than they actually are.
But all of these things happened for the first time here. At Quince House.
It really hit me that we're leaving when I took the swings out of the tree. I can't bring the house, but some parts of it are coming with us.
This is where the new house will stand in the fall of 2020.
We'll be building our next house on a plot of land adjacent to my brother-in-law's farm. We'll be able to wake up and see the cattle out back along the treeline. We're planning on building a hiking trail from our land to theirs so the cousins can visit whenever they want.
There will still be joy. There will still be disappointment and growing.
Quince House got us started.
The featured photo is of my backyard, early 2020.
I was given a good challenge by our secondary curriculum coordinator a couple weeks back. He wanted to know how we get in front of standards-based grading being reduced to collections of isolated skills. In other words, we're doing well tracking our essential standards over time, but those are more or less in isolation (not taking into account any spiralling or scaffolding happening).
File this under stream-of-consciousness rambles. I have three thoughts percolating:
- Curriculum is skills, knowledge, and dispositions. It seems that performance tasks should focus on skills and dispositions more than content because they're the "connective tissue," as it were, to context outside of the classroom.
- Some kind of measurement tool is needed, but what is the scope? Is it defined by the district? Or are those skills and dispositions different based on content area? Or even by classroom?
- Showing application or transfer of information is difficult because you have to make something novel rather than simply report on learning.
In terms of how to do this...well, I haven't quite made it there yet. I have a feeling that this would be a good place for a single point rubric (because those are the new ??? right now) because ofo the flexibility they provide.
Another tack would be to write new performance standards which combine the individual standards, but that's another level of organization to add on top of unpacking the current content material. It could work with a larger group together at the district level, but consensus becomes the challenge.
If you're a teacher using SBG, what thoughts do you have? How do you make sure students are forming holistic understanding and not simply accruing a collection of ideas?
The LMS isn't perfect, but it's what we have.
We've gone through a sea change over the last four years. It started with opening Google Apps for all staff and students and then rolled into using Canvas as an LMS. I have mixed feelings about how heavily we're pouring into people using it, but I think I've landed on, "you have to start somewhere."
Questions I get now were not thinkable three years ago. The more staff get comfortable with the low-level functions (making assignments, using quizzes) the more questions I get that tend to revolve around achieving a goal rather than doing a thing. I have teachers moving toward questions like, "Can my students keep writing journals in Canvas?" (No.)
Discussions are getting better about how to do different things. There is more interest in creating opportunities for students to do more open-ended, meaningful work. I would love to be able to only use the LMS for collating information from other places. I want to make sure teachers are considering where work is done and how it is stored. I want students to be able to keep their own record of what they're doing.
It's a starting point. Canvas, with all of its flaws, has opened up several avenues of discussion that were not possible before we had it in place. I think the main danger is throwing all of the instructional chips into one place. Use the LMS as a launching point, not the end. Keep perspective on what meaningful work actually looks like.
If you're brand new to unit testing, start with the first post in this series to get caught up.
It's time to dive into deeper equality situations with objects and arrays. Every test we've written so far has used a non-strict comparison. In other words, we've only been checking value but not type. This is particularly important in JavaScript because of how it handles truthy and falsy values.
For instance, if you were to write a test checking the equality of 0 and undefined, what would you expect? They're different, right? If you want to try it yourself, you can write a quick test:
QUnit.test('truthy and falsy values', function() {
equal(0, undefined, 'both are falsy values, but unequal types')
deepEqual(0, undefined, 'both are falsy values, and unequal types')
})
QUnit passes the first test because both 0 and undefined are falsy - the values are the same, but the type is different. Using equal as the assertion only checks against the value of the actual and expected arguments. This is where deepEqual helps. Instead of checking values only, deepEqual performs a strict check of both the value and type of the arguments.
Objects and Arrays
We have only looked at simple values - numbers and strings. In this post, we'll look at using deepEqual and propEqual to test objects and arrays. Rather than jumping right into testing our Calcs class, let's start with two simpler examples. Start by adding this to your tests.gs file:
function objectTests() {
QUnit.test('Object and array basics', function() {
var array = [1,2,3,4];
deepEqual(array, [1,2,3,4], 'the arrays are equal');
});
}
This is the first time we've defined a variable inside a Qunit.test instance. Each test is a function, so it can have block-scoped variables used in the test. These variables do not affect other functions in the wrapper. Eventually, we will be retrieving exisitng objects and arrays to test, but for now, we'll define them with each test as necessary.
Because we're defining a new wrapper, you need to go to config.gs and add objectTests() to the tests() wrapper for these new tests to run:
function tests() {
console = Logger;
calcTests();
objectTests();
}
This is personal preference, really...there is nothing saying you cannot include these checks in the calcTests wrapper we're using, but I find it helpful to break out tests into similar groups.
Reload the web app and you'll see a new line passing the array deepEqual check we just wrote. Let's do the same thing for an Object:
function objectTests() {
deepEqual({a: "hello", b: "world"}, {a: "hello", b: "world"}, 'These objects are equivalent');
}
This test will also pass because the objects have strict equality with one another. deepEqual is recursive, meaning it will check for equality even within nested objects:
function objectTests() {
deepEqual(
{
a: "hello",
b: "world",
c: {
aa: "foo",
bb: "bar"
}
}, {
a: "hello",
b: "world",
c: {
aa: "foo",
bb: "bar"
}
}, 'Nested objects can be tested, too');
}
Checking Constructed Objects
Checking object constructors is complicated. You cannot just define a matching object in the function because deepEqual checks the constructor along with the value. Rather than testing the entire object, it is better to check each part of the object.
This follows with the unit testing philosophy - test the smallest possible pieces of your code. If you want to test the structure of the object, we can assign a variable to an object with the desired properties and test our Calcs object against it with propEqual.
To help with flow control, I've added an init() method Calcs which will return the entire object. It doesn't matter a whole not right now, but it will in future posts.
var Calcs = (function() {
const init = function() {
return this;
}
})
From now on, when we need to instantiate Calcs, we'll use Calcs.init().
To test obect properties, let's add a variable with a known structure to use as our expected value. Then, we'll call Calcs.init() to get the full object back to compare properties.
function objectTests() {
var testCalcsClass = {
init: function() {},
name: "calculation methods",
about: function() {},
author: function() {},
add: function(a, b) {},
isNumber: function(val) {},
addArray: function(arr, int) {},
}
propEqual(Calcs.init(), testCalcsClass, 'The constructed object has the expected structure.');
}
propEqual returns true because the properties of both are the same. Calling deepEqual will cause a failure because it checks the properties and the object constructor. Our expected value wasn't created with a constructor like the actual and the test will fail.
Why might this type of check be important?
If your object returns the wrong type of value, propEqual will fail. For example, changing init to a string value in your expected object will fail when compared with Calcs.init() because it's expecting a function, not a string.
Using propEqual on your classes can help prevent type errors down the line by ensuring each property matches the expected type. This kind of check, where you specify an expected structure, is called mocking and we'll look at that in a future post.
Testing Returned Values
What about functions or methods that return structured data? We can use deepEqual to check the returned values. We're going to add a method to Calcs which accepts an array and integer and returns an array with each value increased by the specified amount. Here's the test we'll run:
QUnit.test('Test array calculations', function() {
equal(Calcs.addArray(2, 2), false, 'Param 1 is not an array');
equal(Calcs.addArray([1,2,3], 'dog'), false, 'Param 2 is a string');
deepEqual(Calcs.addArray([1, 2, 3], 2), [3, 4, 5], 'The returned array is correct');
});
Our test defines three checks that need to pass:
- The first parameter is an array,
- the second parameter is a number,
- and the returned array is equal to the expected value.
Our method needs to accept an array and a number to add to each value in the array. We should get a new array back with the updated values.
const addArray = function(arr, int) {
if (!Array.isArray(arr)) { return false }
if (typeof int !== 'number') { return false }
var addArr = arr.map(function(val) { return val + int })
return addArr;
}
return {
addArray: addArray
}
If you reload your web app, all tests should pass. This could also be extended with throws to check for custom error messages like we did back in part 4.
Put it into practice
It's easy to get sucked into thinking you need to check for exact data, particularly with Objects and Arrays. With unit testing, remember that you're checking that a piece of your code does what it's designed to do with any data. Running tests on generic structures gives you a clear idea of what any individual part of your application does. Use propEqual to test mocked objects for structure.
Summary
- equal does a soft comparison (==) and deepEqual uses a strict check (===).
- deepEqual also checks constructor values for Objects.
- propEqual compares Object properties (structure) without considering the constructor.
We've started using Spotify more around the house with the kids. We only have the free account, which means we hear ads every three or four songs. I don't mind the ads, honestly...they need to make money and I use it without cost to my wallet. That may change someday, but not right now.
The bigger problem with the ads is the fact that they play at 10x volume. Our music is reasonable for the room the speakers are in and then Spotify decides, "Hey, this isn't nearly loud enough. SIGN UP FOR GROCERY DELIVERY! THEN DO THIS SURVEY!"
Kids would cry. Something needed to happen. I told my wife I would write a Chrome extension to block those ads. She laughed and then was confused when I said I was serious. Mutify was born.
Chrome extensions work with three pieces: the manifest, the background script and the content script. Each plays a role in how the extension interacts with the browser and with the pages you visit.
The Manifest
The manifest for this extension is sparse. It loads a couple of icons and, more importantly, defines how the extension can look for ads. To work properly, the extension has permission to interact with tabs and will only run on a URL matching https://*.spotify.com/*. The * is a placeholders, so it will still run if Spotify changes from open.spotify.com to player.spotify.com or even closed.spotify.com. When it sees that URL, the extension will become active.
The Content Script
Content scripts in extensions can see the page you're on but it cannot actually interact with the browser. So, when you're on open.spotify.com a three-line bit of Javascript is run that essentially asks, "Is there an ad playing?" every five seconds. All of the work is done by the background script.
The Background Script
Background scripts are loaded when they're needed and can interact with and change the browser. This is where the work happens. When the background scripts receives the prompt from the content script, it does a quick check of the title of the page.
When an ad plays, the tab title always changes to "Advertisement - Some company" (with the company name changing). So, the background tab just checks for that word in the tab title. If it's there, hey presto, there's an ad playing.
The background script tells Chrome to update the tab status to muted, which cuts the ad out. As soon as the ads are done, the tab title changes back to "Artist - Song" and the extension will unmute the tab.
In all, the code for this little project (not incluing the manifest) was 26 lines of Javascript, including some white space. It isn't published to the Chrome Web Store, so if you want to get a copy, here are the installation instructions.
This little one turns two today.
If you're brand new to unit testing, start with the first post in this series to get caught up.
Up until now, our Calcs class has handled errors with simple true and false flags. That's not helpful to the user. At this point, we're ready to begin defining and testing custom errors in our functions. In this post, we're going to throw an error when the add() method receives an invalid input. To keep it simple, we're going to call anything other than the number type invalid and return an error.
Get the Source
You can see the completed source for this part on GitHub.
QUnit throws
The ``throws` assertion <https://api.qunitjs.com/assert/throws>`__ is more complex than the ok, equal, notEqual methods we've looked at already. throws will call a function and then can have one of four possible expected params:
- An Error object
- An Error constructor to use ala errorValue, instanceof, or expectedMatcher
- A RegExp that matches (or partially matches) the String representation
- A callback function that must return true to pass the assertion check.
With throws, we are able to define not only an error to test, but the kind of error that's returned and even the message received by the test. This is helpful for testing functions that can throw several different types of errors.
We'll start by using the built in Error and TypeError and finish by writing our own CustomError class that you can extend yourself.
Write Failing Tests
To begin, add a new block of tests in tests.gs. Four of these will fail at first and our code will be written to pass each one.
QUnit.test('Checking errors', function() {
throws(function() { throw "error" }, "throws with an error message only");
throws(function() { throw new Error }, Error, 'The error was a generic Error');
throws(function() { throw new CustomError() }, CustomError, 'Creates a new instance of CustomError');
throws(function() { throw new CustomError("you can't do that!") }, "you can't do that!", "Throws with a specific message");
throws(function() { throw new CustomError() }, function(err) { return err.toString() === "There was a problem." }, 'When no message is passed, the default message is returned.');
throws(function() { throw new CustomError("You can't do that.") }, function(err) { return err.toString() === "You can't do that." }, 'Error.toString() matches the expected string.');
});
When writing your tests, the biggest mistake is that the first parameter must be a function call which throws your error. This is becuase it has to get the returned value to pass to the expected parameter.
When you run your tests by reloading the webapp, the first two assertions will pass because they're handled by the browser. You'll get failures for anything calling CustomError because it doesn't exist yet.
Build the CustomError
We need to create an error called CustomError that does four things:
- Raises an instance when called (assertion 3)
- Takes a message parameter (assertion 4)
- Return the default message if not passed (assertion 5)
- Includes a toString method to retrieve the passed message in a callback (assertion 6)
Create a new script file called CustomError and place the following code inside:
var CustomError = function(message) {
this.message = message || "There was a problem.";
}
CustomError.prototype.toString = function() {
return this.message;
}
This is scoped globally instead of namespaced (like the Calcs class) because it doesn't access any restricted services in the Apps Script environment. Any class or method can now access and raise this custom error.
If you re-run your test, all assertions should now pass. Now that it is available, we can go back and start using this error in our Calcs class.
Error Handling in the Class
Because the native Error object is always available, we can access those at any point. In calculations.gs, let's not just return false in our function, let's throw a custom TypeError with a message. Our Calcs.add() test block needs to be modified. I'm going to delete a test that no longer applies because we're going to move away from checking with equal. The old line is commented out:
QUnit.test('Checking the `add` method in Calcs', function() {
ok(Calcs.add(1, 1), 'The method is available and received two variables.');
equal(Calcs.add(2, 2), 4, 'When 2 and 2 are entered, the function should return 4.');
throws(function() { Calcs.add('foo', 2) }, TypeError, 'When a non-number is passed in the first param, the function will return a TypeError.');
throws(function() { Calcs.add(2, 'bar') }, CustomError, 'When a non-number is passed in the second param, the function will return a CustomError.');
});
To pass our tests, we want to update Calcs.add() to throw a TypeError if the first param is not a number and a CustomError if the second is not a number.
Here's a refactored version of the add() method which will pass the test we just wrote:
const add = function(a ,b) {
if(!isNumber(a)) { throw new TypeError }
if(!isNumber(b)) { throw new CustomError('This deserves a custom message.'); }
return a + b
}
This refactor checks a and b independently and throws the specific error to satisfy the assertion statements in the tests. If you run your tests, all assertions should now pass.
The throws method is a powerful tool for testing your exception handling. At a minimum, using the broswer errors can help you give useful information to your users when exceptions occur. throws helps you confidently address each error appropriately before your users run into problems.
Summary
- throws is an assertion that will raise an error and catch the response for testing.
- throws expects a function as the first parameter which will raise the error.
- Define custom errors in the global scope so they can be accessed by all classes and functions.
- Write a test block for any custom errors you create before adding those raised exceptions to your code.
If you're brand new to unit testing, start with the first post in this series to get caught up.
We've looked at how to install and configure QUnit and just finished writing some simple tests. In this post, we're going to write a new method, add(a, b) in Calcs which will add the two passed parameters. Then, we'll use testing to check that the params are numbers before returning either the sum or false. We could make this more complex add allow an object (or array), but we'll write another method for that later to compare.
Write the Test
We know our expected output should be a + b, whatever that happens to be. Let's add a test to tests.gs which will help us write a working function:
function calcTests() {
QUnit.test('Checking the `add` method in Calcs', function() {
ok(Calcs.add(1,1), 'The add method should be available in the module.');
equal(Cals.add(2, 2), 4, 'When 2 and 2 are entered, the function should return 10.');
});
}
We added a new QUnit.test() method in the calcTests() wrapper which defines two tests for our new function. ok checks that the function is available (and not accidentally privately scoped) and equal adds 2 and 2 expecting 4 as the result. Running the test now will produce a failure, which is what you would expect because we haven't written the method yet.
Open calculations.gs and add the add method. Don't forget to return it!
const add = function(a ,b) {
return a + b;
}
return {
name: name,
about: about,
author: author,
add: add,
}
})()
Testing for Errors
We've tried, and passed, two tested conditions, both of which follow the expected use of the function. But, what if a user enters something other than a number? We're going to add a helper function to Calcs which will check a value and return true if it is a number, false if otherwise.
Our function will be called isNumber and here are the tests we'll use for this case:
function calcTests() {
QUnit.test('Checking the `isNumber` method in Calcs', function() {
equal(Calcs.isNumber(2), true, 'The value entered is a number');
equal(Calcs.isNumber('foo'), false, 'The value entered is NOT a number.');
notEqual(Calcs.isNumber('foo'), true, 'The value entered is NOT a number.');
});
}
In this block, we introduce the notEqual assertion which will pass if the returned value is false. We expect true in notEqual because I expect Calcs.isNumber('foo') to return false, making the assertion true and passing. (It's a little hard to wrap your head around at first.)
Writing tests first means they will fail whenever the web app is loaded. As you write the function to pass the test, you're keeping code concise and focusing on one (and only one) outcome, thereby improving maintainability and clarity of your codebase.
const Calcs = (function() {
const isNumber = function(val) {
if(typeof(val) === 'number') {
return true;
}
return false;
}
return {
isNumber: isNumber
}
})
When writing functions to pass tests, first focus on passing. This function could be restructured to use a ternary or some other method of boolean logic, but that doesn't matter right now. We're just focused on satisfying the test conditions. Then we can go back and refactor.
Running your tests should pass all assertions. If not, go back and look at the failures and debug your code.
Handling Private Functions
In certain cases, not all methods need to be exposed to the global namespace. Our isNumber function could certainly be scoped privately because the Javascript core already includes typing (typeof(2) === 'number' // true) which can handle checking.
Testing private methods is tricky and reasons for why you should or shouldn't vary. In applications which compile code with a build process, there are methods for testing private methods. In Apps Script, there is no such build step, so testing private functions becomes more difficult. Here are some considerations:
- Why is the function private? If it is performing a necessary task within the class, consider exposing it to the user.
- Keep private functions simple, like our boolean test, and write tests which require the private function to also pass.
- Use separate helper classes with utility functions that can be tested separately.
In all, the design of your codebase is up to you. Let testing help you make these decisions. Refactoring is much easier because any change you make should still pass the tests you've already written. For clarity, we'll keep isNumber public for now.
Updating Functions
We haven't updated the add() method yet, which is the ultimate goal. Remember, we want to make sure both parameters entered are numbers before trying to add. To start, let's make sure .add() returns false if a non-number is passed into it. Here's our test block:
QUnit.test('Checking the `add` method in Calcs', function() {
equal(Calcs.add('foo', 2), false, 'When a non-number passed in the first param, the function will return false.');
equal(Calcs.add(2, 'bar'), false, 'When a non-number is passed in the second param, the function will return false.');
equal(Calcs.add('foo', 'bar'), false, 'When two non-numbers are passed, the function will return false.');
});
All of these tests may seem redundant, but we want to try and cover each scenario of a non-numer entering our function. Again, writing tests first makes you focus on updating functions to pass. Let's make a change to the add() method which will fulfill that function. Here's our updated method:
const add = function(a ,b) {
if(isNumber(a) && isNumber(b)) {
return a + b
} else {
return false
}
}
Refactoring
At this point, we have all of our tests passing and our application will function as intended. You can now go back and refactor knowing that your tests will fail if you break a function somewhere.
Summary
- Writing tests first helps you solve one - and only one - problem at a time.
- Passing the test is more important (at first) than writing clever code. Once your test passes, you can go back and refactor with confidence.
- All new functions (or changes to existing functions) get their own, explicit test.
- Write multiple tests covering all possible scenarios for failure to make sure you are writing robust and maintainable code.
- Apps Script does not have a build step, so be careful about adding private functions that are difficult to test.
If you're brand new to unit testing, start with the first post in this series to get caught up.
Simple Tests
From part one, unit tests are for single units of code. They test a specific function for a specific result. I found a helpful living guide on writing unit tests that included some very clear expectations:
Unit tests are isolated and independent of each other. Any given behaviour should be specified in one and only one test. The execution/order of execution of one test cannot affect the others.
Let's create a simple class with some properties and methods we can test. We'll use QUnit to write some tests for those methods. Once we've covered the basics, a future post will look at more complex application structures and tests.
Source
The completed source for this part can be found here.
Writing Functions and Tests
Let's start by defining a Calculations class using Bruce Mcpherson's recommended namespacing structure to keep everything neat. If you're following along, create a Script file named calculations.gs in your editor and add the following code.
const Calcs = (function() {
const name = 'Calculation class';
const about = function() {
return 'A class for calculating things';
}
return {
name: name,
about: about,
}
})();
A Note on Naming Tests
Following the testing guide, naming tests clearly is important as their messages will be your guides to problem solving. Each test is given a specific message parameter that has a specific action...should...result format. An named action (calling a class parameter or method) should do something and end in a defined result.
In QUnit for GAS, the result is defined as the expected result in assertions that accept that paramter (keep reading below).
Writing Simple Tests
Now it's time to define some tests. The biggest change in my thinking came when I switched to writing tests first to define what I want the outcome to be before diving in and figuring out if my function is giving me the right output or not. Create a new script file called tests.gs and add the following:
function calcTests() {
QUnit.test('Checking the Calcs class parameters', function() {
ok(Calcs.name, 'The name parameter should be available in the namespace.');
equal(Calcs.about(), 'A class of calculation methods', 'The about method should return the Calcs class description.');
ok(Calcs.author(), 'The author method should return the Calcs class author description.');
});
}
Breaking this block down:
- function calcTests() { ... }: a wrapper which contains several tests. The name is arbitrary, but it should describe what you're testing in general.
- QUnit.test(name, callback): a method requiring two parameters: a name and a callback function. The callback defines specific assertions (or tests) to run.
Inside the test are the specific assertions we're making about the function:
- ok(state, [message]): The simplest test that evaluates the truthy/falsy state of the input. The message parameter is optional.
- equal/notEqual(expected, actual, [message]): Comparisons of expected values with actual returned along with an optional message.
Naming and writing good messaging takes practice and I'm still working on a system that works well for me. The great thing is that if a system isn't working well, just rename it or change the messaging!
The last step before we can run tests is to tell QUnit where to look for those tests in the config file we defined in part one. Open your config.gs file and make sure it looks like this (excluding comments):
QUnit.helpers( this );
function tests() {
console = Logger;
calcTests();
}
function doGet( e ) {
QUnit.urlParams( e.parameter );
QUnit.config({
title: "QUnit for GAS"
});
QUnit.load( tests );
return QUnit.getHtml();
};
What's happening:
- calcTests(), our tests function, is included in the tests() wrapper function in the QUnit config (line 8).
- tests() is loaded into QUnit with QUnit.load(tests) (line 20)
Running Tests
QUnit is run as a web application through apps script. Go to Publish and choose Deploy as web app.... In the popup, set the new version and limit access to yourself.
You'll need to verify the application can have access to your account. Once that is done, you can open your web application link. If you've done your setup correctly, you should see your three test results:
You just ran your first unit tests!
Failing a Test
There are plenty of ways to write failing tests. They fail either because your code doesn't produce the expected value or because your test is expecting something that isn't happening. Let's make a small change to our Calcs class which will cause a test to fail.
In the class, change the .about method to:
const about = function() {
return 'A class of calculation method';
}
Since our test is asserting that this function will return the string, A class of calculation methods, we can expect this test to fail because it will evaluate to false. Run your tests again either by reloading the web app page. Sure enough, we have a failure:
There are a couple things to note from this result:
- The expected result is defined in your test function.
- The actual result and the difference are shown so you can identify the point of failure (and yes, your tests can be the point of failure!)
Since the .about() method fails its test, I know I need to go back and fix the bug. Adding an 's' to 'method' solves the bug. Reloading the page will confirm with a passed test.
Stack traces in QUnit for GAS are marginally helpful. This is because the testing happens on Googles servers, not your computer, so there are several steps in the tooling that add layers of trace data. Some ways to make this more readable are to add code references to your tests file or to have function-based naming so you can find what failed. For this example, we don't have to worry too much, but we'll look into more complex applications at a later point.
Changing Your Code
The whole point of unit testing is that you catch breaking changes before your code is released. Let's make a change to our Calcs class and write a test to make sure that nothing is broken. Start by writing a simple test to define what we want that function to do.
QUnit.test('About Calcs test', function() {
...
ok(Calcs.author(), 'The author method is publicly available');
...
})
...and then add the function to Calcs which will pass the test.
const Calcs = (function() {
const author = function() {
return 'This ' + name + 'is authored by Brian.'
}
...
})
Reload your web app page. What happens?
Your test should have failed (if you followed my code above) with the error, Cannot find function author in object [object Object]. But why?
Something is wrong...the test couldn't find the function author() even though I added it to my class. The explanation is that I never exported that function in the return statement! Since it wasn't exported, the test fails. A potential bug in my application has been caught early and is simple to diagnose and repair before it causes user errors later. Update the return statement in the calculations class to:
...
return {
name: name,
about: about,
author: author,
}
...
...and run the tests again by reloading the web app to see that everything now passes.
Summary
This is the first glimpse into using QUnit inside an Apps Script project. Once the setup is complete, you can start writing tests for what you expect your code to do, which gives you clarity and insight into actually writing the function while knowing your test will catch bugs.
- Tests are grouped into wrapper functions, usually by similarity in purpose.
- Specific tests are run with the QUnit.test() method which takes two parameters:
- A title for the tests
- A callback function defining each type of test
- Tests are passed into a tests() wrapper function in the config file.
- The tests() wrapper is passed into QUnit.load() to run in a web app.
- ok, equal, and notEqual are simple checks for true/false results when the expected and actual results are compared.
I'm not good at writing testable code. I'm more of a 'figure it out when it breaks' kind of hobby programmer. The problem with this is that I am constantly making my own bugs and not really finding them until a bad time.
Unit testing is the process of running automated tests against your code to make sure it's working correctly. Each test is for one unit of code - a single function, usually. It expects a value and will pass or fail based on the value received as part of the test.
To get better, I forced myself to write unit tests in Google Apps Script for two reasons:
- I've been writing a lot of Apps Script code lately,
- There are not many good methods for unit testing in GAS.
This series
The point of this series is to force myself to learn, and use, a unit testing method when writing code and to update the far outdataed unit testing tutorials for Apps Script published online already. I've tried several testing libraries but will be using QUnit as the testing suite.
I'm following Miguel Grinberg's method of posting tutorial code as tagged versions of a GitHub project. Each post will link to a specific tag with the completed source code for that section.
Here's the source for this post
Now, for large projects, you could argue that using clasp and a traditional unit testing library like Mocha or Jasmine is preferable, and you might be right. But, for the purposes of learning, I wanted to keep everything as 'pure' as I could, so all files and tests are written and tests in the online apps script editor.
What is QUint?
It's a testing framework developed and maintained by the jQuery Foundation. It is used in jQuery development to make sure things don't self destruct as the library expands.
QUnit is written for Javascript. Because GAS is based on Javascript, there is a handy library which can be installed in your apps script project.
When testing on your local computer, tests are run by your machine. With Apps Script, everything is run on Google's servers. The QUnit library exposes the framework through a web app that fetches the framework code and executes it when the web app loads.
Install
You can install QUnit for apps script by going to Resources > Libraries in the editor and searching for MxL38OxqIK-B73jyDTvCe-OBao7QLBR4j in the key field. Select v4 and save. Now the QUnit object is available in your project.
Setup
The QUnit library needs some configuration to work with an apps script project. There are three parts to the setup: 1) Declaring QUnit at the global scope, 2) defining tests, and 3) configuring the web application to run the tests.
1. Instantiate QUnit
Once the library is loaded, it needs to be instantiated at the global level to run. Create a new script file called config.gs to hold all of your QUnit code.
The first line should be:
QUnit.helpers(this);
This exposes all assertion methods in the QUnit library (ok, notEqual, expect, etc.) instead of a pared-down object.
2. Define Tests
Tests are defined within wrapper functions that can be passed into QUnit. This tests function will simply hold a list of tests to run when the web application is loaded. We won't be writing any tests in this post but go ahead and add a wrapper for populate later.
function tests() {
console = Logger;
}
3. Web App Config
TheQUnit.config() object declares settings for the web app, so it gets wrapped in the doGet() function. URL params are used to pass information from the app to the testing library with QUnit.urlParams().
QUnit also has a config object which can set default behaviors. You can see a full config object in the project source. For this simple setup, all I'm going to declare is the web app title. Add this to your config.gs file:
function doGet( e ) {
var params = JSON.stringify(e);
return HtmlService.createHtmlOutput(params);
};
Now you're ready to write some code. Running QUnit right now won't do anything; that will come in part 2.
Summary
- QUnit is a testing library developed by the jQuery foundation.
- Google Apps Script is Javascript-like, so a JS testing library can be modified to test Apps Script projects.
- QUnit for Google Apps Script is a library which can be used in the online Apps Script editor.
- It runs with a web app and is defined by a doGet method and a config object.
Our second daughter turns four today.
tl;dr I have a Google Apps Script project getting a major overhaul. If you want to look at the code and contribute, it's on GitHub.
A couple years back, I published a little addon which would scan a Google Doc for linked YouTube videos and allow you to watch them in a popup or sidebar. I called it DocuTube and published without much more thought.
Since writing that app, I've learned a ton more and decided to give it a major overhaul. It was mediocre on the web store with some valid complaints about a lack of clarity and functionality.
As I added functions, I took my time to figure out better ways to structure my code. I followed Bruce McPherson's wonderful advice to add namespacing (isolating functions from one another) to help keep everything tidy. It bent my brain into pretzels, but it was so good to wrestle though. I now have an application that is more manageable and extensible because separate parts are sequestered from one another.
This month, I published an update which adds search, video previews, automatic embedding, and cleans up video playback.
Search
This was the biggest addition (and the most frequent request) in the app listing. This version of DocuTube includes YouTube searching, which allows authors to find - and insert content - they want in the document without managing several hundred tabs.
This is used in schools - I've had several contacts of GSuite admins asking about the permissions and user data storage (minimal permissions, nothing is stored on users) before they pushed it out to their domains. Since studets are (potentially) searching for videos, all searches in DocuTube are forced to safe videos only.
I will not be making that optional.
The YouTube API is quotaed, which means every search counts against your total allowance for the day. To manage this, I cache results heavily. Each search returns 50 videos and they're each stored in the cache as you page back and forth. Embeds are pulled from the cached data and used to populate the preview portion and do the embedding work. That said, my quota is limited to 10,000 units per day. As use increases, I can see the need to apply for more search credits very soon.
Video Previews
Since searching was included, I wanted to be able to provide a way to actually check and make sure the video clicked was the one the user wanted to actually embed.
Clicking on a video gives a playable iframe for a preview. The user can then choose how to attach the video to the document: copy the link to the clipboard (manual paste), insert the thumbnail, or add some custom text. All of this is done with the cached resource so the API isn't hit again to get the link or thumbnail.
Embeds
Google Docs doesn't include an "embed" in the traditional sense. When I use the term embed in the context of DocuTube, I mean it handles the link for you. Inserting a video as a thumbnail grabs the title image, throws it in the document, and then adds the link. You can certainly do the same thing manually with several tabs and clicks.
I think "embed" is an okay term because it leads into the other function of DocuTube: watching videos.
Watch
This hasn't changed a whole lot. The major update from v0.7 (the current version) to v1.0 is that video watching is inclusive. Prior to 1.0, you needed to choose where to pull links from: the document or the comments.
With the 1.0 update, all videos linked in the document, regardless of location, are added to the Watch sidebar. This also removes the option of watching videos in a popup because the whole point of including the video as an embedded item is not leaving the document. If videos are loaded in a popup that takes up the entire editor, I've essentially kicked you out of the document.
If you want to give it a try, you can search from the Docs Addons menu or install it from the GSuite Marketplace. Issues can be sent to brian@ohheybrian.com, posted as an issue on the code itself.
I maintain a website at school where teachers can register for professional development. They can see all offerings and save sessions to their profile for remidners, etc.
The bulk of the data comes through Javascript, which populates the main page in a fancily-styled form. Each course is a form element with a unique ID that is passed to the backend when they register. The database stores the user ID and the course ID in a couple different places to manage all the reminders.
Because each course is a JSON object, you cannot just send a link for a specific course, which is a problem when you sometimes have dozens on the screen. That means they're either having to remember to search (which matches titles) or scroll until they find the right session. Coordinating a group of people becomes difficult.
Since each of my form elements has a specific ID, I decided to use a URL query to automatically select the correct element. I can also build a specific URL for each session and add it to the element when it's built on the page. Double win. Here's how it works.
The URL
You can call window.location.search to pull any query parameters from a URL (anything after a ? character). The browser also has a handy URLSearchParams function that allows you use methods like .has() and .get() to make them more accessible in the code. With some Javascript, I can pull the keys from the URL when the page loads and then kick off another action.
I want to pass a specific course ID to the querystring for the user. So, my URL becomes:
https://mysite.com/?course=abc123
Acting on the query
The normal page load scripts didn't need to change much. I added a quick conditional to check if the URL included a query string with the .has() method I mentioned above. I can specify which key to look for, which makes extending this function easier in the future.
if (urlParams.has('course')) {
if (urlParams.get('course') === course.key) {
document.querySelector(`input[name='course'][value='${course.key}']`).checked = true;
window.location.hash = `#${course.key}`;
loadSubmitBadge();
}
}
If a query is passed in the URL, that course gets a checkbox pre-selected and focused on the screen for the user. They're free to either select more courses or just hit sbumit and be on their way.
Adding the link
The last step was to add a link icon to each course that could be copied and sent in an email. The course IDs are random and not easy to remember, so they needed to be provided to the users.
This was one line in the constructor:
div.querySelector('.course-share-link').href =`https://mysite.com/?course=${course.key}`
I didn't want users jumping to the link when they clicked the button, so I needed one more little function to catch that click, prevent the redirect, and then copy the URL to the clipboard for easy pasting:
async function copyToClipboard(e) {
e.preventDefault();
try {
await navigator.clipboard.writeText(e.target.parentNode.getAttribute('href'));
alert('Workshop URL copied to clipboard');
} catch (err) {
alert('Failed to copy: ', err);
}
}
navigator.clipboard.writeText() is a new asynchronous browser method, which is why I call await inside the try...catch block. It prevents browser errors from showing to the user.
This method should work for any HTML form as long as your form elements are unique IDs (or names) that can be targeted.
A few times a week, we sing hymns with the kids before bed. I get the guitar out and we practice their favorites. The lyrics are Bible-based truth and my kids have always done great with song as a memory tool. They can probably sing more of a hymn from memory than I can at this point, to be honest. (If you're interested, here's a great list to start with from The Gospel Coalition.)
Part of that time is "silly time" where they make up a situation and I think up a song on the spot. Most situations have to do with an unfortuntate encounter between two incompatible animal species (the alligator climbed the tree and ate the raccoon, for instance). Other times, it's about the dog (Jo's Song) or the baby (I get back to my metal days and we all scream).
But there's also The Monkey Took His Banana to the Water, which is a regular in the rotation. It's got a Johnny Karate vibe and will stick in your ear for days.
The Monkey Took His Banana to the Water
195 BPM
Verse
Oh the monkey went down to the water
And he took his banana with him to the water
When he came out of the water, his banana was wet
Chorus
It was wet wet wet
It was wet wet wet
It was wet wet wet
It was wet wet wet
Repeat V1
Instant classic.
The featured image is my own from 2013. I only had one daughter at that time and my wife's cousins would come over to play guitar together. This living room has had a lot of music in it since we moved in.
Thank you for this post! Do you know if its possible to add an already existing Google Meets link to the payload instead of requesting for a new one? I found the .entryPoints method in the google documentation but cannot figure out how to implement it to save my life.
Yeah, you can add existing meetings, but you have to use the 10-character Meet ID (aaa-bbbb-ccc)…you can’t use a nicknamed Meet, unfortunately. The conferenceData portion of the payload changes from createRequest, which generates all the necessary information asynchronously, to defining a
and object.This will post a calendar event with your specified Meet.
Thanks for this. I have generating events working. Now, I want to pull the Google Meet conference details including url and dial in number/pin from the generated events using script. Is that possible?
The response object from the creation call has all of those details. You can add a block to handle that data to write back to the sheet, send an email, etc….What are you trying to do with it if its attached to the calendar already?
Is this just for adding a specific Meet code to an event that has NO meet data at all or has an existing Meet code and you’re just changing it? I have a big script using this code already but haven’t cracked the nut on how to change codes.
Hi Brian,
I’m also interested in grabbing the hangoutLink, which is easily done from the response, and what I’m wanting to do is patch/update the original description to append the hangoutLink / URL to the description. This is simply so that the URL is available through embedded views of the calendar. I’m struggling to find good docs on using Patch through Apps Scripts though; any pointers?
Paul,
In the example in the post, you can get the response in a
block that allows you to update more information. I would do something like this:Note that I haven’t tested this, so make sure not to overwrite something important.
Many thanks Brian – that worked perfectly!