ガルーンのイベントを Apple カレンダーにエクスポートするブックマークレット
開いているガルーンのイベントを iCal ファイルに変換して Apple カレンダーに取り込むブックマークレットと、そのコードの解説です。
ガルーンから Apple カレンダーにイベントの詳細をコピーして貼り付けるのは面倒です。 このブックマークレットは、開いている任意のガルーンのイベントから iCal ファイルを生成するので、1 クリックでイベントの詳細を引き継げます。
ファイルはブラウザーの中で組み立てるため、イベントの標題とメモがページの外に出ることはありません。

使い方
初期設定
- 下記のブックマークレットのコードをコピーします。
- Chrome のアドレスバーに
@bookmarksと入力します。 - 右上の
⋮アイコンをクリックします。 - ブックマークを追加をクリックし、URL フィールドにコードを貼り付けます。
補足
URL フィールドに貼り付けたときにブラウザーが改行を取り除いても動作するように、コードではブロックコメントを使っています。
ガルーンのイベントを Apple カレンダーにエクスポートする
- ガルーンのイベントのページを開きます。
- ブックマークレットをクリックします。
- ダウンロードされた
garoon-<イベント ID>.icsファイルを開き、イベントが Apple カレンダーアプリに追加されていることを確認します。
ブックマークレットはファイルを組み立て、新しいタブを開かずにそのままダウンロードを開始します。
トラブルシューティング
エクスポートに失敗した場合は、ブックマークレットがアラートを表示します。
- 「Error: Not on a Garoon event.」 - ページがガルーンのイベントページではないか、イベントに開始時刻がありません。特定のイベントを開いて、もう一度ブックマークレットをクリックします。
- 「Error: Could not build the iCal file.」 - イベントオブジェクトは取得できましたが、ファイルの組み立てに失敗しました。アラートにはエラーメッセージが含まれます。
アラートが表示されず、ファイルもダウンロードされない場合は、ブラウザーのコンソールでログに出力されたeventオブジェクトと生成された iCal のテキストを確認します。
- ブラウザーのコンソールを開きます。
- Mac:
Command+Option+C - Windows、Linux、Chrome OS:
Control+Shift+C
- Mac:
- ログに出力された
eventオブジェクトとicsの文字列を確認します。
ブックマークレットのコード
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: (() => {
// ... (コードスニペット)
})();ガルーンのイベントオブジェクトを取得する
garoon.schedule.event.get() JavaScript API を使って、開いているガルーンのイベントのイベントオブジェクトを取得します。
windowウェブ API により、グローバルスコープからgaroonオブジェクトにアクセスできます。
const event = window.garoon?.schedule?.event?.get();入力を確認する
処理を進める前に、イベントに開始時刻があることを確認します。
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 を変更するため、エクスポートの前に取り除きます。
const origin = location.origin.replace(".s.", ".");イベントの URL を組み立てる
オリジン URL とイベント ID を組み合わせて、短くて分かりやすいイベントの URL を生成します。
const url = `${origin}${location.pathname}?event=${event.id}`;iCal のテキストを組み立てる
iCal ファイルはプレーンテキストなので、どのサービスも呼び出さずにブラウザーだけで組み立てられます。 形式は RFC 5545 で定義されており、各行はNAME:valueという形のプロパティです。
1 件のイベントには、1 つのVEVENTブロックを囲むVCALENDARが必要です。
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配列から取得しますが、会議室のないイベントではこの配列自体が存在しないため、既定値として空の配列を使います。
const rooms = (event.facilities ?? []).map((facility) => facility.name).join(", ");DESCRIPTIONとLOCATIONは、入れる値があるときだけ追加されるため、メモも会議室もないイベントで空のプロパティが 2 つ残ることはありません。
開始時刻と終了時刻を書式設定する
ガルーンは2026-08-20T14:30:00+09:00のような絶対時刻の RFC 3339 のタイムスタンプを返します。 toISOString()を呼ぶと同じ瞬間が UTC に変換され、区切り記号を取り除くと iCal の日時形式になります。
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 時間の長さを与えます。
const end = event.end?.dateTime
? new Date(event.end.dateTime)
: new Date(start.getTime() + 3600000);終日のイベントを処理する
終日のイベントは、たまたま午前 0 時に始まる時刻指定のイベントではありません。 iCal には終日のイベント専用の形式があり、代わりに時刻指定の形式を使っていたことが、以前のバージョンが終日のイベントを間違った日にエクスポートしていた原因です。
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 の文字列を切り出して取得します。
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 つ目の値が途中で切れてしまいます。
const escapeText = (input) =>
String(input ?? "")
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|[\r\n]/g, "\\n");バックスラッシュを最初に置き換えるのは、後続のルールが追加したバックスラッシュを二重に置き換えないためです。 入力をString(input ?? "")で包んでいるため、標題やメモがなくてもエラーにならず、空の値になります。
次のようなメモは、
Review the draft, then reply
Thanksファイルには次のように書き込まれます。
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 です。
const bytes = new TextEncoder().encode(line);
if (bytes.length <= 75) return line;バイト配列を切るときには 2 つの危険があり、ループはその両方を防いでいます。
- マルチバイト文字の途中で切ると、壊れた断片が 2 つできます。UTF-8 の継続バイトはすべてビット
10で始まるため、文字の先頭バイトに到達するまで切る位置を戻します。 - バックスラッシュとそれがエスケープする文字の間で切ると、エスケープの組が分断されます。切る位置の直前にあるバックスラッシュの数を数え、奇数なら 1 バイト戻すことで、組を保てます。
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 オクテットで切っているのは、先頭のスペースも上限に含まれるためです。
日本語の標題は、次のように折り返されます。
SUMMARY:定例ミーティング:第三四半期の進捗確認と来期
の予算計画のレビュー1 行目は 74 オクテットです。SUMMARY:の 8 オクテットと、日本語 22 文字の 66 オクテットの合計です。 あと 1 文字増えると 77 オクテットになります。
イベントに安定した UID を付与する
UIDは、取り込まれたイベントが新しいイベントなのか、すでに持っているイベントの新しいコピーなのかを、カレンダーアプリが判断するための値です。 UIDが変わらなければ、再エクスポートを同じイベントとして認識できるため、Apple カレンダーはコピーを追加せずに既存のイベントを更新できます。 UIDがなければ、エクスポートのたびに重複が増えていきます。
`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 に変換します。
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 のテキストをコンソールに出力し、スクリプトの入力と出力の両方を確認できるようにします。
console.log({ event, ics });スクリプト全体はtryブロックの中でも実行されるため、想定外のイベントの形が来ても、何も起きないのではなく、アラートの表示とスタックトレースの出力が行われます。
} 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 がページの外に出ることもなくなります。 メモに議事録、顧客名、会議のリンクが入りうる仕事のカレンダーでは、こちらのほうが重要な変更点です。
お役に立ちましたか?
この記事やブックマークレットが参考になりましたら、お茶をおごっていただけると嬉しいです。🍵