ガルーンのイベントを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がページの外に出ることもなくなります。 メモに議事録、顧客名、会議のリンクが入りうる仕事のカレンダーでは、こちらのほうが重要な変更点です。
お役に立ちましたか?
この記事やブックマークレットが参考になりましたら、お茶をおごっていただけると嬉しいです。🍵