<span class="mw-page-title-main">Google calendar</span>
Fabrice P. Lauss𝕪s ygentoman Web

Google Calendar

My calendar, on my wiki, in two states: whole at home, and reduced to its bare occupancy online—the hours that are taken, and not one word about what takes them.

The frame below is Google's own calendar/embed, put together by Widget:Google calendar. It is built on every machine of minelocalhost, azag, yzao, whatever comes next—and on none but those, because the calendar is private and the frame is of no use to anybody else: Google answers a stranger with a permission notice and nothing more. The widget decides by naming the one host that is public rather than listing the several that are not, which is the way round that does not rot: the first version tested for localhost, so browsing my own wiki as azag—which is how I actually browse it—took the public branch and drew the redacted grid at home.

(the calendar, loading)

On laussy.org the same widget draws something else entirely, and Google is not called at all—which is the polite way round, since a public page should not be quietly fetching from a third party on a visitor's behalf. It reads cal/busy.json, a file on my own server holding nothing but pairs of times, and paints them as a month of little bars: the spots of the day that are occupied. Add ?cal=busy to this address to see that view from any machine of mine—with invented hours, since the script inside Google posts to laussy.org and never to this machine, and what sits in cal/busy.json at home is a fixture kept to check the drawing. ?cal=embed forces the frame back.

What travels, and what does not

Nothing but times, unless I have opened an entry on purpose (below). No title, no location, no guest, no description, no colour, no number of guests, no notion of what any of it was—those never leave Google at all, so they cannot be sitting on a public server by an oversight, and there is no setting anywhere whose mis-click would publish them.

The intervals are also merged before they go, which matters more than it looks: unmerged, the file would quietly say how many separate appointments an afternoon holds, and three back-to-back half hours read very differently from one long meeting. Merged, the page can say taken and stop there. Declined invitations, and anything the calendar itself counts as free, are not taken.

The window is the last six months and the coming twelve, and that is another thing this arrangement buys. Had I instead ticked Google's own see only free/busy and let the public embed do the hiding, there would be no window at all: whoever opened the page could page back to 2009, forward to 2031, or—the part that decided it—subscribe to the live feed in their own calendar and watch my occupancy from then on, for good. A file I write is a file I can bound.

Opening one on purpose

Some things I am happy to say. A seminar I am giving, a conference I am at, a week I am away—those are the answer to the question a reader of this page is asking, and the grid alone gives only its shadow. So an event can be opened, one at a time, deliberately, and then it carries its title.

Three gestures do it, any one of them:

  • set the event's Visibility to Public—Google's own field, made for exactly this and meaning exactly this;
  • begin the title with **Seminar on polaritons. The asterisk never appears on the page, and it has the merit of showing at a glance, in every view of my calendar, which entries are open; or
  • give it the colour Sage or Basil—the two greens.

The colour that was not a colour

The green took some finding, and the finding is worth keeping. I wanted pistachio, a colour Google had plainly offered me when I set the event's colour, and having set it, nothing was published. Nor did the script see anything: getColor() answered empty for that event and for every other on the calendar.

It was not the script. Asked through the Calendar API itself, which sees what CalendarApp cannot, the answer was the same and flatter: nought events carry a colour at all. And the event palette, printed out, is eleven colours and no more—1 Lavender, 2 Sage, 3 Grape, 4 Flamingo, 5 Banana, 6 Tangerine, 7 Peacock, 8 Graphite, 9 Blueberry, 10 Basil, 11 Tomato. No pistachio anywhere.

Pistachio belongs to the other palette, the twenty-four colours a whole calendar may wear, and mine wears it. That is why it was offered: the swatch at the top of an event's colour menu is the calendar's own colour, carrying the calendar's own name, and choosing it means this event has no colour of its own. Picking pistachio wrote nothing to the event, and wrote nothing very convincingly, since the menu had said pistachio and the event had turned pistachio. A tag that every event already wears is not a tag.

So the tag is Sage or Basil, and the script reads colours through the Calendar API rather than CalendarApp, which is blind to anything outside its eleven. If the advanced service is ever missing, colour tagging quietly does nothing rather than quietly doing something—the right way round for a switch that decides what is public: it fails shut.

An open entry now gives up everything: its title, its location, its text, and the names of the people coming. What keeps that from being reckless is not a shorter list of fields but three rules of a different kind.

It must be mine. isOwnedByMe() is asked before any tag is even looked at. An event I was merely invited to carries somebody else's words—their agenda, their joining link, their passcode—and a colour of mine is no licence to publish them. An event whose guests may edit it is refused too: they could rewrite it after I had read it. This is the rule that made the rest safe enough to write.

Names, never addresses. A guest travels as a display name or not at all. An address belongs to the person it reaches, who did not agree to appear on a public page; a name with no space in it is dropped as well, on the suspicion that it is a local part in disguise. The cost is that a mononym never publishes, which I accept.

And nothing is published until I have read it. See below—it is the part I care about most.

A conferencing link in the location is dropped whatever else happens, because the passcode rides in the url and a location is the one field a page is forever tempted to make clickable. The description is flattened out of Google's HTML into plain lines: <script> is stripped, entities are decoded exactly once—a second decode is how a defanged tag comes back to life—and a run of forty lines or fifteen hundred characters is where it stops.

The occupancy is unchanged by any of this: an open event is in the merged busy list like every other, and in open with its words. That is on purpose—if opening an event removed it from the occupancy, the gap would be its own announcement, and a reader could tell that something was being withheld on precisely the days I had thought about it hardest.

On the page they are drawn as Google draws them, because the page is now a week, laid out the way a calendar is: the hours down the left, the days across, and every entry a box occupying exactly the time it takes. A private one is a hatched block with nothing to say; an open one is solid, and carries its hour and its title. The difference is legible at a glance and needs no legend, which is the point—the first version drew the day as a thin horizontal bar, and a bar is a diagram of a day rather than a day.

An all-day entry sits in a row of its own above the grid, in a reddish of its own so it is never mistaken for an hour, and it washes the whole column beneath it—a day taken whole should look taken whole, not like a bar at the top and then a lot of free afternoon. A run of days is one bar spanning them, which the month grid could never manage: a fixed-layout table has no spanning mechanism, and CSS grid does. In the month, a day is a way into its week—click it and the week opens. A red line marks the hour it is now. The screen holds eight in the morning to six in the evening—the day as it is actually lived—and the small hours are a scroll away rather than half the height of the page; a line under the grid says how many blocks lie outside that window, and takes you to them, because a calendar that hides an appointment is worse than one that is ugly. There is a month button for when eighteen months need crossing rather than a week read.

The chrome is Google's, near enough, because that is what a hand already knows: a Today pill, a pair of chevrons, and the title itself as a door to a month you can point at—a mini calendar with a dot on every day that has something on it, arrows that stop at the ends of the published window, and the arrow keys working the way they work in every date picker there has ever been. Its own title opens the twelve months of a year, and then the arrows step years—eighteen months is a long way at one month a click. The colours are not Google's, though: they are the page's own. Aliceblue behind the days, which is what laussy.org paints behind everything; lightsteelblue for the rules, which is the border of the box you are reading this in; and the blue of an af tag for whatever is open, which is the blue this wiki uses for exactly that. Only the all-day band stays warm, so that it can never be read as an hour. The lettering is a sans face—this is the one thing on the wiki that is an instrument rather than a page—and one token, --gc-sans: inherit, puts it back to the Georgia of the prose.

Clicking a box opens a card with the whole of it, and two ways to take it away: add a copy to Google Calendar, which is the same action=TEMPLATE address af2cal uses to put things in (the round trip closes), and download an .ics for any other calendar. A run of days is a filled bar across its cells.

And the whole of it can be subscribed to. cal/open.ics is a real iCalendar feed, rewritten on every push, and one webcal:// link offers it: my public dates arrive in a reader's own calendar and stay right as I change them. The two are labelled apart on purpose—a subscription follows me, a copy does not, since TEMPLATE has no UID and always mints a disconnected event.

Getting that file correct took more care than the page did. It is folded at seventy-five octets rather than characters, so a Japanese character or an emoji is never cut in half; the escapes go in with the backslash first, or they escape each other; an all-day entry ends on the day after the last one, because iCalendar's end is exclusive; the identity of each event is the per-occurrence id, so a weekly seminar is fifty-one entries and not one, and re-publishing updates a subscriber's copy instead of duplicating it. An event that straddles midnight is two bars in the grid and one entry in the feed.

An accent nearly cost me this, in the same way it nearly cost me af2cal. Every push had been faultless until titles began to travel; then one came back 401 signature out of a clear sky, with three good ones either side. Apps Script had been handed the body as a string both to sign and to send, and the two operations do not always agree on an encoding—a disagreement that is invisible until some title carries an é, and my calendar is full of Spanish. Signing the bytes and posting the same bytes settles it; Nothing happening at the Café de la Luz now makes the trip whole. The text is the only thing on that page that came from outside, so it is treated as such: push.php flattens it to one line—a title arriving with newlines is a title with something hidden after them—drops control characters and angle brackets, and cuts it at 120 characters; the widget then renders it with textContent and escapes anything that goes near an attribute. A title of <script>alert(1)</script> reaches the page as the harmless string it deserves to be.

Words the page keeps

An open entry says what it says in Google, and Google is a poor place to write: a box with no links in it, no tags, no italics, nothing that can point at the page of mine which says more—and then every word typed there has to pass the gate below before a stranger may read it.

So the page may keep the words instead. {{Calendar entry}} writes a block of ordinary wikitext, keyed to an entry by its Anno Fabri tag, and nothing at all shows where it is written:

{{Calendar entry|af=0Boom|text=The tag above is the one af2cal wrote into the description.}}

The widget takes that block and puts it in the card of the entry it names, so a card may carry a link, an image, a template, a tag with its tooltip—anything a page can carry, and for the plainest of reasons: none of it was built by the script. MediaWiki had already parsed it before the calendar was drawn, and all the card does is copy a node it found. The entry of the eighteenth of September has one; click it.

The tag is the key, and that is the round trip closing on itself. af2cal writes AF: 0Boom into the description; the description travels with an entry I have opened; the widget reads the tag back off it and looks on the page for a block that names it. Nothing else has to agree with anything—move the meeting, rename it, and the words still find it. An entry made in Google by hand, carrying no tag, is named instead by its date, and by its hour if the day holds more than one.

A note may correct the title and the place—Google stores a pair of coordinates when I drop a pin, and nobody wants to read those. Its words go under the ones the calendar holds, or take their place with instead=yes. What it may never do is touch an hour: the grid and the card are both drawn from the file, and a page that could argue with the calendar about when something is would be worse than a page that said nothing.

None of this passes the gate, and none of it needs to. The gate is there because words from Google arrive on their own, without my having looked at them; words written here are on a public page by definition, put there by an edit of mine and by nothing else.

The tag is now drawn as a tag. The line AF: 0Boom no longer sits among the words of an entry: it appears as 0Boom (af) under the hour, and hovering it says the second it stands for. That arithmetic is not in the widget—it is the aftool gadget, which every page here already carries for the two machines on AF, and which now hands its decoder out instead of taking it home.

The one thing that fails quietly is a tag with a typo in it: the note simply never appears, which is also exactly what a note written before its event looks like, so the widget cannot tell them apart and does not try. ?notes=1 lists every note on the page and says which entry took it.

0Boom (af)

The entry that proved this one: made from the wiki with af2cal, opened on purpose, and now saying more here than it ever said in Google. The tag above it is the five characters af2cal put in its description—hover it for the second it names.

The gate

Between Google and the page there is a room with a door, and the door is shut.

push.php validates what arrives and then does not publish it. The words go to cal-private/pending.json, a file outside the webroot altogether, mode 600, which no url can reach by any spelling. The occupancy—the anonymous blocks, the thing that was already public—is written out at once, exactly as before. Only the words wait.

AUTO is where I read them. It shows the text as the page would show it, not as Google holds it: flattened, scrubbed, truncated, the guests already filtered. Then y or n, one entry at a time, and what I approve is a fingerprint of those exact words. Move a meeting I have cleared and it stays cleared; change a syllable of it and it comes back to me. Nothing else can put words on that page—not a trigger, not a mistake in the script, not somebody else's invitation.

The consequence I like best is what the gate looks like from outside: nothing. An entry waiting to be read is drawn exactly like an entry that was never meant to be public—an anonymous block, like all the others. The page does not say "something is pending here", because a page that says that has told you something.

How it gets there

A script inside Google Calendar, at script.google.com, with an installable trigger on the calendar. Adding, moving or deleting anything fires it; it reads the events, throws everything but the start and end away, merges what is left, and posts it to cal/push.php here, signed with a key that lives in PrivateSettings.php on each machine and is never synced. A second trigger runs the same thing every day at four in the morning, in case a fire is ever missed. The foot of the calendar says when it last reported in, so a trigger that has quietly died is visible on the page rather than being something to wonder about.

The far end does not take the script's word for it. push.php validates each interval and then rebuilds the file from the fields it has checked, so what is written is only ever a version, a zone, a window and a list of times. A title cannot be stored by a script that has been changed, nor by anyone who has stolen the key: there is no field on this side to put one in. The redaction is a shape enforced on the server, not a promise made in Google.

The way in

Things reach the calendar from here by their Anno Fabri tag, with af2cal: af2cal 0Boom coffee at the Café de la Luz opens the new-event form with the right Friday, the right second and the tag already in the description, and one click on Save does the rest. The tag is the whole point of the round trip—the event, the equation label and the code comment all end up carrying the same five characters, so af 0Boom tells me what a line in the calendar was about, and the calendar tells me what I was doing the day I wrote the line.

The tag rides in the description, so for an entry I have not opened it never travels: nobody online can read 0Boom off my calendar, only that the Friday afternoon it names is spoken for. For an entry I have opened the description goes with the rest of it, and the page makes something of the tag rather than leaving it lying there—it is drawn as a tag, it carries the moment it stands for, and it is the key the page's own words are filed under (above).

The way back

The other direction—the calendar writing into the wiki, so that an AF-tagged event turns into a line of the diary by itself—is still not done, but it is now a short step rather than a long one: the script above already reads every event inside Google and already knows how to post here. It would want a second endpoint that accepts tagged events in full, and the same rule as ever—let nothing that is not tagged travel at all.

The script

One edge nearly went unnoticed. An event ending exactly at midnight formats as 00:00 of the next day, so the interval it produced straddled two dates—and push.php, quite rightly, will not store an interval whose ends fall on different days, since the grid draws one day per cell. The first real push came back 400 {"error":"interval"} and the calendar had at least one such event. Midnight now closes the day it ends, at 23:59; and since the same formatting collapses a thirty-second event to a single stamp, the ends are compared as stamps rather than as dates, so a sub-minute event is dropped instead of being sent as a zero-length one.

/**
 * gcal2busy — v1.4.1 (22 Aug 2026)
 *
 * Lives inside Google Calendar, at script.google.com, bound to nothing but the
 * calendar itself. An installable onEventUpdated trigger fires whenever an
 * event is added, moved or deleted; a daily trigger runs the same thing at 4am
 * in case a fire was ever missed. Each run reads the calendar, throws every
 * title, location, guest and description away, merges what is left into bare
 * stretches of taken time, and posts that to laussy.org.
 *
 * The redaction happens HERE, before anything travels: a title is never
 * carried across the wire and so can never be sitting on a public server by
 * mistake. cal/push.php on the far side rebuilds the file from validated
 * fields only, so this script is not trusted to have kept its word either.
 *
 * v1.1.0 lets me open a single event on purpose. Two gestures do it, and both
 * are deliberate enough that neither happens by accident:
 *
 *   - set the event's Visibility to Public (its own field, made for this), or
 *   - begin its title with the marker below, which has the advantage of
 *     showing at a glance, in every view, which events are open.
 *
 * An open event still gives up only its title and its location. The
 * DESCRIPTION never travels under any setting: it is where af2cal writes the
 * AF tag and where I write notes to myself.
 *
 * Setup, once: paste this in, fill the three constants, run setup().
 */

var ENDPOINT  = 'https://laussy.org/cal/push.php';
var PUSH_KEY  = 'PASTE-THE-KEY-HERE';      // the $wgLaussyCalPushKey of PrivateSettings.php
var CALENDAR  = '';                        // '' = the default calendar; or an id

var BACK_MONTHS = 6;                       // how far into the past to publish
var FWD_MONTHS  = 12;                      //  ... and into the future

// What makes an event public: 'visibility', 'marker', 'both', or 'none'.
// 'colour' is offered too but is NOT in 'both' on purpose — recolouring an
// event is a careless gesture, and this is the one decision that must never be
// made carelessly.
var PUBLIC_BY      = 'any';                // visibility, or the marker, or a colour
var PUBLIC_MARKER  = '*';                  // a title beginning with it is open
// Colour ids that mean open. Google's EVENT palette is eleven and only eleven:
//   1 Lavender  2 Sage  3 Grape  4 Flamingo  5 Banana  6 Tangerine
//   7 Peacock   8 Graphite  9 Blueberry  10 Basil  11 Tomato
// "Pistachio" is not among them — it belongs to the 24-colour CALENDAR palette,
// and it appears at the top of an event's colour menu because that swatch is
// the calendar's own colour. Choosing it writes NOTHING to the event, which is
// why colouring one that way published nothing. Both greens are taken here so
// that either reads as open. Run whatsOn() to see the live palette.
var PUBLIC_COLOURS = ['2', '10'];          // Sage and Basil
var PUBLISH_WHERE  = true;                 // the location too, when there is one

/* ------------------------------------------------------------------ */

function cal_() {
  return CALENDAR ? CalendarApp.getCalendarById(CALENDAR) : CalendarApp.getDefaultCalendar();
}

function ymd_(d, tz) { return Utilities.formatDate(d, tz, 'yyyy-MM-dd'); }
function stamp_(d, tz) { return Utilities.formatDate(d, tz, "yyyy-MM-dd'T'HH:mm"); }

/** Midnight of the day after d, in the calendar's own zone. */
function nextMidnight_(d, tz) {
  var s = ymd_(d, tz).split('-');
  var day = new Date(d.getTime());
  // Walk forward in hours until the date string changes, then trim back to
  // the top of that hour and again to the minute. Crude, but it is right
  // across a summer-time change, which arithmetic on 86400000 is not.
  var i;
  for (i = 0; i < 30; i++) {
    day = new Date(day.getTime() + 3600000);
    if (ymd_(day, tz) !== ymd_(d, tz)) { break; }
  }
  var h = Number(Utilities.formatDate(day, tz, 'HH'));
  var m = Number(Utilities.formatDate(day, tz, 'mm'));
  return new Date(day.getTime() - (h * 3600000) - (m * 60000));
}

/**
 * An event may straddle midnight; a day in the grid may not, and push.php
 * refuses an interval whose two ends fall on different dates. Two edges bite:
 * an event ending exactly AT midnight formats as 00:00 of the NEXT day, and a
 * sub-minute event formats to the same stamp at both ends. Both are settled
 * here, on the stamps themselves — comparing the Dates would let a 30-second
 * event through as a zero-length one.
 */
function split_(a, b, tz, out) {
  var guard = 0;
  while (guard++ < 400) {
    var mid = nextMidnight_(a, tz);
    var done = (b <= mid);
    var sa = stamp_(a, tz);
    var sb = stamp_(done ? b : mid, tz);
    if (sb.slice(0, 10) !== sa.slice(0, 10)) {   // midnight closes the day it ends
      sb = sa.slice(0, 10) + 'T23:59';
    }
    if (sb > sa) { out.push([sa, sb]); }         // nothing shorter than a minute
    if (done) { return; }
    a = mid;
  }
}

/**
 * Merge overlapping and touching intervals. This is not tidiness: unmerged,
 * the file would say how many separate appointments an afternoon holds, and
 * three back-to-back half hours read very differently from one long meeting.
 * Merged, the page can say "taken" and nothing else.
 */
function merge_(ivs) {
  ivs.sort(function (p, q) { return p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0; });
  var out = [];
  for (var i = 0; i < ivs.length; i++) {
    var last = out[out.length - 1];
    if (last && ivs[i][0] <= last[1] && ivs[i][0].slice(0, 10) === last[0].slice(0, 10)) {
      if (ivs[i][1] > last[1]) { last[1] = ivs[i][1]; }
    } else {
      out.push([ivs[i][0], ivs[i][1]]);
    }
  }
  return out;
}

/** Declined, and anything the calendar itself counts as free, is not busy. */
function counts_(ev) {
  try {
    if (ev.getMyStatus() === CalendarApp.GuestStatus.NO) { return false; }
  } catch (e) { /* an event with no guests has no status; it counts */ }
  try {
    if (ev.getTransparency && ev.getTransparency() === CalendarApp.Transparency.TRANSPARENT) {
      return false;
    }
  } catch (e) { /* older runtimes have no transparency; it counts */ }
  return true;
}

/** Is this one I have deliberately opened? */
function open_(ev) {
  var by = PUBLIC_BY;
  if (by === 'none') { return false; }
  if (by === 'visibility' || by === 'both' || by === 'any') {
    try {
      if (ev.getVisibility() === CalendarApp.Visibility.PUBLIC) { return true; }
    } catch (e) { /* an older runtime has no visibility; fall through */ }
  }
  if (by === 'marker' || by === 'both' || by === 'any') {
    if (title_(ev).indexOf(PUBLIC_MARKER) === 0) { return true; }
  }
  if (by === 'colour' || by === 'any') {
    try {
      var c = colourOf_(ev);               // '' when the event wears the
      if (c) {                             // calendar's own colour
        for (var i = 0; i < PUBLIC_COLOURS.length; i++) {
          if (String(c) === String(PUBLIC_COLOURS[i])) { return true; }
        }
      }
    } catch (e) { /* an older runtime has no colour; never mind */ }
  }
  return false;
}

function title_(ev) {
  try { return String(ev.getTitle() || '').trim(); } catch (e) { return ''; }
}

/** The title as it should be read, without the marker that opened it. */
function label_(ev) {
  var t = title_(ev);
  if (t.indexOf(PUBLIC_MARKER) === 0) { t = t.slice(PUBLIC_MARKER.length).trim(); }
  return t.slice(0, 120);
}

function where_(ev) {
  if (!PUBLISH_WHERE) { return ''; }
  try { return String(ev.getLocation() || '').trim().slice(0, 80); } catch (e) { return ''; }
}

/**
 * Colours, read the hard way. CalendarApp.getColor() answers '' for anything
 * outside the eleven EventColor values it knows, which is why a Pistachio
 * event looked exactly like an uncoloured one. The Advanced Calendar Service
 * hands back the raw colorId instead, so that is what we index here: event id
 * -> colorId, for the whole window, in one call.
 *
 * Needs the Calendar advanced service enabled in the project (Services +).
 * Without it this returns an empty index and colour tagging simply does
 * nothing, which is the right way round for a thing that decides what is
 * public: it fails shut, never open.
 */
function colourIndex_(from, to) {
  var idx = {};
  if (typeof Calendar === 'undefined' || !Calendar.Events) { return idx; }
  try {
    var id = CALENDAR || cal_().getId();
    var page = null, token = null, guard = 0;
    do {
      page = Calendar.Events.list(id, {
        timeMin: from.toISOString(), timeMax: to.toISOString(),
        singleEvents: true, maxResults: 2500, pageToken: token,
        fields: 'items(id,colorId),nextPageToken'
      });
      var it = page.items || [];
      for (var i = 0; i < it.length; i++) {
        if (it[i].colorId) { idx[it[i].id] = String(it[i].colorId); }
      }
      token = page.nextPageToken;
    } while (token && guard++ < 20);
  } catch (e) {
    Logger.log('colourIndex_: ' + e);
  }
  return idx;
}

var COLOURS = {};   // filled by collect_() and whatsOn() before open_() is used

function colourOf_(ev) {
  try {
    var id = ev.getId().replace(/@google\.com$/, '');
    if (COLOURS[id]) { return COLOURS[id]; }
  } catch (e) {}
  try { return ev.getColor() || ''; } catch (e) { return ''; }
}

function collect_() {
  var cal = cal_();
  var tz = cal.getTimeZone();
  var now = new Date();

  var from = new Date(now.getTime()); from.setMonth(from.getMonth() - BACK_MONTHS);
  var to   = new Date(now.getTime()); to.setMonth(to.getMonth() + FWD_MONTHS);

  COLOURS = colourIndex_(from, to);
  var events = cal.getEvents(from, to);
  var timed = [], days = {}, open = [], openday = [];

  for (var i = 0; i < events.length; i++) {
    var ev = events[i];
    if (!counts_(ev)) { continue; }
    if (ev.isAllDayEvent()) {
      // getAllDayEndDate() is exclusive, the way Google stores it.
      var d = ev.getAllDayStartDate();
      var end = ev.getAllDayEndDate();
      var first = null, last = null, guard = 0;
      while (d < end && guard++ < 400) {
        var k = ymd_(d, tz);
        days[k] = 1;
        if (first === null) { first = k; }
        last = k;
        d = new Date(d.getTime() + 86400000);
      }
      if (first !== null && open_(ev) && label_(ev)) {
        openday.push({ d: first, u: last, t: label_(ev), w: where_(ev) });
      }
    } else {
      // The redacted interval goes in whatever happens next: the occupancy is
      // the same whether an event is open or not, so a gap never tells anyone
      // that something was withheld.
      split_(ev.getStartTime(), ev.getEndTime(), tz, timed);
      if (open_(ev) && label_(ev)) {
        var mine = [];
        split_(ev.getStartTime(), ev.getEndTime(), tz, mine);
        for (var j = 0; j < mine.length; j++) {
          open.push({ a: mine[j][0], b: mine[j][1], t: label_(ev), w: where_(ev) });
        }
      }
    }
  }

  var allday = [];
  for (var k in days) { if (days.hasOwnProperty(k)) { allday.push(k); } }
  allday.sort();

  return {
    v: 2,
    tz: tz,
    from: ymd_(from, tz),
    to: ymd_(to, tz),
    busy: merge_(timed),
    allday: allday,
    open: open,
    openday: openday
  };
}

function hex_(bytes) {
  var s = '';
  for (var i = 0; i < bytes.length; i++) {
    var b = (bytes[i] + 256) % 256;             // Apps Script hands back signed bytes
    s += (b < 16 ? '0' : '') + b.toString(16);
  }
  return s;
}

/** The one thing this script does. Both triggers call it. */
function pushBusy() {
  var lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) { return; }        // a burst of edits, one push
  try {
    var body = JSON.stringify(collect_());
    var ts = String(Math.floor(Date.now() / 1000));

    // Sign the BYTES, and send the same bytes. The string overloads of
    // computeHmacSha256Signature and of payload do not always agree on an
    // encoding, and the disagreement only shows once a title carries an accent
    // — which is why the pushes were faultless until v1.1.0 started sending
    // titles, and then one came back 401 "signature" out of a clear sky. The
    // same trap as the Latin-1 'é' that nearly escaped in af2cal.
    var bytes = Utilities.newBlob(body).getBytes();
    var sig = hex_(Utilities.computeHmacSha256Signature(
      Utilities.newBlob(ts + '|' + body).getBytes(),
      Utilities.newBlob(PUSH_KEY).getBytes()));

    var r = UrlFetchApp.fetch(ENDPOINT, {
      method: 'post',
      contentType: 'application/json; charset=utf-8',
      payload: bytes,
      headers: { 'X-Cal-Ts': ts, 'X-Cal-Sig': sig },
      muteHttpExceptions: true,
      // A redirect can silently drop the signing headers and turn into a
      // baffling 401; better to see the 3xx itself.
      followRedirects: false
    });
    var code = r.getResponseCode();
    Logger.log('push ' + code + ' ' + r.getContentText().slice(0, 300));
    if (code !== 200) { throw new Error('push refused: ' + code); }
  } finally {
    lock.releaseLock();
  }
}

/**
 * A look at what the calendar actually says. Google names its event colours
 * (Tomato, Sage, Basil...) but the API answers with an id, and the names in
 * the picker do not always match the eleven the API knows about — so read the
 * id off a real event rather than guessing which one "pistachio" is, and put
 * it in PUBLIC_COLOURS above. Run it from the editor and read the log.
 */
function whatsOn() {
  var cal = cal_(), tz = cal.getTimeZone();
  var a = new Date(Date.now() - 86400000);
  var b = new Date(Date.now() + 5 * 86400000);
  COLOURS = colourIndex_(a, b);
  var evs = cal.getEvents(a, b);
  Logger.log('PUBLIC_BY=' + PUBLIC_BY + '  PUBLIC_COLOURS=' + JSON.stringify(PUBLIC_COLOURS));
  Logger.log('advanced Calendar service: ' + (typeof Calendar === 'undefined' ? 'NOT ENABLED' : 'on') +
             '; ' + Object.keys(COLOURS).length + ' event(s) carry a colorId');
  try {
    Logger.log('this calendar\'s own colour is ' + cal.getColor() +
               ' — the swatch at the top of an event\'s colour menu, which means "no event colour"');
  } catch (e) {}
  try {
    var pal = Calendar.Colors.get();
    var ids = [];
    for (var k in pal.event) { ids.push(k + '=' + pal.event[k].background); }
    Logger.log('the event palette: ' + ids.join('  '));
  } catch (e) { Logger.log('palette: ' + e); }
  Logger.log(evs.length + ' event(s) from yesterday to five days out:');
  for (var i = 0; i < evs.length; i++) {
    var e = evs[i], c = '?', v = '?';
    try { c = colourOf_(e) || '(the calendar\'s own)'; } catch (x) {}
    try { v = String(e.getVisibility()); } catch (x) {}
    Logger.log('  ' + stamp_(e.getStartTime(), tz) +
               '  colour=' + c + '  visibility=' + v +
               '  OPEN=' + open_(e) + '  ' + JSON.stringify(title_(e).slice(0, 40)));
  }
}

/** Run once, by hand, from the editor. Authorise when asked. */
function setup() {
  var mine = ScriptApp.getProjectTriggers();
  for (var i = 0; i < mine.length; i++) {
    if (mine[i].getHandlerFunction() === 'pushBusy') { ScriptApp.deleteTrigger(mine[i]); }
  }
  var who = CALENDAR || Session.getEffectiveUser().getEmail();
  ScriptApp.newTrigger('pushBusy').forUserCalendar(who).onEventUpdated().create();
  ScriptApp.newTrigger('pushBusy').timeBased().everyDays(1).atHour(4).create();
  pushBusy();
  Logger.log('triggers set for ' + who + '; first push done');
}