本文へスキップ

ガルーンのイベントをAppleカレンダーにエクスポートするブックマークレット ​

開いているガルーンのイベントをiCalファイルに変換してAppleカレンダーに取り込むブックマークレットと、そのコードの解説です。

ガルーンからAppleカレンダーにイベントの詳細をコピーして貼り付けるのは面倒です。 このブックマークレットは、開いている任意のガルーンのイベントからiCalファイルを生成するので、1クリックでイベントの詳細を引き継げます。

ファイルはブラウザーの中で組み立てるため、イベントの標題とメモがページの外に出ることはありません。

ガルーンのイベントをAppleカレンダーにエクスポートするブックマークレットのデモ

使い方 ​

初期設定 ​

  1. 下記のブックマークレットのコードをコピーします。
  2. Chromeのアドレスバーに@bookmarksと入力します。
  3. 右上の⋮アイコンをクリックします。
  4. ブックマークを追加をクリックし、URLフィールドにコードを貼り付けます。

補足

URLフィールドに貼り付けたときにブラウザーが改行を取り除いても動作するように、コードではブロックコメントを使っています。

ガルーンのイベントをAppleカレンダーにエクスポートする ​

  1. ガルーンのイベントのページを開きます。
  2. ブックマークレットをクリックします。
  3. ダウンロードされたgaroon-<イベント ID>.icsファイルを開き、イベントがAppleカレンダーアプリに追加されていることを確認します。

ブックマークレットはファイルを組み立て、新しいタブを開かずにそのままダウンロードを開始します。

トラブルシューティング ​

エクスポートに失敗した場合は、ブックマークレットがアラートを表示します。

  • 「Error: Not on a Garoon event.」 - ページがガルーンのイベントページではないか、イベントに開始時刻がありません。特定のイベントを開いて、もう一度ブックマークレットをクリックします。
  • 「Error: Could not build the iCal file.」 - イベントオブジェクトは取得できましたが、ファイルの組み立てに失敗しました。アラートにはエラーメッセージが含まれます。

アラートが表示されず、ファイルもダウンロードされない場合は、ブラウザーのコンソールでログに出力されたeventオブジェクトと生成されたiCalのテキストを確認します。

  1. ブラウザーのコンソールを開きます。
    • Mac:Command+Option+C
    • Windows、Linux、Chrome OS:Control+Shift+C
  2. ログに出力されたeventオブジェクトとicsの文字列を確認します。

ブックマークレットのコード ​

js
javascript: (() => {
  /* Escape a TEXT value per RFC 5545 3.3.11. */
  const escapeText = (input) =>
    String(input ?? '')
      .replace(/\\/g, '\\\\')
      .replace(/;/g, '\\;')
      .replace(/,/g, '\\,')
      .replace(/\r\n|[\r\n]/g, '\\n');

  /* UTC date-time form, e.g. 20260820T053000Z. No VTIMEZONE block needed. */
  const utcStamp = (value) =>
    new Date(value)
      .toISOString()
      .replace(/[-:]/g, '')
      .replace(/\.\d{3}/, '');

  /* Calendar date form, taken from the RFC 3339 string so no time zone math applies. */
  const dateStamp = (dateTime) =>
    String(dateTime).slice(0, 10).replace(/-/g, '');

  /* DTEND is exclusive for all-day events, so advance one day. */
  const dayAfter = (yyyymmdd) =>
    new Date(
      Date.UTC(
        Number(yyyymmdd.slice(0, 4)),
        Number(yyyymmdd.slice(4, 6)) - 1,
        Number(yyyymmdd.slice(6, 8)) + 1,
      ),
    )
      .toISOString()
      .slice(0, 10)
      .replace(/-/g, '');

  /* Fold content lines at 75 octets per RFC 5545 3.1, without splitting a
     multi-byte character or an escape sequence. */
  const foldLine = (line) => {
    const bytes = new TextEncoder().encode(line);
    if (bytes.length <= 75) return line;
    const decoder = new TextDecoder();
    const chunks = [];
    let cut = 0;
    let limit = 75;
    while (cut < bytes.length) {
      let end = Math.min(cut + limit, bytes.length);
      if (end < bytes.length) {
        while (end > cut + 1 && (bytes[end] & 0xc0) === 0x80) end--;
        let slashes = 0;
        while (bytes[end - 1 - slashes] === 0x5c) slashes++;
        if (slashes % 2 === 1) end--;
      }
      chunks.push(decoder.decode(bytes.slice(cut, end)));
      cut = end;
      limit = 74; /* continuation lines start with a space */
    }
    return chunks.join('\r\n ');
  };

  const buildIcs = (event, eventUrl, host) => {
    let dtStart;
    let dtEnd;
    if (event.isAllDay === true) {
      const endSource = event.end?.dateTime ?? event.start.dateTime;
      dtStart = `DTSTART;VALUE=DATE:${dateStamp(event.start.dateTime)}`;
      dtEnd = `DTEND;VALUE=DATE:${dayAfter(dateStamp(endSource))}`;
    } else {
      const start = new Date(event.start.dateTime);
      const end = event.end?.dateTime
        ? new Date(event.end.dateTime)
        : new Date(
            start.getTime() + 3600000,
          ); /* start-only events get one hour */
      dtStart = `DTSTART:${utcStamp(start)}`;
      dtEnd = `DTEND:${utcStamp(end)}`;
    }

    const rooms = (event.facilities ?? [])
      .map((facility) => facility.name)
      .join(', ');
    const now = new Date();

    const lines = [
      'BEGIN:VCALENDAR',
      'VERSION:2.0',
      'PRODID:-//tokyo-geek//garoon-to-apple//EN',
      'CALSCALE:GREGORIAN',
      'BEGIN:VEVENT',
      /* A stable UID lets a re-export update the event instead of duplicating it.
         Use the cleaned host so both access URLs produce the same UID. */
      `UID:garoon-${event.id}@${host}`,
      `DTSTAMP:${utcStamp(now)}`,
      /* A rising SEQUENCE tells Apple Calendar the re-export is the newer copy. */
      `SEQUENCE:${Math.floor(now.getTime() / 60000)}`,
      dtStart,
      dtEnd,
      `SUMMARY:${escapeText(event.subject)}`,
      /* The Garoon link belongs here and nowhere else. URL is a URI value, not
         TEXT, so it is not escaped, and it is kept out of DESCRIPTION so the
         memo stays the memo. */
      `URL:${eventUrl}`,
    ];
    if (event.notes) lines.push(`DESCRIPTION:${escapeText(event.notes)}`);
    if (rooms) lines.push(`LOCATION:${escapeText(rooms)}`);
    lines.push('END:VEVENT', 'END:VCALENDAR');

    return lines.map(foldLine).join('\r\n') + '\r\n';
  };

  const download = (ics, fileName) => {
    const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
    const objectUrl = URL.createObjectURL(blob);
    const anchor = document.createElement('a');
    anchor.href = objectUrl;
    anchor.download = fileName;
    document.body.append(anchor);
    anchor.click();
    anchor.remove();
    setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
  };

  try {
    const event = window.garoon?.schedule?.event?.get();

    if (!event?.start?.dateTime) {
      alert(
        `Error: Not on a Garoon event.\nPlease open a specific Garoon event.`,
      );
      return;
    }

    /* Client certificate authentication inserts ".s" into the host name. */
    const origin = location.origin.replace('.s.', '.');
    const eventUrl = `${origin}${location.pathname}?event=${event.id}`;
    const ics = buildIcs(
      event,
      eventUrl,
      location.hostname.replace('.s.', '.'),
    );

    console.log({ event, ics });
    download(ics, `garoon-${event.id}.ics`);
  } catch (error) {
    console.error(error);
    alert(`Error: Could not build the iCal file.\n${error.message}`);
  }
})();

ブックマークレットとは ​

ブックマークレットとは、ウェブブラウザーのブックマークとして保存する小さなJavaScriptのコードです。

クリックすると開いているウェブページ上でコードが実行されるため、拡張機能をインストールせずにブラウザーの機能を拡張できます。

コードの解説 ​

コードをIIFEで囲む ​

まず、コードの言語としてjavascriptを指定します。

次に、コードを即時実行関数式(IIFE)で囲みます。 ブックマークレットはグローバルスコープで実行されるため、この囲みによってスクリプトの変数がページのグローバルスコープに漏れないようにします。

javascript
javascript: (() => {
  // ... (コードスニペット)
})();

ガルーンのイベントオブジェクトを取得する ​

garoon.schedule.event.get() JavaScript APIを使って、開いているガルーンのイベントのイベントオブジェクトを取得します。

windowウェブAPI により、グローバルスコープからgaroonオブジェクトにアクセスできます。

javascript
const event = window.garoon?.schedule?.event?.get();

入力を確認する ​

処理を進める前に、イベントに開始時刻があることを確認します。

javascript
const event = window.garoon?.schedule?.event?.get();

if (!event?.start?.dateTime) {
  alert(`Error: Not on a Garoon event.\nPlease open a specific Garoon event.`);
  return;
}

以前のバージョンではイベントをundefinedと比較していましたが、この書き方では値がnullのときに条件をすり抜け、あとでわかりにくいエラーになります。 偽値かどうかを確認すれば、undefinedとnullの両方を1つの条件でカバーできます。

イベント自体ではなくstart.dateTimeを確認しているのは、これがスクリプトの処理に唯一欠かせないフィールドだからです。 ほかのフィールドは任意で、値がない場合はスクリプトが適切な既定値で処理します。

オリジンURLを変更する ​

location.originを使って、開いているページのオリジン URLを取得します。

クライアント証明書認証の機能はサブドメインとドメインの間に.sを追加してURLを変更するため、エクスポートの前に取り除きます。

javascript
const origin = location.origin.replace('.s.', '.');

イベントのURLを組み立てる ​

オリジンURLとイベントIDを組み合わせて、短くて分かりやすいイベントのURLを生成します。

javascript
const url = `${origin}${location.pathname}?event=${event.id}`;

iCalのテキストを組み立てる ​

iCalファイルはプレーンテキストなので、どのサービスも呼び出さずにブラウザーだけで組み立てられます。 形式は RFC 5545 で定義されており、各行はNAME:valueという形のプロパティです。

1件のイベントには、1つのVEVENTブロックを囲むVCALENDARが必要です。

javascript
const lines = [
  'BEGIN:VCALENDAR',
  'VERSION:2.0',
  'PRODID:-//tokyo-geek//garoon-to-apple//EN',
  'CALSCALE:GREGORIAN',
  'BEGIN:VEVENT',
  `UID:garoon-${event.id}@${host}`,
  `DTSTAMP:${utcStamp(now)}`,
  `SEQUENCE:${Math.floor(now.getTime() / 60000)}`,
  dtStart,
  dtEnd,
  `SUMMARY:${escapeText(event.subject)}`,
  `URL:${eventUrl}`,
];
if (event.notes) lines.push(`DESCRIPTION:${escapeText(event.notes)}`);
if (rooms) lines.push(`LOCATION:${escapeText(rooms)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');

return lines.map(foldLine).join('\r\n') + '\r\n';

最後の行には、重要な点が2つあります。

  • 行の連結には、単なる改行ではなくCRLFを使います。RFC 5545のセクション3.1で定められており、LFだけのファイルは厳密なパーサーに拒否されます。
  • 連結の前に、すべての行がfoldLineを通ります。詳細は長い行を折り返すを参照してください。

それぞれの値は、本来入るべきフィールドに入れます。メモはDESCRIPTIONに、会議室はLOCATIONに、ガルーンのリンクはURLに入れます。 リンクを説明文に混ぜないことが重要です。URLはTEXT値ではなくURI値なので、エスケープせずに保存され、Appleカレンダーはメモの末尾に並ぶただの文字列ではなく、本物のリンクとして表示できます。

会議室の名前はイベントのfacilities配列から取得しますが、会議室のないイベントではこの配列自体が存在しないため、既定値として空の配列を使います。

javascript
const rooms = (event.facilities ?? [])
  .map((facility) => facility.name)
  .join(', ');

DESCRIPTIONとLOCATIONは、入れる値があるときだけ追加されるため、メモも会議室もないイベントで空のプロパティが2つ残ることはありません。

開始時刻と終了時刻を書式設定する ​

ガルーンは2026-08-20T14:30:00+09:00のような絶対時刻のRFC 3339のタイムスタンプを返します。 toISOString()を呼ぶと同じ瞬間がUTCに変換され、区切り記号を取り除くとiCalの日時形式になります。

javascript
const utcStamp = (value) =>
  new Date(value)
    .toISOString()
    .replace(/[-:]/g, '')
    .replace(/\.\d{3}/, '');

Asia/Tokyoの14:30開始は20260820T053000Zになります。 末尾のZはその値がUTCであることを示し、解釈は1通りしかないため、ファイルにVTIMEZONEブロックは必要ありません。 Appleカレンダーは、イベントを表示するときにこの瞬間を閲覧者のローカルのタイムゾーンに変換します。

ファイルにタイムゾーン名を含めていないのも、この理由からです。 以前のバージョンはUTCのタイムスタンプとAsia/Tokyoのタイムゾーンパラメーターを同時に送っていましたが、これは矛盾した入力です。タイムスタンプはすでに絶対時刻なので、2つ目のタイムゾーンは無視されるか、二重に適用されるかのどちらかになります。

終了時刻のないイベント(isStartOnly)には使えるend.dateTimeがないため、長さ0のイベントとしてエクスポートせずに1時間の長さを与えます。

javascript
const end = event.end?.dateTime
  ? new Date(event.end.dateTime)
  : new Date(start.getTime() + 3600000);

終日のイベントを処理する ​

終日のイベントは、たまたま午前0時に始まる時刻指定のイベントではありません。 iCalには終日のイベント専用の形式があり、代わりに時刻指定の形式を使っていたことが、以前のバージョンが終日のイベントを間違った日にエクスポートしていた原因です。

javascript
if (event.isAllDay === true) {
  const endSource = event.end?.dateTime ?? event.start.dateTime;
  dtStart = `DTSTART;VALUE=DATE:${dateStamp(event.start.dateTime)}`;
  dtEnd = `DTEND;VALUE=DATE:${dayAfter(dateStamp(endSource))}`;
}

ここで適用されるルールは2つです。

  • VALUE=DATEは、その値が時刻もタイムゾーンも持たない暦日であることを示し、20260820のように書きます。
  • DTENDは終了日を含まないため、8月20日の1日だけのイベントは8月21日で終わります。1日進めないと、Appleカレンダーでは長さ0のイベントになります。

日付そのものは、DateオブジェクトからではなくRFC 3339の文字列を切り出して取得します。

javascript
const dateStamp = (dateTime) => String(dateTime).slice(0, 10).replace(/-/g, '');

スケジュールオブジェクトのドキュメントによると、ガルーンは終日のイベントの開始をローカルのタイムゾーンの00:00:00として返します。 この午前0時をUTCに変換すると、JSTの利用者では前日の15:00に移動してしまうため、この瞬間に対して日付の計算を行うとバグが再発します。 文字列を切り出せば、書かれているとおりのローカルの日付を取得でき、変換そのものを避けられます。

補足

ガルーンのドキュメントでは、JavaScript APIが終日のイベントの終了を最終日の00:00:00として返すことが示唆されている一方で、REST APIは23:59:59を返しますが、複数日にわたる終日のイベントでどちらが返るかは未確認です。

テキストの値をエスケープする ​

RFC 5545のセクション3.3.11では、TEXTの値の中で4つの文字に特別な意味を持たせています。バックスラッシュ、セミコロン、カンマ、そして改行です。 これらをそのまま書くと値が壊れます。とくに多いのがエスケープしていないカンマで、パーサーが2つ目の値の始まりと解釈し、1つ目の値が途中で切れてしまいます。

javascript
const escapeText = (input) =>
  String(input ?? '')
    .replace(/\\/g, '\\\\')
    .replace(/;/g, '\\;')
    .replace(/,/g, '\\,')
    .replace(/\r\n|[\r\n]/g, '\\n');

バックスラッシュを最初に置き換えるのは、後続のルールが追加したバックスラッシュを二重に置き換えないためです。 入力をString(input ?? "")で包んでいるため、標題やメモがなくてもエラーにならず、空の値になります。

次のようなメモは、

text
Review the draft, then reply
Thanks

ファイルには次のように書き込まれます。

text
DESCRIPTION:Review the draft\, then reply\nThanks

ここでの\nは改行ではなく、バックスラッシュと文字の2文字です。 値の中に本物の改行があると、そこでプロパティが終わってしまいます。

長い行を折り返す ​

RFC 5545のセクション3.1は、1つの内容行を75オクテットまでに制限し、それより長いものは複数行に分割します。 継続行は半角スペース1つで始まり、パーサーはCRLFとそのスペースを取り除いて元の値に戻します。

この上限が数えるのは、文字数ではなくオクテット数です。 ガルーンでは標題やメモが日本語であることが多いため、この違いは無視できません。日本語の1文字はUTF-8で3バイトなので、75文字の行は225オクテット、上限の3倍になります。 バイト数を数えるために使うのが TextEncoder です。

javascript
const bytes = new TextEncoder().encode(line);
if (bytes.length <= 75) return line;

バイト配列を切るときには2つの危険があり、ループはその両方を防いでいます。

  • マルチバイト文字の途中で切ると、壊れた断片が2つできます。UTF-8の継続バイトはすべてビット10で始まるため、文字の先頭バイトに到達するまで切る位置を戻します。
  • バックスラッシュとそれがエスケープする文字の間で切ると、エスケープの組が分断されます。切る位置の直前にあるバックスラッシュの数を数え、奇数なら1バイト戻すことで、組を保てます。
javascript
while (end > cut + 1 && (bytes[end] & 0xc0) === 0x80) end--;
let slashes = 0;
while (bytes[end - 1 - slashes] === 0x5c) slashes++;
if (slashes % 2 === 1) end--;

継続行を75オクテットではなく74オクテットで切っているのは、先頭のスペースも上限に含まれるためです。

日本語の標題は、次のように折り返されます。

text
SUMMARY:定例ミーティング:第三四半期の進捗確認と来期
 の予算計画のレビュー

1行目は74オクテットです。SUMMARY:の8オクテットと、日本語22文字の66オクテットの合計です。 あと1文字増えると77オクテットになります。

イベントに安定したUIDを付与する ​

UIDは、取り込まれたイベントが新しいイベントなのか、すでに持っているイベントの新しいコピーなのかを、カレンダーアプリが判断するための値です。 UIDが変わらなければ、再エクスポートを同じイベントとして認識できるため、Appleカレンダーはコピーを追加せずに既存のイベントを更新できます。 UIDがなければ、エクスポートのたびに重複が増えていきます。

javascript
`UID:garoon-${event.id}@${host}`,
`DTSTAMP:${utcStamp(now)}`,
`SEQUENCE:${Math.floor(now.getTime() / 60000)}`,

ホスト名には.s.を取り除いたものを使うため、ガルーンの2つのアクセスURLのどちらからでも、同じイベントには同じUIDが生成されます。

SEQUENCEはイベントの版数で、更新として受け入れられるには値が増えている必要があります。 現在時刻を分単位で使えば、2回目のエクスポートの値が1回目より必ず大きくなります。

ファイルをダウンロードする ​

組み立てたテキストを Blob に入れ、URL.createObjectURL() でそのBlobをページからリンクできるURLに変換します。

javascript
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);

download属性は、リンク先に移動する代わりに保存するようブラウザーに指示し、ファイル名も指定します。 オブジェクトURLは、それを作成したページと同一オリジンなので、この属性が有効になります。

ウィンドウを開かないため、ポップアップブロックと戦う必要はなく、2回目のクリックなしでダウンロードが始まります。

オブジェクトURLは、ドキュメントが破棄されるまでBlobをメモリー上に保持するため、revokeObjectURL()で解放します。 ダウンロードが始まる時間を確保するために、この呼び出しは遅らせています。

デバッグ用にログを出力する ​

デバッグに役立つように、eventオブジェクトと生成されたiCalのテキストをコンソールに出力し、スクリプトの入力と出力の両方を確認できるようにします。

javascript
console.log({ event, ics });

スクリプト全体はtryブロックの中でも実行されるため、想定外のイベントの形が来ても、何も起きないのではなく、アラートの表示とスタックトレースの出力が行われます。

javascript
} catch (error) {
  console.error(error);
  alert(`Error: Could not build the iCal file.\n${error.message}`);
}

v1へのアップグレード ​

このブックマークレットの最初のバージョンは、クエリー文字列を組み立ててcalndr.linkに渡していました。iCalファイルを返す無料のサービスです。 そのサービスはもうありません。エンドポイントはAddCalにリダイレクトされ、カレンダーのファイルではなくHTMLのページを返すようになり、ドキュメントに記載された後継の機能は有料プランのDynamic Link Keyを必要とします。 元のコードは garoon-to-apple-bookmarklet-v0.js として参照用に残しています。

ブラウザーの中でファイルを生成すると、この依存がなくなります。さらに、イベントの標題、メモ、社内のガルーンのURLがページの外に出ることもなくなります。 メモに議事録、顧客名、会議のリンクが入りうる仕事のカレンダーでは、こちらのほうが重要な変更点です。

お役に立ちましたか? ​

この記事やブックマークレットが参考になりましたら、お茶をおごっていただけると嬉しいです。🍵

参考資料 ​