サイボウズGaroonのスケジュールをGoogleカレンダーに連携する方法

# はじめに
Garoonのスケジュールを通知させたいと思い、Cybozu GaroonのスケジュールをGoogleカレンダーに連携してみたのでその方法について紹介します！

企業に所属する方は、権限やセキュリティの観点から外部からGaroon REST APIにアクセスできない場合や、MicrosoftのSSO認証でアカウントが紐付けられているためCybozu Desktop2が利用できない場合が多いかもしれません。 そこで、今回はGaroon上からGaroon REST APIリクエストを送信する方法と、Google Calendar APIを使って実装していきます。
Garoon上からGaroon REST APIリクエストを送信する方法については、以下の記事を参照してください。簡単に言えば、Garoonの画面上でAPIリクエストを送信する方法であり、ブラウザの開発者ツールを使えば権限の問題なく実行できます。

https://cybozu.dev/ja/garoon/docs/js-api/apis/garoon-rest-api-request/

# 事前準備
ブラウザ上でjavascriptを実行する手段として、Chrome拡張機能の[ScriptAutoRunner](https://chrome.google.com/webstore/detail/scriptautorunner/gpgjofmpmjjopcogjgdldidobhmjmdbm?hl=ja)を使用するのでインストールします。ScriptAutoRunnerは、任意のサイトで任意のJavaScriptを自動実行させる拡張機能になります。

https://qiita.com/nakajmg/items/bcbf0e5debcfa70c8d63

また、Googleカレンダーの操作にはGoogle Calendar APIを使うので、以下の記事を参考にリフレッシュトークンを取得しておきます。

https://dev.classmethod.jp/articles/using-the-google-tag-manager-api/

:::note warn
記事中では、Tag Manager APIを有効化していますが、Google Calendar APIを有効化してください。
認証コードの[スコープ](https://developers.google.com/identity/protocols/oauth2/scopes?hl=ja#calendar)は、```https://www.googleapis.com/auth/calendar```にしています。
:::

# 本題
## ScriptAutoRunner設定
基本的な使い方については、上記のリンクを参照してください。
 ~~Google APIには、クライアントライブラリが用意されていますが、ScriptAutoRunnerからはうまく利用できなかったため、Google Calendar APIへの送信にはaxiosとXMLHttpRequestを使用します。
※もし使えたらコメントお願いします
XMLHttpRequestを使う理由としては、axiosでPOST通信を行うとCORSエラーが発生し、Google Calendar APIはCORS対応していないためです。~~ 
Google APIに、axiosでPOST通信を行ってもエラーが発生しなくなったため修正しました。

axiosは外部ライブラリになるので、まず読み込んでおきます。 ScriptAutoRunnerには、URLでJavaScriptファイルを読み込む機能がありますが、自分の環境ではうまく動作しなかったため、以下のようにコードを直接記述して読み込ませます。
![ScriptAutoRunner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123678/6ee0c2df-efae-1d94-c928-41ccd9043371.png)

ホスト名も、Garoonだけで実行させればいいので`XXXX.cybozu.com`等、ご自身の環境に合わせて指定してください。

次に、上部のコードアイコンをクリックしてスクリプトを追加しておきます。

## ソースコード
以下のソースコードを先程追加したスクリプトに記述してください。

:::note warn
Google認証のクライアントID、クライアントシークレット、リフレッシュトークンは事前準備で作成したものに書き換えてください。
:::

``` javascript
// Google OAuth2認証情報
const clientId = '<YOUR_CLIENT_ID>';
const clientSecret = '<YOUR_CLIENT_SECRET>';
const refreshToken = '<YOUR_REFRESH_TOKEN>';

const calendarName = 'Garoon';

const tagGaroonUniqueEventId = 'GAROON_UNIQUE_EVENT_ID';
const tagGaroonSyncDatetime = 'GAROON_SYNC_DATETIME';

const syncdaysbefore = 7;
const syncdaysafter = 30;

const today = new Date();
const datestart = new Date();
const dateend = new Date();

datestart.setDate(today.getDate() - syncdaysbefore);
datestart.setHours(0, 0, 0, 0);
dateend.setDate(today.getDate() + syncdaysafter);
dateend.setHours(23, 59, 59, 0);

// 前回の実行時間をキャッシュするキー
const last_execution_time_key = 'lastExecutionTime';

/**
 * Google CalendarとGaroonを同期する関数
 */
async function main() {
    try {
        // cookieから前回の実行時間を取得する
        const cookies = document.cookie.split(";")
            .map(cookie => cookie.trim().split("="))
            .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
        const lastExecutionTime = cookies[last_execution_time_key];
        console.log('Last execution time:', new Date(parseInt(lastExecutionTime)).toString());

        // 現在の実行時間を取得する
        const currentExecutionTime = new Date();

        // 前回の実行時間が設定されている場合スキップ
        if (lastExecutionTime) {
            return;
        }

        const accessToken = await getAccessToken(refreshToken);
        console.log('Access token:', accessToken);
        const calendarId = await searchOrCreateCalendar(accessToken, calendarName);
        console.log('Calendar ID:', calendarId);
        const googleEvents = await getGoogleEvents(calendarId, datestart, dateend, accessToken);
        console.log('Google Calendar events:', googleEvents);
        const garoonEvents = await getGaroonEvents(datestart, dateend);

        let garoonUniqueids = await syncGaroonEventsToGoogleCalendar(garoonEvents, googleEvents, calendarId, accessToken);
        await removeDeletedGaroonEventsFromGoogleCalendar(googleEvents, garoonUniqueids, calendarId, accessToken);

        // 現在の実行時間をcookieに保存する
        const expirationDate = new Date(currentExecutionTime.getTime() + (3 * 60 * 60 * 1000)); // 3時間後の時刻
        document.cookie = `${last_execution_time_key}=${currentExecutionTime.getTime()}; expires=${expirationDate.toUTCString()}; path=/`;
    } catch (error) {
        console.error('Error:', error.message);
    }
}

main();

/**
 * 指定された期間のGaroonのイベントを取得する関数
 * @param {Date} datestart - 取得する期間の開始日時
 * @param {Date} dateend - 取得する期間の終了日時
 * @returns {Promise<Array>} - フィルタリングされたイベントの配列を含むPromiseオブジェクト
 * @throws {Error} - Garoon APIへのリクエストが失敗した場合にエラーをスローする
 */
function getGaroonEvents(datestart, dateend) {
    return new Promise((resolve, reject) => {
        garoon.api('/api/v1/schedule/events?rangeStart=' + encodeURIComponent(datestart.toISOString()) + '&rangeEnd=' + encodeURIComponent(dateend.toISOString()) + '&orderBy=start%20asc&limit=200',
            'GET', {}, (resp) => {
                // 取得が成功した場合の処理
                console.log(resp);
                const events = resp.data.events;
                const filteredEvents = [];

                // イベントのフィルタリング
                for (let i = 0; i < events.length; i++) {
                    const event = events[i];
                    if (["休み"].includes(event.eventMenu)) {
                        continue;
                    }
                    filteredEvents.push(event);
                }
                // フィルタリングされたイベントを返す
                resolve(filteredEvents);
            }, (err) => {
                // 取得が失敗した場合の処理
                console.log(err);
                reject(err);
            }
        );
    });
}

/**
 * リフレッシュトークンを使用して、Google OAuth2のアクセストークンを取得する関数
 * @param {string} refreshToken - リフレッシュトークン
 * @returns {Promise<string>} - 取得したアクセストークンを含むPromiseオブジェクト
 * @throws {Error} - アクセストークンの取得に失敗した場合にエラーをスローする
 */
async function getAccessToken(refreshToken) {
    // Google OAuth2トークンエンドポイント
    const endpoint = 'https://oauth2.googleapis.com/token';

    // リクエストパラメータ
    const params = {
        client_id: clientId,
        client_secret: clientSecret,
        refresh_token: refreshToken,
        grant_type: 'refresh_token'
    };

    try {
        // axiosでリクエストを送信してアクセストークンを取得する
        const response = await axios.post(endpoint, null, {
            params: params,
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
            }
        });

        // 取得したアクセストークンを返す
        return response.data.access_token;
    } catch (error) {
        // エラーが発生した場合はエラーメッセージをスローする
        throw new Error(error.response.data.error_description);
    }
}

/**
 * アクセストークンを使用してGoogleカレンダーを検索または作成する関数
 * @param {string} accessToken - アクセストークン
 * @param {string} calendarName - 検索または作成するカレンダーの名前
 * @returns {Promise<string>} - 検索または作成されたカレンダーのIDを含むPromiseオブジェクト
 * @throws {Error} - カレンダーの検索または作成に失敗した場合にエラーをスローする
 */
async function searchOrCreateCalendar(accessToken, calendarName) {
    // GoogleカレンダーAPIのエンドポイント
    const endpoint = `https://www.googleapis.com/calendar/v3/users/me/calendarList`;

    try {
        // axiosでGETリクエストを送信する
        const response = await axios.get(endpoint, {
            params: {
                access_token: accessToken
            }
        });

        const calendars = response.data.items;
        // 指定されたカレンダー名を持つカレンダーを探す
        const calendar = calendars.find(cal => cal.summary === calendarName);
        if (calendar) {
            // カレンダーが見つかった場合は、そのカレンダーのIDを返す
            console.log(`Found calendar: ${calendarName}`);
            return calendar.id;
        } else {
            // カレンダーが見つからなかった場合は、新しいカレンダーを作成する
            console.log(`Calendar not found: ${calendarName}. Creating a new calendar...`);
            return createCalendar(accessToken, calendarName);
        }
    } catch (error) {
        // エラーが発生した場合はエラーメッセージをスローする
        throw new Error(`Failed to search calendar: ${error.message}`);
    }
}

/**
 * Googleカレンダー上に新しいカレンダーを作成する。
 * @param {string} accessToken - Google OAuth2アクセストークン
 * @param {string} calendarName - 作成するカレンダーの名称
 * @returns {Promise<string>} - 作成されたカレンダーのIDが含まれるPromise
 * @throws {Error} - 作成に失敗した場合はエラーメッセージがスローされる
 */
async function createCalendar(accessToken, calendarName) {
    // GoogleカレンダーAPIのエンドポイント
    const endpoint = `https://www.googleapis.com/calendar/v3/calendars`;
    // 作成するカレンダーの情報
    const calendar = {
        summary: calendarName,
        timeZone: 'Asia/Tokyo'
    };

    try {
        // axiosでPOSTリクエストを送信する
        const response = await axios.post(endpoint, calendar, {
            headers: {
                'Authorization': `Bearer ${accessToken}`,
                'Content-Type': 'application/json'
            }
        });

        // 作成したカレンダーのIDを返す
        const calendarId = response.data.id;
        console.log(`Created new calendar: ${calendarName}`);
        return calendarId;
    } catch (error) {
        // エラーが発生した場合はエラーメッセージをスローする
        throw new Error(`Failed to create calendar: ${error.message}`);
    }
}

/**
 * Google Calendar APIを使用して、指定された期間内のイベントを取得する。
 * @param {string} calendarId - イベントを取得するカレンダーのID。
 * @param {Date} datestart - 取得するイベントの開始日時。
 * @param {Date} dateend - 取得するイベントの終了日時。
 * @param {string} accessToken - Google APIのアクセストークン。
 * @returns {Array} - 指定された期間内のイベントオブジェクトの配列。
 */
async function getGoogleEvents(calendarId, datestart, dateend, accessToken) {
    // Google Calendar APIのエンドポイント
    const endpoint = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
    // リクエストパラメータ
    const params = {
        singleEvents: true,
        timeMin: datestart.toISOString(),
        timeMax: dateend.toISOString()
    };
    // リクエストヘッダー
    const headers = {
        'Authorization': `Bearer ${accessToken}`
    };

    try {
        // axiosでGETリクエストを送信する
        const response = await axios.get(endpoint, { params, headers });
        return response.data.items;
    } catch (error) {
        // エラーが発生した場合はエラーメッセージをスローする
        throw new Error(`Failed to get events: ${error.message}`);
    }
}

/**
 * GaroonのイベントをGoogle Calendarに同期するメソッド。
 * @param {Object[]} garoonEvents - 同期するGaroonのイベントの配列。
 * @param {Object[]} googleEvents - Google Calendarから取得したイベントの配列。
 * @param {string} calendarId - 同期するGoogle CalendarのID。
 * @param {string} accessToken - Google APIのアクセストークン。
 * @returns {string[]} - GaroonのユニークID。
 */
async function syncGaroonEventsToGoogleCalendar(garoonEvents, googleEvents, calendarId, accessToken) {
    let garoonUniqueids = [];

    // 各Garoonのイベントについて処理を行う
    for (let i = 0; i < garoonEvents.length; i++) {
        const garoonEvent = garoonEvents[i];

        let uniqueid = getGaroonUniqueEventID(garoonEvent);
        garoonUniqueids.push(uniqueid);
        console.log(["GAROON EVENT", garoonEvent.subject, garoonEvent.start.dateTime, garoonEvent.updatedAt, uniqueid]);

        const googleEvent = googleEvents.find((e) => e.extendedProperties.private.GAROON_UNIQUE_EVENT_ID === uniqueid);
        if (googleEvent) {
            // Garoonの予定がすでにGoogle Calendarに存在する
            if (new Date(garoonEvent.updatedAt) > new Date(googleEvent.extendedProperties.private.GAROON_SYNC_DATETIME)) {
                // 最終同期日時よりも更新日時が新しいときは既存のイベントを削除
                deleteGoogleEvent(calendarId, googleEvent.id, accessToken);
                // 1秒待機
                await new Promise(resolve => setTimeout(resolve, 1000));
                console.log("UPDATED. DELETED EXISTING EVENT");
            } else {
                // 更新がない場合はスキップ
                console.log("SKIPPED");
                continue;
            }
        }

        // Google Calendarに新しいイベントを作成する
        const eventtitle = (garoonEvent.eventMenu ? (garoonEvent.eventMenu + " ") : "") + garoonEvent.subject;
        const timeZone = 'Asia/Tokyo';
        let eventstart = new Date(garoonEvent.start.dateTime);
        let eventend = new Date(garoonEvent.end.dateTime);
        const isAllDayEvent = garoonEvent.isAllDay;
        const isStartOnlyEvent = garoonEvent.isStartOnly;
        let start = {
            timeZone: timeZone
        };
        let end = {
            timeZone: timeZone
        };

        if (isAllDayEvent) {
            // 終日の予定の場合は'YYYY-MM-DD'形式
            start.date = formatDate(eventstart);
            end.date = formatDate(eventend);
        } else {
            if (isStartOnlyEvent) {
                eventend = new Date(garoonEvent.start.dateTime);
            }

            start.dateTime = eventstart.toISOString();
            end.dateTime = eventend.toISOString();
        }

        const eventId = await createGoogleEvent(accessToken, calendarId, eventtitle, start, end, garoonEvent.notes, uniqueid);
        console.log('Created event ID:', eventId);

        // 1秒待機
        await new Promise(resolve => setTimeout(resolve, 1000));
    }

    return garoonUniqueids;
}

/**
 * Garoonイベントから削除された場合にGoogleカレンダーから削除する。
 * @param {string} calendarId - カレンダーID
 * @param {Array} googleEvents - Google Calendarにあるイベントの配列
 * @param {Array} garoonUniqueids - GaroonのイベントのユニークIDの配列
 * @param {string} accessToken - アクセストークン
 * @returns {Promise<void>}
 */
async function removeDeletedGaroonEventsFromGoogleCalendar(googleEvents, garoonUniqueids, calendarId, accessToken) {
    for (let i = 0; i < googleEvents.length; i++) {
        const googleEvent = googleEvents[i];

        if (!googleEvent.extendedProperties || !googleEvent.extendedProperties.private) {
            continue;
        }
        let uniqueid = googleEvent.extendedProperties.private.GAROON_UNIQUE_EVENT_ID;
        if (!garoonUniqueids.includes(uniqueid)) {
            // Garoonイベントにないときはイベントを削除
            await deleteGoogleEvent(calendarId, googleEvent.id, accessToken);
            // 1秒待機
            await new Promise(resolve => setTimeout(resolve, 1000));
            console.log("UPDATED. DELETED EXISTING EVENT");
        }
    }
}

/**
 * Googleカレンダーに新しいイベントを作成する関数
 * @param {string} accessToken - Googleカレンダーにアクセスするためのアクセストークン
 * @param {string} calendarId - イベントを作成するカレンダーのID
 * @param {string} eventTitle - イベントのタイトル
 * @param {Date} eventStart - イベントの開始日時
 * @param {Date} eventEnd - イベントの終了日時
 * @param {string} eventDescription - イベントの説明
 * @param {string} uniqueid - イベントの一意のID（garoonのuniqueid）
 * @returns {Promise<string>} - 作成されたイベントのID
 * @throws {Error} - イベントの作成に失敗した場合にスローされる
 */
async function createGoogleEvent(accessToken, calendarId, eventTitle, eventStart, eventEnd, eventDescription, uniqueid) {
    // Google Calendar APIのエンドポイント
    const endpoint = `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?access_token=${accessToken}`;
    // イベント情報を設定
    const event = {
        summary: eventTitle,
        description: eventDescription,
        start: eventStart,
        end: eventEnd,
        reminders: {
            useDefault: true
        },
        extendedProperties: {
            private: {
                // Garoonのuniqueidと同期日時を設定
                [tagGaroonUniqueEventId]: uniqueid,
                [tagGaroonSyncDatetime]: today.toISOString()
            }
        }
    };

    try {
        // axiosでPOSTリクエストを送信する
        const response = await axios.post(endpoint, event, {
            headers: {
                'Authorization': `Bearer ${accessToken}`,
                'Content-Type': 'application/json'
            }
        });

        // 作成したイベントのIDを返す
        const eventId = response.data.id;
        console.log(`Created new event: ${eventTitle}`);
        return eventId;
    } catch (error) {
        // エラーが発生した場合はエラーメッセージをスローする
        throw new Error(`Failed to create event: ${error.message}`);
    }
}

/**
 * Google Calendar上のイベントを削除する
 * @param {string} calendarId 削除するイベントが属するGoogleカレンダーのID
 * @param {string} eventId 削除するイベントのID
 * @param {string} accessToken Google APIのアクセストークン
 * @throws {Error} ネットワークエラーや削除に失敗した場合にエラーを投げる
 */
async function deleteGoogleEvent(calendarId, eventId, accessToken) {
    // Google Calendar APIのエンドポイント
    const endpoint = `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${eventId}`;
    try {
        // axiosでDELETEリクエストを送信
        const response = await axios.delete(endpoint, {
            headers: {
                Authorization: `Bearer ${accessToken}`,
            },
        });
        console.log('Deleted event:', eventId);
    } catch (error) {
        // エラーが発生した場合はエラーを投げる
        throw new Error(`Failed to delete event: ${error.message}`);
    }
}

/**
 * ガルーンイベントから一意なIDを生成する関数
 * @param {Object} garoonevent - ガルーンイベントオブジェクト
 * @return {string} - 一意なID
 */
function getGaroonUniqueEventID(garoonevent) {
    return garoonevent.id + (garoonevent.repeatId ? ("-" + garoonevent.repeatId) : "");
}

/**
 * 日付を 'YYYY-MM-DD' 形式に整形する関数
 * @param {Date} dt - 日付オブジェクト
 * @return {string} - 整形された日付文字列
 */
function formatDate(dt) {
    var y = dt.getFullYear();
    var m = ('00' + (dt.getMonth() + 1)).slice(-2);
    var d = ('00' + dt.getDate()).slice(-2);
    return (y + '-' + m + '-' + d);
}
```

### 注意点
今回のコードでは、Googleカレンダーの予定を操作しても反映されません。~~また、Garoonの予定が削除された場合に、Googleカレンダーの予定からは削除されないので手動で削除する必要があります。~~
Garoonの予定が削除されたときにGoogleカレンダーの予定からも削除するように修正しました。

# おわりに
以上で、Cybozu GaroonのスケジュールをGoogleカレンダーに連携する方法について紹介しました。
この記事が、GaroonとGoogleカレンダーの連携に興味を持っている方や、同様の問題に直面している方にとって役立つ情報となれば幸いです。
バグやなにかアイデアがあればコメントお願いします！