PHPでGoogleカレンダーから空き時間を抽出するプログラムを書いてみた
背景
筆者は現在就職活動中なのですが、たびたび**LINEやメールで「直近で空いている日程をいくつか教えてください」と言われることがよくあります。
元々自分がスマホで字打つのがあんま好きじゃないのもあって、そのたびに毎回Googleカレンダー見て、空いてる日程をスマホで打ってまたGoogleカレンダー見て...**というのが面倒だったのでGoogleカレンダーから空き時間を抽出するプログラムを作ろうと考えた次第です。
概要
GoogleカレンダーAPIのFreebusyをPHPで叩いて空き時間を抽出します
本稿では、翌日から2週間以内の10:00~20:00の間で空いている時間の取得を目標とします。
現状、開始時刻と終了時刻を入力した予定のみ対応しており、「全日」の予定には対応していないため、今後対応予定です。
(一部バグが認めらるため、修正予定です。 3/21現在)
前提
- 0,1のビット演算が分かる
- PHPをググりながらなら書ける
- GoogleカレンダーAPIをPHPで利用するための準備が済んでおり、予定の追加や取得の基本が分かる
なお、GoogleカレンダーのAPIを利用するための準備や基本操作に関しては、以下のサイトが非常に参考になります。
Google Calendar API と PHP で 予定の取得と追加をしてみるよ(準備編) | 東京のWeb制作会社LIG
Google Calendar API と PHP で 予定の取得と追加をしてみるよ(PHP編) | 東京のWeb制作会社LIG
空き時間抽出とは
さて、ここで私がゴールとしているのは、例えば以下のようなスケジュールから、空き時間を抽出する作業をプログラムにやってもらうことです。
人間が目で見ればすぐに空き時間が分かるのですが、素人プログラマな私には、プログラムだとどうやって空き時間抽出するんじゃ...という感じでした。
そこで以下の記事を参考にさせて頂きました。(なおGoogleカレンダーAPIでPHPを用いて空き時間を抽出するプログラムについての記事は私が調べた限りありませんでした)
[複数の予定から空き時間を算出するプログラム(bit演算利用) | Blow Up By Black Swan]
(https://blowup-bbs.com/python-compute-free-schedule-by-bit/)
上記より、「スケジュールをセグメントに分け、0or1のビットでFree(空いている)かBusy(予定がある)を表現すればよい」ということが分かりました。つまり、上記のスケジュールであれば、以下のように表現できる、ということです。
この場合、30分ごとのセグメントに分けています。これなら0or1でプログラムに予定のあるorなしを判断させることができますね、考えた方は天才だと思います。
Freebusyとは
参考
Google Calendar API Freebusy - Administrator
Freebusy: query | Calendar API | Google Developers
一言で言えば、その日の予定がある(Busyな)時刻を抽出してくれるAPIです。
PHPでの使い方としては以下のようになります。なお、一部不要な要素もあるため加工しています。(例では翌日〜2週間以内のものを取得しています。)
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$afterTwoWeeks = date('Y-m-d', strtotime('+15 day'));
$freebusyReq = new Google_Service_Calendar_FreeBusyRequest();
$freebusyReq->setTimeMin(date('c', $tomorrow));
$freebusyReq->setTimeMax(date('c', $afterTwoWeeks));
$freebusyReq->setTimeZone('Asia/Tokyo');
$freebusyReqItem = new Google_Service_Calendar_FreeBusyRequestItem();
$freebusyReqItem->setId(カレンダーID);
$freebusyReq->setItems(array($freebusyReqItem));
$freebusyResObj = $service->freebusy->query($freebusyReq);
$freebusy = [];
foreach ($freebusyResObj['calendars'][$calendarId]['busy'] as $item) :
// オブジェクト→配列に変換して代入
$freebusy[] = (array) $item;
endforeach;
$busyTimes = [];
foreach ($freebusy as $array) {
$busyTimes[] = ['start' => $array['start'], 'end' => $array['end']];
}
Array
(
[0] => Array
(
[start] => 2020-03-02T15:00:00+09:00
[end] => 2020-03-02T16:00:00+09:00
)
[1] => Array
(
[start] => 2020-03-03T10:00:00+09:00
[end] => 2020-03-03T18:00:00+09:00
)
[2] => Array
(
[start] => 2020-03-04T10:00:00+09:00
[end] => 2020-03-04T18:00:00+09:00
)
以下同様・・・
)
上記のように、Busy時刻の開始をstart、終了をendとして取得してくれます。
これを利用して空き時間抽出をやっていきます、、、
Freebusyデータ→ビット列への変換
以下にFreebusyデータをビット列に変換するための関数群を記載します。
// スケジュールをビット列に変換する関数
function changeScheduleToBit($busyTimes)
{
$scheduleBitData = [];
foreach ($busyTimes as $busyTime) {
// changeEventToBitは日付とビット列を返す関数
list($date, $bitData) = changeEventToBit($busyTime);
if (!array_key_exists($date, $scheduleBitData)) {
$scheduleBitData[$date] = $bitData;
} else {
// 同じ日付のビット列は論理和を求めて再代入する
$scheduleBitData[$date] = makeLogicalSum($scheduleBitData[$date], $bitData);
}
}
return $scheduleBitData;
}
// イベントをビット列に変換する関数
function changeEventToBit($busyTime)
{
// イベントの開始時刻、終了時刻、日付をセット
$eventStart = strtotime($busyTime['start']);
$eventEnd = strtotime($busyTime['end']);
$date = substr($busyTime['start'], 0, 10);
// 1日の開始時刻と終了時刻をセット
$dayStart = strtotime($date . 'T10:00:00+09:00');
$dayEnd = strtotime($date . 'T20:00:00+09:00');
// セグメント数(30分ごとに分割)をセット
$segmentCount = ($dayEnd - $dayStart) / 60 / 60 * 2;
// 30分ずつのセグメントを走査していくポインタをセット
$dayPointer = $dayStart;
$bitData = "";
// ビット列生成
for ($i = 0; $i < $segmentCount; $i++) {
if ($dayPointer > $dayEnd) {
break;
}
// FreeOrBusyをポインタにより判断
if ($eventStart <= $dayPointer && $dayPointer < $eventEnd) {
$bitData .= "1";
} else {
$bitData .= "0";
}
// ポインタを1800秒(30分)ずらす
$dayPointer = $dayPointer + 1800;
}
return [$date, $bitData];
}
// 2進数ビット列の論理和を求める関数
function makeLogicalSum($bits1, $bits2)
{
$logicalSum = '';
for ($i = 0; $i < strlen($bits1); $i++) {
$bit1 = substr($bits1, $i, 1);
$bit2 = substr($bits2, $i, 1);
if ($bit1 === "0" and $bit2 === "0") {
$logicalSum .= "0";
} else {
$logicalSum .= "1";
}
}
return $logicalSum;
}
changeScheduleToBit()の中でchangeEventToBit()によりビット列のスケジュールを生成し、日付の同じデータに関しては、その論理和(OR)をmakeLogicalSum()で求めることで1日にまとめています。
結果として、例えば以下のようなデータを取得できます。
Array
(
[2020-03-02] => 00000000001100000000
[2020-03-03] => 11111111111111110000
[2020-03-04] => 11111111111111110000
[2020-03-05] => 11000000000110000000
[2020-03-06] => 00000000001100000000
[2020-03-10] => 00000001000000000000
)
ビット列→時刻への変換+予定の前後1時間をbusyに
さて、後はビット列を時刻に戻せばいいだけかと思いますが、現実問題、人に空いているスケジュールを聞かれた際は、既に入っている予定の前後1時間ほどに余裕をもたせたスケジュールを伝えるかと思います。移動時間等もありますからね。
ということで、以下がビット列を時刻へと変換する関数と、予定の前後1時間をbusyに変換する関数です。
// 各日付のスケジュールビットデータを時刻へと変換する関数(空き時間の取得)
function getVacantTime($scheduleBitData)
{
$vacantTimes = [];
foreach($scheduleBitData as $date => $bitData) {
$dayStart = strtotime($date . 'T10:00:00+09:00');
$dayEnd = strtotime($date . 'T20:00:00+09:00');
$dayPointer = $dayStart;
$vacantTimeStart = null;
$vacantTimeEnd = null;
$segmentCount = ($dayEnd - $dayStart) / 60 / 60 * 2;
// 「1」の前後1時間をNG(busy)時間とする
$bitData = changeOneHourBeforeAndAfterBusy($bitData, $segmentCount);
// セグメントごとに0,1を判断
for ($i = 0; $i < $segmentCount; $i++) {
// 0かつ開始時間なし
if ($bitData[$i] === '0' && $vacantTimeStart === null) {
$vacantTimeStart = $dayPointer;
$dayPointer = $dayPointer + 1800;
// 0かつ開始時間ありかつポインタが19:30に達していない
} else if ($bitData[$i] === '0' && $vacantTimeStart !== null && $dayPointer < $dayEnd - 1800) {
$dayPointer = $dayPointer + 1800;
// 1かつ開始時間あり
} else if ($bitData[$i] === '1' && $vacantTimeStart !== null) {
$vacantTimeEnd = $dayPointer;
// 日付oo/ooをキーとして配列に空き時間を格納
$vacantTimes[str_replace('-', '/', substr($date, 5, 5))][] = date('H:i', $vacantTimeStart) . '〜' . date('H:i', $vacantTimeEnd);
$vacantTimeStart = null;
$vacantTimeEnd = null;
$dayPointer = $dayPointer + 1800;
// 1かつ開始時間null
} else if ($bitData[$i] === '1' && $vacantTimeStart === null) {
$dayPointer = $dayPointer + 1800;
// 0かつdayポインタが19:30で、開始時間あり
} else if ($bitData[$i] === '0' && $dayPointer === $dayEnd - 1800 && $vacantTimeStart !== null) {
$vacantTimeEnd = $dayPointer;
// 日付oo/ooをキーとして配列に空き時間を格納
$vacantTimes[str_replace('-', '/', substr($date, 5, 5))][] = date('H:i', $vacantTimeStart) . '〜' . date('H:i', $dayEnd);
}
}
}
return $vacantTimes;
}
// 1の前後1時間をbusy(1)に変換する関数
function changeOneHourBeforeAndAfterBusy($bitData, $segmentCount) {
for ($i = 0; $i < $segmentCount; $i++) {
if ($bitData[$i] == '0') {
// 0の1つor2つ隣が1の時刻はbusy(x)とする
if(
isset($bitData[$i - 1]) && $bitData[$i - 1] == '1' ||
isset($bitData[$i - 2]) && $bitData[$i - 2] == '1' ||
isset($bitData[$i + 1]) && $bitData[$i + 1] == '1' ||
isset($bitData[$i + 2]) && $bitData[$i + 2] == '1'
)
{
// 一時的にxに変更
// ここで1に変換すると上記の条件がほぼ当てはまってしまうため
$bitData[$i] = 'x';
}
}
}
// xを1に置換
for ($i = 0; $i < $segmentCount; $i++) {
if ($bitData[$i] == 'x') {
$bitData[$i] = '1';
}
}
return $bitData;
}
Array
(
[03/02] => Array
(
[0] => 10:00〜14:00
[1] => 17:00〜20:00
)
[03/03] => Array
(
[0] => 19:00〜20:00
)
[03/04] => Array
(
[0] => 19:00〜20:00
)
[03/05] => Array
(
[0] => 12:00〜14:30
[1] => 17:30〜20:00
)
[03/06] => Array
(
[0] => 10:00〜14:00
[1] => 17:00〜20:00
)
[03/10] => Array
(
[0] => 10:00〜12:30
[1] => 15:00〜20:00
)
)
getVacantTime()では、ビット列の0or1を判断することで、0の時刻を空き時間として取得しています。changeOneHourBeforeAndAfterBusy()(ネーミングセンス...)では、0の前後1時間に予定がある、つまり0の1つor2つ隣が1である場合に、一時的に'x'という文字列に変換し、最終的に'x'→1に変換しています。
このあたりのアルゴリズムはまだ改良の余地があるかと思います...精進します。
仕上げ
さて、後は予定のない日の補完をすることで、空きスケジュールの完成です。
// 予定のない日の日付、時刻を格納
for ($i = 1; $i <= 14; $i++) {
$key = date('m/d', strtotime('+' . $i . ' day'));
if (!array_key_exists($key, $vacantTimes)) {
$vacantTimes[$key][] = '10:00〜20:00';
}
}
// キーを日付順にソート
ksort($vacantTimes);
// 空き時間を出力
$schedule .= "直近2週間以内の空きスケジュールは...<br>";
foreach ($vacantTimes as $date => $vacantTimeArray) {
$schedule .= $date . ' ';
if (count($vacantTimeArray) == 1) {
$schedule .= $vacantTimeArray[0] . "<br>";
} else {
foreach ($vacantTimeArray as $index => $elem) {
if (count($vacantTimeArray) == $index + 1) {
$schedule .= $elem . "<br>";
} else {
$schedule .= $elem . ",";
}
}
}
}
$schedule .= "だよ!";
これで、以下のように直近2週間以内の空きスケジュールを取得できます。
いちいち予定を何度も見返して空きスケジュールを入力する必要がなくなりましたね。
まとめ
- PHPでGoogle Calendar APIを使って空き時間の取得を行いました。
- 現状、「全日」の予定がある場合に対応できていないので、今後対応したいと思います。
- 冗長な部分も多々あるかと思うので、そちらも改善していきたく思います。