こんにちは、めりのです。
フリーランスをしていると、チームにスケジュールの空き枠を共有する機会がよくあります。
そのとき困るのが、仕事用とプライベート用でGoogleアカウントが分かれているケース。
片方のカレンダーしか見ていないと、ダブルブッキングが起きたり、空き時間の把握が面倒になったりするんですよね。
そこで、仕事用とプライベート用のGoogleカレンダーを自動で双方向同期するApps Scriptを作りました。
「仕事の予定はプライベート側に反映したいけど予定名は隠したい」「プライベートの予定は仕事側にもそのまま見せたい」という悩みをまるっと解決します。
目次
やりたかったこと
- 仕事用カレンダーの予定をプライベート用に自動コピー(予定名は「私用」に統一)
- プライベート用カレンダーの予定を仕事用に自動コピー(予定名はそのまま)
- 招待されてまだ未確定の予定は「【調整可】」を頭につける
- 辞退した予定は同期しない
- 予定が削除・変更されたら同期先にも反映する
- 繰り返し予定にも対応
完成したスクリプト
プライベート用GoogleアカウントのApps Script(script.google.com)に以下のコードを貼り付けてください。
事前準備
スクリプトを実行する前に、仕事用カレンダーをプライベートアカウントと共有しておく必要があります。
- 仕事用アカウントでGoogleカレンダーを開く
- 同期したいカレンダーの「︙」→「設定と共有」
- 「特定のユーザーと共有」→ プライベート用メールアドレスを追加
- 権限を「予定の変更」に設定して送信
- プライベート用Gmailの招待メールを承認
スクリプト本体(動作保証してないよ)
PRIVATE_CALENDAR_ID と WORK_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時間ごとに自動で同期されるようにトリガーを設定します。
- Apps Scriptを開く
- 左メニューの「⏰ トリガー」をクリック
- 右下の「+ トリガーを追加」をクリック
- 実行する関数:
syncBothWays/ イベントのソース:時間主導型 / 時間の間隔:1時間おき - 「保存」をクリック
エラー通知の設定
スクリプトがエラーになったときにメールで通知を受け取ることができます。
- トリガー一覧から
syncBothWaysの「︙」→「失敗通知を編集」 - 通知先にメールアドレスを入力、頻度を「すぐに通知」に設定
- 「保存」をクリック
仕様まとめ
| 方向 | タイトル | 未確定の場合 |
|---|---|---|
| プライベート → 仕事用 | 私用 | 【調整可】私用 |
| 仕事用 → プライベート | 元のタイトルそのまま | そのまま |
- 辞退した予定は同期しない(MAYBEからNOに変えると同期先からも自動削除)
- 繰り返し予定も各インスタンスごとに正しく同期
- 終日予定にも対応
- 同期先の予定を手動削除しても次回実行時に再作成
- 同期済み予定の説明欄に
[sync:ID]タグが入る(無限ループ防止用)
仕事用カレンダーの予定をプライベート側に「私用」として表示することで、仕事の詳細を隠しつつスケジュールの空き状況を管理できてとても便利になりました!
コメント