複数の組織にまたがって仕事をしていると、カレンダーが分断される。片方の組織は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に聞いたら「双方向同期は地雷が多い」と言われました。

ああ、確かに。これ、ちゃんとやろうとすると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)」として埋める。

これなら:

Google Apps Script(GAS)で書けばいける。

[📦 商品リンク: moshimo-book-PhRut]

最初の実装:ICSをパースする

ICSの仕様はRFC5545。BEGIN:VEVENTEND:VEVENT の間に予定情報が入ってます。必要なのは UIDDTSTARTDTEND くらい。

最初は単純に正規表現でパースしました。

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が同じインスタンスを二重に出力してた。

どっちも「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つのブロックでいいじゃないか。

仕様を割り切りました。

この割り切りで、実装がシンプルになった。

キーも 開始時刻 + 終了時刻 だけでいい。

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

同期ロジックは:

  1. ソースカレンダーから予定取得
  2. 各予定のキー(開始+終了)を計算
  3. マップにあれば更新、なければ新規作成
  4. 今回見つからなかったキーは削除

コード量は200行くらい。

実際に使ってみて

今日から運用開始。

まだ数時間だけど、今のところ問題ない。Google側で予定を入れようとすると、Outlookの予定が入ってる時間帯は「埋まってる」って表示されます。

4時間以内には同期される。リアルタイムじゃないけど、まあ許容範囲。

ブロック用カレンダーは、左のカレンダー一覧で色を濃いグレーにしてあります。一目で「これは触っちゃダメなやつ」って分かる。

トリガー頻度は今のところ4時間でちょうどいい感じ。将来的に変えるかもしれないけど、当面はこれで。

振り返り:非対称性とトレードオフ

Google → Outlookは5分で終わった。ICS URLを追加するだけ。

Outlook → Googleは、何日もかかった。ICSパース、繰り返し予定、重複排除、キー設計、差分同期。

技術的に非対称だった理由:

最初は「完璧に同期したい」と思ってました。でも途中で「ブロックさえ分かればいい」に変わった。

トレードオフもあります。

完璧な解は、たぶん存在しない。制約を受け入れて、必要十分を見極めて、実務で動く形に落とし込む。

「ブロックさえ分かればいい」——その割り切りが、最後の判断でした。

関連リソース

[📦 商品リンク: moshimo-book-R44mY]

[📦 商品リンク: moshimo-book-tJG6Z]