MENU

【コピペOK】GoogleカレンダーをApps Scriptで別アカウントに自動同期する方法

こんにちは、めりのです。

フリーランスをしていると、チームにスケジュールの空き枠を共有する機会がよくあります。

そのとき困るのが、仕事用とプライベート用でGoogleアカウントが分かれているケース。

片方のカレンダーしか見ていないと、ダブルブッキングが起きたり、空き時間の把握が面倒になったりするんですよね。

そこで、仕事用とプライベート用のGoogleカレンダーを自動で双方向同期するApps Scriptを作りました。

「仕事の予定はプライベート側に反映したいけど予定名は隠したい」「プライベートの予定は仕事側にもそのまま見せたい」という悩みをまるっと解決します。

目次

やりたかったこと

  • 仕事用カレンダーの予定をプライベート用に自動コピー(予定名は「私用」に統一)
  • プライベート用カレンダーの予定を仕事用に自動コピー(予定名はそのまま)
  • 招待されてまだ未確定の予定は「【調整可】」を頭につける
  • 辞退した予定は同期しない
  • 予定が削除・変更されたら同期先にも反映する
  • 繰り返し予定にも対応

完成したスクリプト

プライベート用GoogleアカウントのApps Script(script.google.com)に以下のコードを貼り付けてください。

事前準備

スクリプトを実行する前に、仕事用カレンダーをプライベートアカウントと共有しておく必要があります。

  1. 仕事用アカウントでGoogleカレンダーを開く
  2. 同期したいカレンダーの「︙」→「設定と共有」
  3. 「特定のユーザーと共有」→ プライベート用メールアドレスを追加
  4. 権限を「予定の変更」に設定して送信
  5. プライベート用Gmailの招待メールを承認

スクリプト本体(動作保証してないよ)

PRIVATE_CALENDAR_IDWORK_CALENDAR_ID をそれぞれ自分のアカウントのメールアドレスに書き換えてください。

// =============================================
// カレンダー双方向同期
//
// プライベート → 仕事用:タイトルを「私用」に統一
//   未確定の場合は「【調整可】私用」
//
// 仕事用 → プライベート:元のタイトルをそのまま使用
// =============================================

const PRIVATE_CALENDAR_ID = "あなたのプライベート用Gmail";
const WORK_CALENDAR_ID = "あなたの仕事用Gmail";

function syncPrivateToWork() {
  syncEvents(
    CalendarApp.getCalendarById(PRIVATE_CALENDAR_ID),
    CalendarApp.getCalendarById(WORK_CALENDAR_ID),
    "private_to_work"
  );
}

function syncWorkToPrivate() {
  syncEvents(
    CalendarApp.getCalendarById(WORK_CALENDAR_ID),
    CalendarApp.getCalendarById(PRIVATE_CALENDAR_ID),
    "work_to_private"
  );
}

// トリガーにはこれを設定する
function syncBothWays() {
  syncPrivateToWork();
  syncWorkToPrivate();
}

function syncEvents(srcCal, destCal, direction) {
  if (!srcCal || !destCal) {
    console.log("カレンダーが見つかりません: " + direction);
    return;
  }

  const now = new Date();
  const future = new Date();
  future.setDate(future.getDate() + 90); // 90日先まで

  const srcEvents = srcCal.getEvents(now, future);
  const destEvents = destCal.getEvents(now, future);

  const destMap = {};
  destEvents.forEach(function(e) {
    const match = (e.getDescription() || "").match(/\[sync:([^\]]+)\]/);
    if (match) {
      if (!destMap[match[1]]) destMap[match[1]] = [];
      destMap[match[1]].push(e);
    }
  });

  console.log("[" + direction + "] 同期元: " + srcEvents.length + "件 / 同期先既存: " + Object.keys(destMap).length + "件");

  var created = 0;
  var updated = 0;
  var deleted = 0;
  var skipped = 0;

  const srcTagSet = new Set();

  srcEvents.forEach(function(event) {
    // IDと開始時間を組み合わせてユニークなタグを生成(繰り返し予定対応)
    const srcTag = event.getId() + "_" + event.getStartTime().getTime();

    // 同期作成された予定はスキップ(無限ループ防止)
    if ((event.getDescription() || "").includes("[sync:")) {
      skipped++;
      return;
    }

    // 辞退した予定はスキップ
    const status = event.getMyStatus();
    if (status === CalendarApp.GuestStatus.NO) {
      skipped++;
      return;
    }

    srcTagSet.add(srcTag);

    // 参加ステータスを確認
    // null = 自分で作成、OWNER = オーナー、YES = 承諾済み、MAYBE/INVITED = 未確定
    const isConfirmed = status === null
      || status === CalendarApp.GuestStatus.OWNER
      || status === CalendarApp.GuestStatus.YES;

    // プライベート→仕事:「私用」に統一(未確定は「【調整可】私用」)
    // 仕事→プライベート:元のタイトルそのまま
    var title;
    if (direction === "private_to_work") {
      title = isConfirmed ? "私用" : "【調整可】私用";
    } else {
      title = event.getTitle();
    }

    const isAllDay = event.isAllDayEvent();
    const existingList = destMap[srcTag];

    if (!existingList || existingList.length === 0) {
      // 新規作成
      try {
        var newEvent;
        if (isAllDay) {
          newEvent = destCal.createAllDayEvent(
            title,
            event.getAllDayStartDate(),
            event.getAllDayEndDate(),
            { description: "[sync:" + srcTag + "]" }
          );
        } else {
          newEvent = destCal.createEvent(
            title,
            event.getStartTime(),
            event.getEndTime(),
            { description: "[sync:" + srcTag + "]" }
          );
        }
        newEvent.setColor("1"); // ラベンダー色
        created++;
        Utilities.sleep(500);
      } catch(e) {
        console.log("作成エラー(スキップ): " + e);
        Utilities.sleep(3000);
      }

    } else {
      // 重複がある場合は1件だけ残して削除
      for (var i = 1; i < existingList.length; i++) {
        try { existingList[i].deleteEvent(); } catch(e) {}
        Utilities.sleep(500);
      }

      // 変更があれば更新
      try {
        const existingEvent = existingList[0];
        const titleChanged = existingEvent.getTitle() !== title;
        const startChanged = isAllDay
          ? existingEvent.getAllDayStartDate().getTime() !== event.getAllDayStartDate().getTime()
          : existingEvent.getStartTime().getTime() !== event.getStartTime().getTime();
        const endChanged = isAllDay
          ? existingEvent.getAllDayEndDate().getTime() !== event.getAllDayEndDate().getTime()
          : existingEvent.getEndTime().getTime() !== event.getEndTime().getTime();

        if (titleChanged || startChanged || endChanged) {
          existingEvent.setTitle(title);
          if (isAllDay) {
            existingEvent.setAllDayDates(event.getAllDayStartDate(), event.getAllDayEndDate());
          } else {
            existingEvent.setTime(event.getStartTime(), event.getEndTime());
          }
          existingEvent.setColor("1");
          updated++;
          Utilities.sleep(500);
        } else {
          skipped++;
        }
      } catch(e) {
        // 削除済みの場合は再作成
        try {
          var newEvent;
          if (isAllDay) {
            newEvent = destCal.createAllDayEvent(
              title,
              event.getAllDayStartDate(),
              event.getAllDayEndDate(),
              { description: "[sync:" + srcTag + "]" }
            );
          } else {
            newEvent = destCal.createEvent(
              title,
              event.getStartTime(),
              event.getEndTime(),
              { description: "[sync:" + srcTag + "]" }
            );
          }
          newEvent.setColor("1");
          created++;
          Utilities.sleep(500);
        } catch(e2) {
          console.log("再作成エラー(スキップ): " + e2);
          Utilities.sleep(3000);
        }
      }
    }
  });

  // 同期元から削除・辞退された予定を同期先からも削除
  Object.keys(destMap).forEach(function(srcTag) {
    if (!srcTagSet.has(srcTag)) {
      destMap[srcTag].forEach(function(destEvent) {
        try {
          destEvent.deleteEvent();
          deleted++;
          Utilities.sleep(500);
        } catch(e) {}
      });
    }
  });

  console.log("[" + direction + "] 新規作成: " + created + "件 / 更新: " + updated + "件 / 削除: " + deleted + "件 / スキップ: " + skipped + "件");
}

自動実行(トリガー)の設定

1時間ごとに自動で同期されるようにトリガーを設定します。

  1. Apps Scriptを開く
  2. 左メニューの「⏰ トリガー」をクリック
  3. 右下の「+ トリガーを追加」をクリック
  4. 実行する関数:syncBothWays / イベントのソース:時間主導型 / 時間の間隔:1時間おき
  5. 「保存」をクリック

エラー通知の設定

スクリプトがエラーになったときにメールで通知を受け取ることができます。

  1. トリガー一覧から syncBothWays の「︙」→「失敗通知を編集」
  2. 通知先にメールアドレスを入力、頻度を「すぐに通知」に設定
  3. 「保存」をクリック

仕様まとめ

方向タイトル未確定の場合
プライベート → 仕事用私用【調整可】私用
仕事用 → プライベート元のタイトルそのままそのまま
  • 辞退した予定は同期しない(MAYBEからNOに変えると同期先からも自動削除)
  • 繰り返し予定も各インスタンスごとに正しく同期
  • 終日予定にも対応
  • 同期先の予定を手動削除しても次回実行時に再作成
  • 同期済み予定の説明欄に [sync:ID] タグが入る(無限ループ防止用)

仕事用カレンダーの予定をプライベート側に「私用」として表示することで、仕事の詳細を隠しつつスケジュールの空き状況を管理できてとても便利になりました!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA


目次