Google カレンダーと期限付きテーブルの双方向同期
バックグラウンドサーバースクリプトを定期実行し、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 への変更および旧バージョンとの互換性について」を参照してください。
処理の流れ
図を読み込み中…
スケジュール実行のたびに、次の順で処理します。
- リフレッシュトークンから Google のアクセストークンを取得する
- Google → プリザンター: イベント一覧を取得し、イベントごとに
items.Upsert(イベント ID をキー)する - プリザンター → Google: プリザンターの API でレコード一覧を取得し、
- イベント ID が未設定のレコードは Google にイベントを作成し、返されたイベント ID をレコードに保存する
- イベント ID が設定済みのレコードは Google のイベントを更新する
- 処理件数を
logs.LogInfoで SysLogs に記録する
前提条件
Script.json
App_Data/Parameters/Script.json の BackgroundServerScript を true にします(既定値は false です。Script.json)。
{
"ServerScript": true,
"BackgroundServerScript": true
}WARNING
パラメータファイルの変更後はプリザンターの再起動が必要です。パラメータ再読み込み機能を使う場合は、特権ユーザでログインして実行してください。
API キー
プリザンター → Google の処理ではプリザンターの API でレコードを取得・更新するため、対象テーブルの編集権限を持つユーザの API キーを作成しておきます。
期限付きテーブル
イベントを格納する期限付きテーブルを次の構成で作成し、サイト ID を控えておきます。
| カラム | 用途 | 説明 |
|---|---|---|
| タイトル | イベント名 | Google カレンダーのイベントタイトル |
| 内容 | イベント詳細 | イベントの説明 |
| 開始 | 開始日時 | イベントの開始日時 |
| 完了 | 終了日時 | イベントの終了日時 |
| 分類A | Google イベント ID | 同期のキーとして使用 |
| 分類B | 同期元 | google または pleasanter。レコードの作成元を識別 |
分類 C 以降を使っても構いません。その場合はスクリプト内の ClassA・ClassB をすべて修正してください。
Google Calendar API の準備
OAuth クライアントの作成
- Google Cloud Console でプロジェクトを作成または選択する
- 「API とサービス」→「ライブラリ」から Google Calendar API を有効化する
- 「API とサービス」→「認証情報」→「認証情報を作成」→「OAuth クライアント ID」を選択する
- アプリケーションの種類は「デスクトップアプリ」を選択して作成する
- クライアント ID とクライアントシークレットを控える
リフレッシュトークンの取得
サーバースクリプト(V8 / ClearScript)では OAuth の対話的認証フローを実行できません。そのため、事前にリフレッシュトークンを取得しておき、スクリプトではそこからアクセストークンを取得します。OAuth 2.0 Playground を使います。
- OAuth 2.0 Playground にアクセスする
- 右上の歯車アイコンから「Use your own OAuth credentials」にチェックを入れる
- 控えておいたクライアント ID とクライアントシークレットを入力する
- 「Select & authorize APIs」で
https://www.googleapis.com/auth/calendarを選択して「Authorize APIs」をクリックする - Google アカウントでログインし、アクセスを許可する
- 「Exchange authorization code for tokens」をクリックする
- 表示されたリフレッシュトークンを控える
WARNING
リフレッシュトークンは Google カレンダーへのフルアクセス権限を持つ認証情報です。安全な場所に保管し、ソースコード管理システムにはコミットしないでください。
カレンダー ID の確認
Google カレンダーで対象カレンダーの「設定と共有」を開き、「カレンダーの統合」セクションにあるカレンダー ID を控えます。プライマリカレンダーの場合、カレンダー ID は Gmail アドレスと同じです。
バックグラウンドサーバースクリプトの登録
- 特権ユーザでログインする
- 「テナント管理」→「サーバスクリプト」タブを開く
- 「新規作成」をクリックする
- 次を設定して「追加」→「更新」
| 項目 | 設定値 |
|---|---|
| タイトル | Google カレンダー双方向同期 |
| 条件 | バックグラウンドサーバスクリプト |
| スケジュール | 毎時(任意) |
| 関数化 | オン(スクリプトの先頭で return を使うため) |
| 無効 | チェックなし |
| サーバスクリプト | 下記 |
スクリプト
// --- 設定 ---
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 |
calendarId | Google カレンダーの ID(Gmail アドレスまたはカレンダー固有の ID) |
clientId | Google OAuth 2.0 のクライアント ID |
clientSecret | Google OAuth 2.0 のクライアントシークレット |
refreshToken | Google 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=250 | 1 回のリクエストで取得する最大件数(Google Calendar API の上限は 2500 件) |
Keys: ['ClassA'] | イベント ID(分類A)を Upsert のキーにする。同じ ID のレコードは更新、新しいイベントは作成 |
event.start.dateTime | 時刻指定イベントは ISO 8601 形式の日時。終日イベントは event.start.date に日付のみが入る |
event.status | cancelled のイベントはスキップ |
同期の競合
同じイベントが両方で変更された場合、このスクリプトでは次のようになります。
| 状況 | 動作 |
|---|---|
| Google で変更、プリザンターで未変更 | ① で Google の内容がプリザンターに反映される |
| プリザンターで変更、Google で未変更 | ② でプリザンターの内容が Google に反映される |
| 両方で変更 | ① で Google の内容がプリザンターに反映された後、② でその内容が Google に再反映される(結果的に Google の内容が優先) |
WARNING
このスクリプトでは Google カレンダー側の変更が優先されます。プリザンター側の変更を優先したい場合は、② → ① の順序に変更してください。更新日時による厳密な競合解決が必要な場合は、updatedMin パラメータやレコードの UpdatedTime を使った差分同期の実装を検討してください。
カスタマイズ例
終日イベントとして登録する
終日イベントでは dateTime ではなく date フィールドを使います。開始と終了が同じ日のレコードを終日イベントにする例です。
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(実行中)のレコードだけを対象にします。
httpClient.Content = JSON.stringify({
ApiVersion: 1.1,
ApiKey: apiKey,
View: {
ColumnFilterHash: {
Status: '[100,150,200]'
},
ApiGetPageSize: 500
}
});Google で削除されたイベントを完了にする
Google 側で削除されたイベントは status が cancelled になります。syncGoogleToPleasanter の forEach 内で、対応するレコードのステータスを 900(完了)に更新する例です。
// 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 パラメータを追加する必要があります。