PHPでGoogle Calendar API を使って空き時間を抽出してみた

# 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](https://liginc.co.jp/472637) 
[Google Calendar API と PHP で 予定の取得と追加をしてみるよ（PHP編） | 東京のWeb制作会社LIG](https://liginc.co.jp/474321)

# 空き時間抽出とは
　さて、ここで私がゴールとしているのは、例えば以下のようなスケジュールから、空き時間を抽出する作業をプログラムにやってもらうことです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491447/dfe6a2a5-eb06-fcc3-4435-1fb468cf462d.png)
　人間が目で見ればすぐに空き時間が分かるのですが、素人プログラマな私には、プログラムだとどうやって空き時間抽出するんじゃ...という感じでした。  
　そこで以下の記事を参考にさせて頂きました。（なおGoogleカレンダーAPIでPHPを用いて**空き時間を抽出する**プログラムについての記事は私が調べた限りありませんでした）
[複数の予定から空き時間を算出するプログラム(bit演算利用) | Blow Up By Black Swan] 
(https://blowup-bbs.com/python-compute-free-schedule-by-bit/)   
 　上記より、「**スケジュールをセグメントに分け、0or1のビットでFree（空いている）かBusy（予定がある）を表現すればよい**」ということが分かりました。つまり、上記のスケジュールであれば、以下のように表現できる、ということです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491447/b2f63f84-f6ce-2db7-3702-a8aaf8962bfe.png)
　この場合、**30分ごとのセグメントに分けています**。これなら0or1でプログラムに予定のあるorなしを判断させることができますね、考えた方は天才だと思います。

# Freebusyとは
参考
[Google Calendar API Freebusy - Administrator](https://administrator.de/forum/google-calendar-api-freebusy-506314.html)
[Freebusy: query  |  Calendar API  |  Google Developers](https://developers.google.com/calendar/v3/reference/freebusy/query)

　一言で言えば、その日の予定がある（Busyな）時刻を抽出してくれるAPIです。
PHPでの使い方としては以下のようになります。なお、一部不要な要素もあるため加工しています。（例では翌日〜2週間以内のものを取得しています。）

```php5:freebusy.php

$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']];
}
```

```:$busyTimesの中身
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データをビット列に変換するための関数群を記載します。

```php5:functions.php
// スケジュールをビット列に変換する関数
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];
}

// ２進数ビット列の論理和を求める関数
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日にまとめています。
　結果として、例えば以下のようなデータを取得できます。

```:$scheduleBitData
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に変換する関数**です。

```php5:functions.php
// 各日付のスケジュールビットデータを時刻へと変換する関数（空き時間の取得）
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;
}
```

```:$vacantTimes
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に変換しています。
　このあたりのアルゴリズムはまだ改良の余地があるかと思います...精進します。
# 仕上げ
　さて、後は予定のない日の補完をすることで、空きスケジュールの完成です。

```php5:
// 予定のない日の日付、時刻を格納
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週間以内の空きスケジュールを取得できます。 
　![capture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491447/c136c628-0e4f-f623-45d4-36eff1ad8888.png)
　いちいち予定を何度も見返して空きスケジュールを入力する必要がなくなりましたね。
# まとめ
- PHPでGoogle Calendar APIを使って空き時間の取得を行いました。
- 現状、「全日」の予定がある場合に対応できていないので、今後対応したいと思います。
- 冗長な部分も多々あるかと思うので、そちらも改善していきたく思います。