複数の組織にまたがって仕事をしていると、カレンダーが分断される。片方の組織はOffice 365で、もう片方はGoogle Workspace。それぞれのカレンダーに予定が入って、会議を設定するたびに両方を確認して、空いてる時間を探す。
地味に面倒だった。
最初は「まあ2つ見ればいいか」と思っていたんだけど、気づいたらダブルブッキングしてたことが2回あって、さすがにこれはまずいなと。
既存ツールを調べる
まずググった。「Google Calendar Outlook 同期」とか。
出てくるのは海外のSaaS。OneCal、CalendarBridge、SyncGene、あたり。どれも月5〜10ドルくらいで、機能的には「Google ⇄ Office 365の双方向同期」ができるらしい。レビューもそこそこ良い。
試しにOneCalを使おうとした。
でも、できなかった。Office 365側でOAuth認証の画面が出て、「管理者の承認が必要です」で止まる。
ああ、そういうことか。組織側で外部アプリとの連携を制限してるんだ。管理者に「カレンダー同期ツール使いたいので承認してください」って申請する?いや、それはちょっと現実的じゃないな。稟議通すコストのほうが高い。
CalendarBridgeも試したけど同じ。SyncGeneも同じ。
OAuth制限という壁がある以上、外部ツールは使えない。
じゃあ自作? → いったん保留
次に考えたのは「自作するか」だった。Lovableとか使えば1ヶ月くらいで作れるんじゃないか、とか。
でもChatGPTに聞いたら「双方向同期は地雷が多い」と言われました。
- 無限ループ(A→B→A→B...)
- 同一イベントの識別(GoogleのIDとOutlookのIDは別)
- 競合解決(同時編集されたら?)
- 削除の扱い(片方で消したら?)
ああ、確かに。これ、ちゃんとやろうとするとOneCal作るのと同じことになるな。
自作はやめた。
そもそも何がしたいのか、もう一度整理しました。
「両方のカレンダーを見ながら、ダブルブッキングせずに予定を入れたい」
双方向の完全同期じゃなくていい。ブッキングさえ防げればいい。
Google → Outlook(これはあっさり解決)
まず片方向だけやってみることにした。Google側の予定をOutlookで見られるようにする。
OutlookのWebアプリには「カレンダーの追加」→「インターネットから」みたいな機能があります。GoogleカレンダーのICS URL(購読URL)を取得して貼り付ける。
GoogleカレンダーのICS URLは、カレンダーの設定 → 「カレンダーの統合」→ 「非公開アドレス(iCal形式)」から取れます。
これをOutlookに追加したら、そのまま表示されました。
5分で終わった。
Outlook側では、Google由来の予定も含めて空き時間が計算されます。つまり、Outlook側で新しい予定を入れようとすると「この時間は埋まってる」って判定が効く。
片方向はこれで解決。
Outlook → Google(ここから沼)
じゃあ逆もやろう。Outlookの予定をGoogleで見る。
同じようにやればいい。Outlookカレンダーを「公開」して、ICS URLを取得。GoogleカレンダーでURLから追加。
できた。Google側でもOutlookの予定が見える。
でも、これだとダブルブッキングを防げない。
試しにGoogle側で新しい予定を入れようとしたら、Outlook由来の予定が入ってる時間帯でも「空いてる」扱いになります。Google側の予定作成UIが、「この時間は埋まってる」って教えてくれない。
調べたら、GoogleカレンダーのURL購読(ICS表示)は「参照専用」で、空き時間の計算には使われない仕様らしい。見えるけど、制御できない。
困った。
GASで「ブロック予定」を作る案
この時点で考えたのは、「じゃあGoogle側に実体の予定を作ればいいんじゃないか」でした。
OutlookのICSから予定情報を読み取って、Google側の専用カレンダーに「ブロック」として同期する。タイトルとか詳細は同期しなくていい。時間帯だけを「予定あり(Busy)」として埋める。
これなら:
- OAuth制限に引っかからない(ICSは公開URLだから)
- ダブルブッキング制御が効く(実体予定だから空き時間計算に使われる)
- 情報漏洩リスクが低い(タイトルを持ち出さない)
Google Apps Script(GAS)で書けばいける。
[📦 商品リンク: moshimo-book-PhRut]
最初の実装:ICSをパースする
ICSの仕様はRFC5545。BEGIN:VEVENT ~ END:VEVENT の間に予定情報が入ってます。必要なのは UID、DTSTART、DTEND くらい。
最初は単純に正規表現でパースしました。
const m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
YYYYMMDDTHHMMSS 形式を想定。秒まできっちり入ってる前提。
でも、実際にOutlookから取得したICSを見たら、こんな行がありました。
DTSTART;TZID=Tokyo Standard Time:20260109T143000
秒がない。HHMM。
パースに失敗して、その予定がスキップされました。
正規表現を修正。秒あり・秒なし両対応にしました。
// YYYYMMDDTHHMM(Z)?
let m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(Z)?$/);
if (m) { ... }
// YYYYMMDDTHHMMSS(Z)?
m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
if (m) { ... }
これで一応動くようになった。
でも次の問題が待っていました。
繰り返し予定で予定が消える
定例会議とか、毎週の予定が「一部だけ」しか同期されない。
ログを見ると、created=27 とかになってるのに、実際のカレンダーには10件くらいしかない。
なんで?
調べたら、繰り返し予定の場合、OutlookのICSは同じ UID を複数のイベントで使い回してました。
例えば:
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008...
DTSTART:20251223T130000
DTEND:20251223T140000
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU
END:VEVENT
これがマスターイベント。「隔週火曜13:00-14:00」というルール。
で、もし特定の週だけ時間変更されてたりすると、こういうのも出てきます。
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008... (同じUID)
RECURRENCE-ID;TZID=Tokyo Standard Time:20260106T130000
DTSTART;TZID=Tokyo Standard Time:20260109T143000
DTEND;TZID=Tokyo Standard Time:20260109T153000
END:VEVENT
RECURRENCE-ID があります。これは「1/6の回は1/9 14:30に変更されてます」みたいな意味。
でも、最初の実装では UID だけをキーにしてたから、同じUIDの予定が来るたびに上書きされて、結果として最後の1件しか残らなかった。
キー設計を見直しました。UID + 開始時刻 を組み合わせる。
const instanceId = e.recurrenceIdRaw || formatIcsLocalKey_(start);
const key = `${e.uid}|${instanceId}`;
これで各回が別イベントとして認識されるようになりました。
予定が重複する問題
それでも、一部の予定が「横に2つ並ぶ」ことがありました。
同じ時間、同じタイトル「ブロック」が2つ。
原因を探したら、Outlookが同じインスタンスを二重に出力してた。
- マスターイベント(RRULEあり、RECURRENCE-IDなし)
- 個別インスタンス(RECURRENCE-IDあり)
どっちも「1/23 13:00-14:00」を指してるんだけど、ICS上では別の VEVENT として存在してる。
なんでこんな仕様なんだ。
正規化処理を入れました。同じ開始時刻の予定が複数ある場合、RECURRENCE-ID を持つ方(例外インスタンス)を優先して、マスターは無視する。
function normalizeEvents_(events) {
const map = new Map();
for (const e of events) {
const k = `${e.uid}|${formatIcsLocalKey_(e.dtstart)}`;
if (!map.has(k)) {
map.set(k, e);
continue;
}
const existing = map.get(k);
// RECURRENCE-IDがある方を優先
if (!existing.recurrenceIdRaw && e.recurrenceIdRaw) {
map.set(k, e);
}
}
return Array.from(map.values());
}
これでようやく重複が消えました。
でもここまで来て、なんかもう疲れてきた。
仕様を割り切る
ここで一度立ち止まりました。
そもそも何がしたかったのか。「ダブルブッキングを防ぐこと」だ。
Outlookの予定を完全にGoogleに再現する必要はない。タイトルも、詳細も、参加者も、要らない。時間帯が「埋まってる」ことさえ分かればいい。
じゃあ、同じ時間帯に複数の予定があっても、1つのブロックでいいじゃないか。
仕様を割り切りました。
- 開始・終了時刻が同じ予定は、1つのブロックに集約
- タイトルは固定で「ブロック」
- 詳細は空(情報漏洩防止)
- ブロック用カレンダーは「捨ててもいい」前提
この割り切りで、実装がシンプルになった。
キーも 開始時刻 + 終了時刻 だけでいい。
const eventKey = `${startTime.getTime()}_${endTime.getTime()}`;
同じ時間の予定は同じキーになります。上書きされる。でもそれでいい。
最終的な構成
最終的に落ち着いた形:
ソース: GoogleカレンダーにURL購読で取り込んだOffice 365カレンダー
ターゲット: 専用の「ブロック用」Googleカレンダー
同期頻度: 4時間おき(そんなに頻繁にカレンダーは更新されない)
同期方式: 差分同期
差分同期にしたのは、効率を考えて。毎回全削除+再作成だと、イベント数が増えたときにAPI呼び出しが膨らみます。今は100件程度だけど、将来を考えて差分にしました。
差分同期の場合、マッピング(ソースのイベントキー → ターゲットのイベントID)を保持する必要があります。Script Propertiesに JSON で保存。
const map = JSON.parse(props.getProperty(PROP_MAP) || '{}');
同期ロジックは:
- ソースカレンダーから予定取得
- 各予定のキー(開始+終了)を計算
- マップにあれば更新、なければ新規作成
- 今回見つからなかったキーは削除
コード量は200行くらい。
実際に使ってみて
今日から運用開始。
まだ数時間だけど、今のところ問題ない。Google側で予定を入れようとすると、Outlookの予定が入ってる時間帯は「埋まってる」って表示されます。
4時間以内には同期される。リアルタイムじゃないけど、まあ許容範囲。
ブロック用カレンダーは、左のカレンダー一覧で色を濃いグレーにしてあります。一目で「これは触っちゃダメなやつ」って分かる。
トリガー頻度は今のところ4時間でちょうどいい感じ。将来的に変えるかもしれないけど、当面はこれで。
振り返り:非対称性とトレードオフ
Google → Outlookは5分で終わった。ICS URLを追加するだけ。
Outlook → Googleは、何日もかかった。ICSパース、繰り返し予定、重複排除、キー設計、差分同期。
技術的に非対称だった理由:
- OutlookのICS出力仕様(秒なし、RECURRENCE-ID、マスター+例外の二重表現)
- GoogleのICS購読仕様(空き時間計算に使われない)
- OAuth制限という組織的制約
最初は「完璧に同期したい」と思ってました。でも途中で「ブロックさえ分かればいい」に変わった。
トレードオフもあります。
- タイトルが見えない(でもそれでいい、情報漏洩防止)
- リアルタイムじゃない(でも4時間なら許容範囲)
- 同じ時間の予定が複数あっても1ブロック(でも目的は達成できる)
完璧な解は、たぶん存在しない。制約を受け入れて、必要十分を見極めて、実務で動く形に落とし込む。
「ブロックさえ分かればいい」——その割り切りが、最後の判断でした。
関連リソース
[📦 商品リンク: moshimo-book-R44mY]
[📦 商品リンク: moshimo-book-tJG6Z]