[GAS] Googleカレンダーをスプレッドシートへ差分同期!自動更新・重複削除
はじめに
「Googleカレンダーに入力した予定を、ExcelやGoogleスプレッドシートで一覧管理したい」
「毎回手動で書き写すのは面倒だし、予定の変更や削除があった時に転記漏れや重複が起きてしまう…」
現場でこのようなお悩みを抱えていませんか?
手動での転記作業は、ヒューマンエラーの原因になるだけでなく、貴重な業務時間を圧迫してしまいます。全件を毎回取得し直すスクリプトだと、データ量が増えた際にGASの実行時間制限(6分)に引っかかってしまうことも少なくありません。
今回は、指定したGoogleカレンダーの予定をGoogleスプレッドシートへ「差分同期(変更があった分だけ更新)」し、重複データの自動整理まで行える汎用GASスクリプトをご紹介します!
今回構築する仕組みの概要
今回作成するスクリプトでは、以下のステップで同期処理を安全かつ高速に行います。
- 特定名義のカレンダーを検索・ID取得: 名称を指定して対象カレンダーを特定します。
- 最終同期日時の管理: 設定シートを作成し、前回の同期日時を記録。変更のあったイベント(差分)のみを取得して処理速度を最適化します。
- 新規追加・更新処理: 既存のイベントIDと照合し、変更があれば上書き更新、なければ新しい行として追記します。
- 重複行の自動削除: 重複したイベントIDが存在する場合、最新の行を残して古い不要な行を削除します。
- 削除された予定の反映: カレンダー上で削除された予定をスプレッドシート側からも自動で削除します。
ソースコード(コピペOK)
スプレッドシートのスクリプトエディタに以下のコードを貼り付けてご使用ください。
※スプレッドシートIDなどの固有情報は PropertiesService(スクリプトプロパティ)に安全に退避させる構造に改修済みです。
/**
* ====================================================================
* Utility Module for GAS / Civic Tech Akita
* Copyright (c) Civic Tech Akita (https://akita-csmedia.com/)
*
* 本モジュールは Civic Tech Akita の留保知的財産権(自社資産)です。
* ライセンス・実装に関するお問い合わせ: https://akita-csmedia.com/?page_id=1476
* ====================================================================
*/
// 設定(定数)
const SHEET_NAME = 'カレンダー'; // 同期先のシート名
const EVENT_ID_COLUMN_INDEX = 6; // イベントIDを保存する列番号 (A列から数えて6番目 = F列)
const TARGET_CALENDAR_NAME = 'サンプル行事'; // 同期したいカレンダーの名前を入力してください
const SETTINGS_SHEET_NAME = '設定シート'; // 最終同期日時を記録するシート名
const LAST_SYNC_DATE_CELL = 'A1'; // 最終同期日時を記録するセル
/**
* Google カレンダーのデータを指定のスプレッドシートに差分同期します (特定名義のカレンダーのみ).
* 同じイベントIDが存在する場合、一番下の行を残し、それ以外の重複した行は削除されます。
*/
function syncSpecificCalendarToSpreadsheetCombinedDatesWithLatestIdDiffSync() {
// スクリプトプロパティからスプレッドシートIDを取得(直書き回避)
const properties = PropertiesService.getScriptProperties();
let spreadsheetId = properties.getProperty('SPREADSHEET_ID');
// プロパティが未設定の場合のサンプルフォールバック(ID表記は抽象化済み)
if (!spreadsheetId) {
spreadsheetId = '*******************************************'; // 対象のスプレッドシートIDを設定してください
}
// 特定の名前のカレンダーのIDを取得
const calendarId = getCalendarIdByName(TARGET_CALENDAR_NAME);
// カレンダーが見つからなかった場合は処理を終了
if (!calendarId) {
Logger.log('"' + TARGET_CALENDAR_NAME + '" という名前のカレンダーが見つかりませんでした。同期処理を中止します。');
return;
}
// スプレッドシートとシートを取得
const ss = SpreadsheetApp.openById(spreadsheetId);
let sheet = ss.getSheetByName(SHEET_NAME);
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
// 設定シートが存在しない場合は作成
if (!settingsSheet) {
ss.insertSheet(SETTINGS_SHEET_NAME).getRange(LAST_SYNC_DATE_CELL).setValue('');
}
// 最終同期日時を取得
let lastSyncDate = settingsSheet.getRange(LAST_SYNC_DATE_CELL).getValue();
if (lastSyncDate instanceof Date) {
Logger.log('最終同期日時: ' + Utilities.formatDate(lastSyncDate, Session.getTimeZone(), 'yyyy/MM/dd HH:mm:ss'));
} else {
lastSyncDate = new Date(0); // 初期値として Unix Epoch を設定
Logger.log('最終同期日時が設定されていません。全期間同期を実行します。');
}
// シートが存在しない場合は作成
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow(['タイトル', '開始', '終了', '場所', 'メモ', 'イベントID']);
}
// 同期期間を設定 (現在から過去1ヶ月、未来2ヶ月の範囲で変更があったイベントを取得)
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
const endDate = new Date();
endDate.setMonth(endDate.getMonth() + 2);
// カレンダーの予定を取得 (eventIdを含む、最終同期日時以降に変更があったもの)
const calendar = CalendarApp.getCalendarById(calendarId);
let events;
if (lastSyncDate) {
events = calendar.getEvents(startDate, endDate, { fields: ['items(id, summary, start, end, location, description, updated)'], updatedMin: lastSyncDate });
Logger.log('最終同期日時以降に変更のあったイベント数: ' + events.length);
} else {
events = calendar.getEvents(startDate, endDate, { fields: ['items(id, summary, start, end, location, description, updated)'] });
Logger.log('全期間のイベント数: ' + events.length);
}
const currentEventIds = new Set(events.map(event => event.getId()));
// シートから既存の予定のデータを取得 (イベントIDをキーに行番号を保存)
const existingEventRows = {}; // イベントIDとそれに対応するすべての行番号を格納
const lastRow = sheet.getLastRow();
const headerRows = 1;
if (lastRow > headerRows) {
const existingData = sheet.getRange(headerRows + 1, 1, lastRow - headerRows, EVENT_ID_COLUMN_INDEX).getValues();
existingData.forEach((row, index) => {
const eventId = row[EVENT_ID_COLUMN_INDEX - 1];
if (eventId) {
existingEventRows[eventId] = existingEventRows[eventId] || [];
existingEventRows[eventId].push(index + headerRows + 1);
}
});
}
// 取得した予定をシートに書き込みまたは更新するためのマップ
const updatedOrAddedEvents = {};
events.forEach(event => {
const eventId = event.getId();
const title = event.getTitle();
const startTime = event.getStartTime();
const endTime = event.getEndTime();
const location = event.getLocation() || '';
const description = event.getDescription() || '';
const startDateTimeFormatted = Utilities.formatDate(startTime, Session.getTimeZone(), 'yyyy/MM/dd HH:mm');
const endDateTimeFormatted = Utilities.formatDate(endTime, Session.getTimeZone(), 'yyyy/MM/dd HH:mm');
const eventData = [title, startDateTimeFormatted, endDateTimeFormatted, location, description, eventId];
updatedOrAddedEvents[eventId] = eventData;
let targetRow;
// 既存の行を更新
if (existingEventRows[eventId] && existingEventRows[eventId].length > 0) {
targetRow = Math.max(...existingEventRows[eventId]);
sheet.getRange(targetRow, 1, 1, EVENT_ID_COLUMN_INDEX).setValues([eventData]);
} else {
// 新しい予定を追加
targetRow = sheet.getLastRow() + 1;
sheet.appendRow(eventData);
}
updatedOrAddedEvents[eventId + '_row'] = targetRow; // 最新の行番号を確実に記録
});
Logger.log('updatedOrAddedEvents:', updatedOrAddedEvents);
// 重複したイベントIDの古い行を削除
const allEventIdsInSheet = {};
for (let i = headerRows + 1; i <= sheet.getLastRow(); i++) {
const eventId = sheet.getRange(i, EVENT_ID_COLUMN_INDEX).getValue();
if (eventId) {
allEventIdsInSheet[eventId] = allEventIdsInSheet[eventId] || [];
allEventIdsInSheet[eventId].push(i);
}
}
Logger.log('allEventIdsInSheet:', allEventIdsInSheet);
const rowsToDelete = [];
for (const eventId in allEventIdsInSheet) {
const rows = allEventIdsInSheet[eventId].sort((a, b) => a - b);
if (rows.length > 1) {
const latestRow = updatedOrAddedEvents[eventId + '_row'];
rows.forEach(row => {
if (row !== latestRow) {
rowsToDelete.push(row);
}
});
}
}
Logger.log('rowsToDelete:', rowsToDelete);
// 削除を実行 (逆順に削除)
rowsToDelete.sort((a, b) => b - a);
rowsToDelete.forEach(row => {
sheet.deleteRow(row);
});
// シートに存在し、カレンダーに存在しない予定を削除
for (const eventIdInSheet of Object.keys(existingEventRows)) {
if (!currentEventIds.has(eventIdInSheet) && !rowsToDelete.some(deletedRow => existingEventRows[eventIdInSheet].includes(deletedRow))) {
// カレンダーに存在せず、まだ削除されていない行を削除
const rowsToDeleteFinally = existingEventRows[eventIdInSheet].filter(row => !rowsToDelete.includes(row));
rowsToDeleteFinally.sort((a, b) => b - a).forEach(row => sheet.deleteRow(row));
}
}
// 最終同期日時を更新
settingsSheet.getRange(LAST_SYNC_DATE_CELL).setValue(new Date());
Logger.log('"' + TARGET_CALENDAR_NAME + '" カレンダーの差分同期と重複IDの処理が完了しました (最終同期日時を記録).');
}
/**
* 特定の名前のカレンダーのIDを取得します。
* @param {string} calendarName 取得したいカレンダーの名前
* @return {string|null} カレンダーのID。見つからなかった場合は null。
*/
function getCalendarIdByName(calendarName) {
const calendars = CalendarApp.getCalendarsByName(calendarName);
if (calendars.length > 0) {
return calendars[0].getId();
} else {
return null;
}
}
/**
* 登録されているカレンダーの一覧とIDをログに出力します(デバッグ用)
*/
function listAllCalendarIds() {
const calendars = CalendarApp.getAllCalendars();
calendars.forEach(calendar => {
Logger.log('カレンダー名: ' + calendar.getName() + ', ID: ' + calendar.getId());
});
}
設定・導入のステップ
導入は以下の簡単な4ステップで完了します。
ステップ1: スクリプトプロパティの設定
コード内にスプレッドシートIDを直接記述するのを防ぐため、スクリプトプロパティを利用します。
- スクリプトエディタ画面の左メニューにある 「歯車アイコン(プロジェクトの設定)」 をクリックします。
- 画面下部の「スクリプト プロパティ」セクションで 「スクリプト プロパティを追加」 を選択します。
- プロパティ名に SPREADSHEET_ID、値に対象のスプレッドシートIDを入力して保存します
ステップ2: 定数の変更
コード冒頭にある各種設定値を環境に合わせて変更します。
- TARGET_CALENDAR_NAME: 同期したいGoogleカレンダーの正確な名前(例: 'サンプル行事')
- SHEET_NAME: 予定を出力したいシート名(例: 'カレンダー')
ステップ3: 初回テスト実行
関数 syncSpecificCalendarToSpreadsheetCombinedDatesWithLatestIdDiffSync を選択し、「実行」ボタンを押します。
初回実行時はアクセス権限の承認ポップアップが表示されますので、画面の指示に従って許可してください。実行完了後、スプレッドシートに指定のカレンダーデータが出力されていれば成功です!
ステップ4: 定期実行(トリガー)の設定
自動で定期同期を行いたい場合は、以下の通りトリガーを設定します。
- スクリプトエディタの左メニュー 「時計アイコン(トリガー)」 をクリックします。
- 右下の 「トリガーを追加」 をクリックします。
- 以下の通り設定します:
- 実行する関数を選択: syncSpecificCalendarToSpreadsheetCombinedDatesWithLatestIdDiffSync
- イベントのソースを選択: 時間主導型
- 時間型トリガーのタイプを選択: 時間タイマー(例: 1時間おき、毎日など業務に合わせた間隔)
- 保存をクリックすれば、指定した間隔で自動同期が機能します。
まとめ・導入のご案内
今回は、Googleカレンダーからスプレッドシートへ高速かつ安全に予定を差分同期するGASモジュールをご紹介しました。
差分取得と重複チェックを組み合わせることで、エラーの起きにくい安定したデータ連携基盤を構築できます。
💡 この機能が最初から入った完成品サービスをお探しの方へ
「設定作業をプロに任せたい」「現場ですぐ使えるアプリとして導入したい」方向けに、今回ご紹介した機能が標準搭載された完成品ソリューションを提供しています。
🏢 インフラ構築や組織全体のDXからご相談されたい方へ
Google Workspaceの導入、独自ドメインの整備からアプリ・LINE連携まで、予算最小限で進める全体設計は「地域DX完全ロードマップ」にて詳しく公開しています。
