<span class="mw-page-title-main">Google Calendar on Mediawiki</span>
Fabrice P. Lauss𝕪s ymoid Web

Google Calendar on Mediawiki

My Google calendar is available to various extents on laussywiki, mainly in two states:

  • whole at home (azag or izao).
  • reduced to its bare occupancy online.

"Bare occupancy" means the hours that are taken, and not one word about what takes them.

In images, that's how it looks to me:

And that's how it looks to you:

The frame below is Google's own calendar/embed, put together by Widget:Google calendar. It is built only when the page is served from localhost, 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 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 here—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. 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.

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, being in the description, is among the things that never travel. Nobody online can read 0Boom off my calendar; they can only see that the Friday afternoon it names is spoken for.

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.0.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: the 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
 * times only, so this script is not trusted to have kept its word either.
 *
 * 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

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

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;
}

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);

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

  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 guard = 0;
      while (d < end && guard++ < 400) {
        days[ymd_(d, tz)] = 1;
        d = new Date(d.getTime() + 86400000);
      }
    } else {
      split_(ev.getStartTime(), ev.getEndTime(), tz, timed);
    }
  }

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

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

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));
    var sig = hex_(Utilities.computeHmacSha256Signature(ts + '|' + body, PUSH_KEY));

    var r = UrlFetchApp.fetch(ENDPOINT, {
      method: 'post',
      contentType: 'application/json',
      payload: body,
      headers: { 'X-Cal-Ts': ts, 'X-Cal-Sig': sig },
      muteHttpExceptions: true,
      followRedirects: true
    });
    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();
  }
}

/** 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');
}