Skip to content

Google カレンダーと期限付きテーブルの双方向同期 ​

第4版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

バックグラウンドサーバースクリプトを定期実行し、Google カレンダーのイベントをプリザンターの期限付きテーブルに取り込み(Google → プリザンター)、プリザンターで登録したレコードを Google カレンダーに反映します(プリザンター → Google)。 Google のイベント ID を分類項目に格納し、items.Upsert のキーにすることで重複を防ぎます。認証は事前に取得した OAuth のリフレッシュトークンからアクセストークンを都度取得する方式です。

購読するだけなら iCal フィードという選択肢もある

プリザンター → カレンダーの一方向で十分な場合、サイトを iCalendar(.ics)の URL で配信してカレンダーアプリに購読させる方法が考えられます。ただし 1.5.8.1 には iCal を配信する機能は無く、本体の改修が必要です。設計は iCal フィード(カレンダー購読 URL)の設計 にまとめています。

INFO

API リクエスト例は Api.json の Compatibility_1_3_12 が false(既定値)であることを前提としています。詳しくは公式マニュアルの「既定のAPIバージョン 1.1 への変更および旧バージョンとの互換性について」を参照してください。

処理の流れ ​

図を読み込み中…

スケジュール実行のたびに、次の順で処理します。

  1. リフレッシュトークンから Google のアクセストークンを取得する
  2. Google → プリザンター: イベント一覧を取得し、イベントごとに items.Upsert(イベント ID をキー)する
  3. プリザンター → Google: プリザンターの API でレコード一覧を取得し、
    • イベント ID が未設定のレコードは Google にイベントを作成し、返されたイベント ID をレコードに保存する
    • イベント ID が設定済みのレコードは Google のイベントを更新する
  4. 処理件数を logs.LogInfo で SysLogs に記録する

前提条件 ​

Script.json ​

App_Data/Parameters/Script.json の BackgroundServerScript を true にします(既定値は false です。Script.json)。

json
{
    "ServerScript": true,
    "BackgroundServerScript": true
}

WARNING

パラメータファイルの変更後はプリザンターの再起動が必要です。パラメータ再読み込み機能を使う場合は、特権ユーザでログインして実行してください。

API キー ​

プリザンター → Google の処理ではプリザンターの API でレコードを取得・更新するため、対象テーブルの編集権限を持つユーザの API キーを作成しておきます。

期限付きテーブル ​

イベントを格納する期限付きテーブルを次の構成で作成し、サイト ID を控えておきます。

カラム用途説明
タイトルイベント名Google カレンダーのイベントタイトル
内容イベント詳細イベントの説明
開始開始日時イベントの開始日時
完了終了日時イベントの終了日時
分類AGoogle イベント ID同期のキーとして使用
分類B同期元google または pleasanter。レコードの作成元を識別

分類 C 以降を使っても構いません。その場合はスクリプト内の ClassA・ClassB をすべて修正してください。

Google Calendar API の準備 ​

OAuth クライアントの作成 ​

  1. Google Cloud Console でプロジェクトを作成または選択する
  2. 「API とサービス」→「ライブラリ」から Google Calendar API を有効化する
  3. 「API とサービス」→「認証情報」→「認証情報を作成」→「OAuth クライアント ID」を選択する
  4. アプリケーションの種類は「デスクトップアプリ」を選択して作成する
  5. クライアント ID とクライアントシークレットを控える

リフレッシュトークンの取得 ​

サーバースクリプト(V8 / ClearScript)では OAuth の対話的認証フローを実行できません。そのため、事前にリフレッシュトークンを取得しておき、スクリプトではそこからアクセストークンを取得します。OAuth 2.0 Playground を使います。

  1. OAuth 2.0 Playground にアクセスする
  2. 右上の歯車アイコンから「Use your own OAuth credentials」にチェックを入れる
  3. 控えておいたクライアント ID とクライアントシークレットを入力する
  4. 「Select & authorize APIs」で https://www.googleapis.com/auth/calendar を選択して「Authorize APIs」をクリックする
  5. Google アカウントでログインし、アクセスを許可する
  6. 「Exchange authorization code for tokens」をクリックする
  7. 表示されたリフレッシュトークンを控える

WARNING

リフレッシュトークンは Google カレンダーへのフルアクセス権限を持つ認証情報です。安全な場所に保管し、ソースコード管理システムにはコミットしないでください。

カレンダー ID の確認 ​

Google カレンダーで対象カレンダーの「設定と共有」を開き、「カレンダーの統合」セクションにあるカレンダー ID を控えます。プライマリカレンダーの場合、カレンダー ID は Gmail アドレスと同じです。

バックグラウンドサーバースクリプトの登録 ​

  1. 特権ユーザでログインする
  2. 「テナント管理」→「サーバスクリプト」タブを開く
  3. 「新規作成」をクリックする
  4. 次を設定して「追加」→「更新」
項目設定値
タイトルGoogle カレンダー双方向同期
条件バックグラウンドサーバスクリプト
スケジュール毎時(任意)
関数化オン(スクリプトの先頭で return を使うため)
無効チェックなし
サーバスクリプト下記

スクリプト ​

js
// --- 設定 ---
var apiKey = 'YOUR_API_KEY';
var baseUrl = 'https://pleasanter.example.com/'; // プリザンターの URL(末尾の / まで)
var targetSiteId = 12345;                      // 期限付きテーブルのサイトID
var calendarId = 'calendar-id@example.com';    // GoogleカレンダーID
var clientId = 'YOUR_CLIENT_ID';               // OAuth クライアントID
var clientSecret = 'YOUR_CLIENT_SECRET';       // OAuth クライアントシークレット
var refreshToken = 'YOUR_REFRESH_TOKEN';       // OAuth リフレッシュトークン
// --- 設定ここまで ---

// アクセストークンを取得
var accessToken = getAccessToken(clientId, clientSecret, refreshToken);
if (!accessToken) return;

// ① Googleカレンダー → プリザンター
var importCount = syncGoogleToPleasanter(
    accessToken, calendarId, targetSiteId
);
logs.LogInfo('Google → Pleasanter: ' + importCount + '件');

// ② プリザンター → Googleカレンダー
var exportResult = syncPleasanterToGoogle(
    accessToken, calendarId, targetSiteId, apiKey, baseUrl
);
logs.LogInfo(
    'Pleasanter → Google: 作成=' + exportResult.created
    + '件, 更新=' + exportResult.updated + '件'
);

// --- 関数定義 ---

// リフレッシュトークンからアクセストークンを取得する
function getAccessToken(clientId, clientSecret, refreshToken) {
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri = 'https://oauth2.googleapis.com/token';
    httpClient.Content =
        'client_id=' + encodeURIComponent(clientId)
        + '&client_secret=' + encodeURIComponent(clientSecret)
        + '&refresh_token=' + encodeURIComponent(refreshToken)
        + '&grant_type=refresh_token';
    httpClient.MediaType = 'application/x-www-form-urlencoded';
    var response = httpClient.Post();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('アクセストークン取得に失敗しました');
        return null;
    }

    return JSON.parse(response).access_token;
}

// Googleカレンダーのイベントをプリザンターに取り込む
function syncGoogleToPleasanter(accessToken, calendarId, targetSiteId) {
    // 同期対象期間(過去30日〜未来90日)
    var now = new Date();
    var timeMin = new Date(now);
    timeMin.setDate(timeMin.getDate() - 30);
    var timeMax = new Date(now);
    timeMax.setDate(timeMax.getDate() + 90);

    var url = 'https://www.googleapis.com/calendar/v3/calendars/'
        + encodeURIComponent(calendarId) + '/events'
        + '?timeMin=' + timeMin.toISOString()
        + '&timeMax=' + timeMax.toISOString()
        + '&singleEvents=true'
        + '&orderBy=startTime'
        + '&maxResults=250';

    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri = url;
    httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + accessToken);
    var response = httpClient.Get();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('Googleカレンダーのイベント取得に失敗しました');
        return 0;
    }

    var result = JSON.parse(response);
    var events = result.items || [];
    var upsertCount = 0;

    events.forEach(function (event) {
        try {
            if (event.status === 'cancelled') return;

            var startTime = event.start.dateTime || event.start.date;
            var endTime = event.end.dateTime || event.end.date;

            var data = {
                Keys: ['ClassA'],
                Title: event.summary || '(無題)',
                Body: event.description || '',
                ClassA: event.id,
                ClassB: 'google',
                StartTime: startTime,
                CompletionTime: endTime
            };

            var upsertResult = items.Upsert(
                targetSiteId, JSON.stringify(data)
            );

            if (upsertResult) {
                upsertCount++;
            }
        } catch (e) {
            logs.LogInfo(
                'イベント取り込みエラー: ' + event.id + ' - ' + e.message
            );
        }
    });

    return upsertCount;
}

// プリザンターのレコードをGoogleカレンダーに反映する
function syncPleasanterToGoogle(
    accessToken, calendarId, targetSiteId, apiKey, baseUrl
) {
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri = baseUrl + 'api/items/' + targetSiteId + '/get';
    httpClient.Content = JSON.stringify({
        ApiVersion: 1.1,
        ApiKey: apiKey,
        View: {
            ApiGetPageSize: 500
        }
    });
    httpClient.MediaType = 'application/json';
    var response = httpClient.Post();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('プリザンターのレコード取得に失敗しました');
        return { created: 0, updated: 0 };
    }

    var result = JSON.parse(response);
    var records = result.Response.Data || [];
    var created = 0;
    var updated = 0;

    records.forEach(function (record) {
        try {
            var googleEventId = record.ClassA;
            var title = record.Title;
            var body = record.Body || '';
            var startTime = record.StartTime;
            var completionTime = record.CompletionTime;

            // 開始日時が未設定のレコードはスキップ
            if (!startTime) return;

            // 終了日時が未設定の場合は開始日時の1時間後を設定
            if (!completionTime) {
                var end = new Date(startTime);
                end.setHours(end.getHours() + 1);
                completionTime = end.toISOString();
            }

            var eventData = {
                summary: title,
                description: body,
                start: { dateTime: startTime },
                end: { dateTime: completionTime }
            };

            if (googleEventId) {
                // 既存イベントの更新
                updateGoogleEvent(
                    accessToken, calendarId, googleEventId, eventData
                );
                updated++;
            } else {
                // 新規イベントの作成と、イベントIDの保存
                var newEventId = createGoogleEvent(
                    accessToken, calendarId, eventData
                );
                if (newEventId) {
                    saveEventId(record.IssueId, newEventId, apiKey, baseUrl);
                    created++;
                }
            }
        } catch (e) {
            logs.LogInfo(
                'レコード同期エラー: ' + record.IssueId + ' - ' + e.message
            );
        }
    });

    return { created: created, updated: updated };
}

// Googleカレンダーにイベントを作成し、イベントIDを返す
function createGoogleEvent(accessToken, calendarId, eventData) {
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri =
        'https://www.googleapis.com/calendar/v3/calendars/'
        + encodeURIComponent(calendarId) + '/events';
    httpClient.Content = JSON.stringify(eventData);
    httpClient.MediaType = 'application/json';
    httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + accessToken);
    var response = httpClient.Post();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('Googleイベント作成に失敗しました');
        return null;
    }

    return JSON.parse(response).id;
}

// Googleカレンダーのイベントを更新する(Patch で差分更新)
function updateGoogleEvent(accessToken, calendarId, eventId, eventData) {
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri =
        'https://www.googleapis.com/calendar/v3/calendars/'
        + encodeURIComponent(calendarId) + '/events/'
        + encodeURIComponent(eventId);
    httpClient.Content = JSON.stringify(eventData);
    httpClient.MediaType = 'application/json';
    httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + accessToken);
    httpClient.Patch();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('Googleイベント更新に失敗しました: ' + eventId);
    }
}

// プリザンターのレコードにGoogleイベントIDを保存する
function saveEventId(recordId, googleEventId, apiKey, baseUrl) {
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri = baseUrl + 'api/items/' + recordId + '/update';
    httpClient.Content = JSON.stringify({
        ApiVersion: 1.1,
        ApiKey: apiKey,
        ClassA: googleEventId
    });
    httpClient.MediaType = 'application/json';
    httpClient.Post();

    if (!httpClient.IsSuccess) {
        logs.LogInfo('イベントID保存に失敗しました: ' + recordId);
    }
}

設定項目 ​

設定項目説明
apiKeyプリザンターの API キー
baseUrlプリザンターの URL(https:// から末尾の / まで)。サブディレクトリ配置ならそのパスも含める
targetSiteId期限付きテーブルのサイト ID
calendarIdGoogle カレンダーの ID(Gmail アドレスまたはカレンダー固有の ID)
clientIdGoogle OAuth 2.0 のクライアント ID
clientSecretGoogle OAuth 2.0 のクライアントシークレット
refreshTokenGoogle OAuth 2.0 のリフレッシュトークン

baseUrl に context.ApplicationPath は使えません。ApplicationPath はリクエストの PathBase から作るホスト名なしのパス(例: / や /pleasanter/)で(Context.cs)、httpClient.RequestUri に必要な絶対 URL になりません。そのため URL を直接書く形に修正しています。

処理件数やエラーは logs.LogInfo で SysLogs に記録します(ServerScriptModelLogs.cs)。context.Log の出力は画面への応答に載るだけで、画面を持たないバックグラウンドサーバースクリプトでは残らないため、logs.LogInfo に修正しています。

ポイント ​

処理説明
httpClient.RequestHeaders.Clear()各リクエストの前に呼び出す。RequestHeaders は送信後も残るため、Google 用の Authorization ヘッダがプリザンターの API 呼び出しに付いたり、Add が重複して例外になったりするのを防ぐ。ResponseHeaders は送信のたびに内部でクリアされる(ServerScriptModelHttpClient.cs)
アクセストークン有効期限があるため、実行のたびにリフレッシュトークンから取得する。MediaType は application/x-www-form-urlencoded
同期対象期間timeMin(過去 30 日)〜timeMax(未来 90 日)。環境に合わせて調整
singleEvents=true繰り返しイベントを個別の単発イベントとして展開する。各回に固有の ID が付くため Upsert で正しく管理される
maxResults=2501 回のリクエストで取得する最大件数(Google Calendar API の上限は 2500 件)
Keys: ['ClassA']イベント ID(分類A)を Upsert のキーにする。同じ ID のレコードは更新、新しいイベントは作成
event.start.dateTime時刻指定イベントは ISO 8601 形式の日時。終日イベントは event.start.date に日付のみが入る
event.statuscancelled のイベントはスキップ

同期の競合 ​

同じイベントが両方で変更された場合、このスクリプトでは次のようになります。

状況動作
Google で変更、プリザンターで未変更① で Google の内容がプリザンターに反映される
プリザンターで変更、Google で未変更② でプリザンターの内容が Google に反映される
両方で変更① で Google の内容がプリザンターに反映された後、② でその内容が Google に再反映される(結果的に Google の内容が優先)

WARNING

このスクリプトでは Google カレンダー側の変更が優先されます。プリザンター側の変更を優先したい場合は、② → ① の順序に変更してください。更新日時による厳密な競合解決が必要な場合は、updatedMin パラメータやレコードの UpdatedTime を使った差分同期の実装を検討してください。

カスタマイズ例 ​

終日イベントとして登録する ​

終日イベントでは dateTime ではなく date フィールドを使います。開始と終了が同じ日のレコードを終日イベントにする例です。

js
var eventData;
var startDate = new Date(startTime);
var endDate = new Date(completionTime);

// 開始と終了が同じ日の場合は終日イベントとして扱う
if (startDate.toDateString() === endDate.toDateString()) {
    // 終日イベント(date形式)
    var nextDay = new Date(startDate);
    nextDay.setDate(nextDay.getDate() + 1);
    eventData = {
        summary: title,
        description: body,
        start: { date: startDate.toISOString().split('T')[0] },
        end: { date: nextDay.toISOString().split('T')[0] }
    };
} else {
    // 時刻指定イベント(dateTime形式)
    eventData = {
        summary: title,
        description: body,
        start: { dateTime: startTime },
        end: { dateTime: completionTime }
    };
}

INFO

終日イベントの終了日は「その日を含まない」仕様です。3 月 10 日の終日イベントなら、start.date は 2026-03-10、end.date は 2026-03-11 を指定します。

特定ステータスのレコードだけを同期する ​

レコード取得時に ColumnFilterHash で絞り込みます。次の例は、ステータスが 100(未着手)、150(準備)、200(実行中)のレコードだけを対象にします。

js
httpClient.Content = JSON.stringify({
    ApiVersion: 1.1,
    ApiKey: apiKey,
    View: {
        ColumnFilterHash: {
            Status: '[100,150,200]'
        },
        ApiGetPageSize: 500
    }
});

Google で削除されたイベントを完了にする ​

Google 側で削除されたイベントは status が cancelled になります。syncGoogleToPleasanter の forEach 内で、対応するレコードのステータスを 900(完了)に更新する例です。

js
// syncGoogleToPleasanter 内の forEach に追加
if (event.status === 'cancelled') {
    // 削除されたイベントに対応するレコードのステータスを更新
    httpClient.RequestHeaders.Clear();
    httpClient.RequestUri = baseUrl + 'api/items/' + targetSiteId + '/get';
    httpClient.Content = JSON.stringify({
        ApiVersion: 1.1,
        ApiKey: apiKey,
        View: {
            ColumnFilterHash: {
                ClassA: event.id
            }
        }
    });
    httpClient.MediaType = 'application/json';
    var findResponse = httpClient.Post();

    if (httpClient.IsSuccess) {
        var findResult = JSON.parse(findResponse);
        var matched = findResult.Response.Data || [];
        matched.forEach(function (rec) {
            httpClient.RequestHeaders.Clear();
            httpClient.RequestUri =
                baseUrl + 'api/items/' + rec.IssueId + '/update';
            httpClient.Content = JSON.stringify({
                ApiVersion: 1.1,
                ApiKey: apiKey,
                Status: 900
            });
            httpClient.MediaType = 'application/json';
            httpClient.Post();
        });
    }
    return;
}

WARNING

削除イベントを取得するには、イベント一覧取得 URL に showDeleted=true パラメータを追加する必要があります。

関連ページ ​

変更履歴

第4版外部連携の改修・設計メモ(iCal・RSS/Atom・Webhook 送受信・iPaaS・短縮 URL・POP 受信・マスターデータ同期)を追加
第3版「外部連携・AI」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「外部連携・AI」セクションの記事を追加