iCalendarで複数サービスのカレンダーを纏めて表示する!
クライアント様から「GoogleカレンダーとLINE WORKSのカレンダーを一纏めにして表示出来ないか」とご相談頂きました。アポイントメントを外注するにあたり、空いてる時間を外注先に把握頂くことと、外注先から登録したアポイントメントをクライアント様が把握する目的です。
どちらもiCalendar形式の公開URLが取得出来ますので、これを使いカレンダーを統合することにしました。
仕様の考察
iCalendarは.icsというファイルで提供されますので、kigkonsult iCalcreatorというライブラリを使いicsファイルから予定を取得し、FullcalendarというJavascriptカレンダーで表示できるようにjsonファイルへ書き込むことにします。
サンプル
https://studio-key.com/Sample/vcalendar
LINE WORKSからのicsファイルURL提供は有料プラン以上となっていますので、サンプルは三種類のGoogleカレンダーを取り込んでいます。うち1つは日本の祝日カレンダーです。
予定の種類に関して
Googleカレンダーには様々な指定で予定を登録出来ます。
- 単一日に対して時間指定及び終日
- 毎週◯曜日
- 毎週◯曜日と◯曜日
- 毎月第一◯曜日
- 毎月◯日
iCalendarの仕様としては、毎週第一月曜と第一木曜といった複数の曜日に対して第一や第二の指定が可能なようですが、Googleカレンダーにはそのように登録する方法が見つかりませんでした。それぞれ別に登録する必要が有るようです。
終日に関して
終日とは文字通り一日いっぱいのことですが、GoogleカレンダーやLINE WORKSから登録すると00:00~00:00となるようです。これがFullCalendar側で2日間と判定されてしまうようで、カレンダー上の帯が2日間に渡って伸びてしまいます。
これは問題となりますので、終日は指定日のみの予定とし、かつ営業開始時刻~終了時刻に差し替えることにします。
時刻に関連しますが、Googleカレンダー側のタイムゾーンをAsia/Tokyoにしてもicsファイル上はUTCになってしまうようです。このため、jsonファイルに書き込む際にAsia/Tokyoに変更する必要が有ります。
kigkonsult icalcreatorに関して
composerでインストールすると古いバージョンになるようで、PHP8.1以降でエラーになります。str_replaceの第2因数へNULLを指定しているのが原因なので修正は可能ですが、今後を考えると最新版をダウンロードして設置するのが良さそうです。
https://github.com/iCalcreator/iCalcreator
複数のicsファイルURLから予定を取得する
では作成したプログラムを紹介します。
<?php
require 'vendor/autoload.php';
use Kigkonsult\Icalcreator\Vcalendar;
$config = [
"allday_start" => "09:00:00", //終日の開始時間
"allday_end" => "18:00:00", //終日の終了時間
];
$url_array = [
["type"=>"holidays" , "url" => "https://calendar.google.com/calendar/ical/xxxxx/public/basic.ics"],
["type"=>"google" , "url" => "https://calendar.google.com/calendar/ical/xxxxx/public/basic.ics"],
["type"=>"google2" , "url" => "https://calendar.google.com/calendar/ical/xxxxx/public/basic.ics"],
];
foreach($url_array as $data){
//サービスによって色を変更する
switch($data["type"]){
case "line":
$color = "#1FC04F";
break;
case "google":
$color = "#DC6000";
break;
case "google2":
$color = "#8E7FB0";
break;
case "holidays":
$color = "#FF0000";
break;
}
$icsUrl = $data["url"];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $icsUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$icsContent = curl_exec($ch);
curl_close($ch);
if ($icsContent === false) {
//die('外部ICSファイルの取得に失敗しました');
continue;
}
$vcalendar = Vcalendar::factory();
$vcalendar->parse($icsContent);
$events = [];
foreach ($vcalendar->getComponents(Vcalendar::VEVENT) as $event) {
$summary = $event->getSummary();
$rrule = $event->getRrule();
/**
* BYSETPOS(毎月第一月曜日は[1MO]等) を設定
* iCalendar仕様としては「毎週第一月曜日と第一木曜日」を一度に処理できるようで
* BYDAY[0],BYDAY[1],BYDAY[2].....と増えていく
* しかしGoogle Calendarでは1つの予定へ「毎週第一月曜日と第一木曜日」は設定出来なかった
* このため、bysetposは決め打ちでセットしている
*/
if(isset($rrule["BYDAY"][0][0]) && $rrule["BYDAY"][0][0]){
$rrule["BYSETPOS"] = $rrule["BYDAY"][0][0];
}
$checkDate = checkDatetime($event->getDtstart(),$event->getDtend());
//終日設定の場合は日付が翌日へ跨ぐため、開始日時と終了日時を調整する
//RRULEの終日も有効
if($checkDate["diff"] == "24:00"){
$checkDate["start"] = $checkDate["start_ymd"] ." ".$config["allday_start"];
$checkDate["end"] = $checkDate["start_ymd"] ." ".$config["allday_end"];
}
//24hで割り切れるかどうか
list($hours, $minutes) = explode(':', $checkDate["diff"]);
//経過時間を分に変換
$totalMinutes = $hours * 60 + $minutes;
//経過時間を24h*60minの数値で割る
if ($totalMinutes % 1440 === 0) {
//割り切れた場合は終日設定なので開始日時と終了日時を調整する
$checkDate["start"] = $checkDate["start_ymd"] ." ".$config["allday_start"];
//終日設定の場合は最終日の翌日までが範囲となってしまうので、-1dayで調整
$checkDate["end_ymd"] = date( 'Y-m-d' , strtotime($checkDate["end_ymd"] ." -1 day") );
$checkDate["end"] = $checkDate["end_ymd"] ." ".$config["allday_end"];
}
//RRULE設定無し ----------------------
if($rrule === false){
//祭日の場合は背景色を変更
$bgcolor = "";
if($data["type"] == "holidays") {
$checkDate["end"] = $checkDate["start"];
$bgcolor = "#FFE8EA";
}
//イベント配列をセット
$events[] = [
'color' => $color,
'title' => $event->getSummary(),
'start' => $checkDate["start"],
'end' => $checkDate["end"],
'bgcolor' => $bgcolor,
];
}
//RRULE設定有り ----------------------
else{
//変数のキーと値を小文字にする
$rrule = array_change_key_case($rrule, CASE_LOWER);
$rrule = strtolower_recursive($rrule);
//rurleにbydayが有る場合はVcalendarに合わせて変数をセット
if(isset($rrule["byday"]) && $rrule["byday"]){
$array = [];
foreach($rrule["byday"] as $d){
$array[] = $d["DAY"];
}
$rrule["byweekday"] = $array;
}
//繰り返しの開始日をセット
$rrule["dtstart"] = $checkDate["start"];
//繰り返しの終了日が有ればカレンダーが解釈できる日付に変更
if(isset($rrule["until"]) && $rrule["until"]){
$rrule["until"] = $checkDate["end"];
}
//Vcalendarでrrule->bydayがエラーになるので排除
unset($rrule["byday"]);
/**
* 繰り返し間隔
* 終日で設定した場合は強制的に終了日をセットしてもCalendar上では開始時間しか表示されない
* 終了時刻も表示するためにはdurationの設定が必要
*/
if($checkDate["end"]){
$duration = day_diff($checkDate["start"], $checkDate["end"]);
}else{
$duration = 0;
}
//休日の背景色を変更する
$bgcolor = "";
if($data["type"] == "holidays") {
$checkDate["start"] = $checkDate["end"];
$bgcolor = "FFE8EA";
}
//イベント配列をセット
$events[] = [
'color' => $color,
'title' => $event->getSummary(),
'start' => $checkDate["start"],
'end' => $checkDate["end"],
'duration' => $duration,
'rrule' => $rrule,
'bgcolor' => $bgcolor,
];
}
}
}
/**
* jsonファイルへ書き込む
*/
$file = "json.json";
$data_all = file($file);
$fp = fopen($file,"w");
flock($fp,2);
fputs($fp,json_encode($events));
flock($fp,3);
fclose($fp);
/**
* 配列のキーと要素を小文字に変換
*/
function strtolower_recursive($array) {
foreach ($array as $key => $value) {
if (is_array($value)) {
$array[$key] = strtolower_recursive($value); // 配列の場合、再帰的に処理
} elseif (is_string($value)) {
$array[$key] = strtolower($value); // 文字列の場合、小文字に変換
}
// それ以外の場合は無視
}
return $array;
}
/**
* 日時の差分を計算
*/
function day_diff($date1, $date2) {
$timestamp1 = strtotime($date1);
$timestamp2 = strtotime($date2);
$seconddiff = abs($timestamp2 - $timestamp1);
$hours = floor($seconddiff / 3600);
$minutes = floor(($seconddiff % 3600) / 60);
$timeFormat = sprintf("%02d:%02d", $hours, $minutes);
return $timeFormat;
}
/**
* google calendarから得た開始日時と終了日時を通常の日付フォーマットに変換する
*/
function checkDatetime($start,$end)
{
$datetime = [];
//開始日時
$start->setTimezone(new DateTimeZone('Asia/Tokyo'));
$datetime["start"] = $start->format('Y-m-d H:i:s');
$datetime["start_ymd"] = $start->format('Y-m-d');
//終了日時が有る場合
if($end){
$end->setTimezone(new DateTimeZone('Asia/Tokyo'));
$datetime["end"] = $end->format('Y-m-d H:i:s');
$datetime["end_ymd"] = $end->format('Y-m-d');
//開始と終了の差
$datetime["diff"] = day_diff($datetime["start"],$datetime["end"]);
}
return $datetime;
}
注意点等を書き込んでいますので詳しい説明は省きます。クライアント様のご要望に対応するために無理やりなところも有ります。
FullCalendarで表示する
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>カレンダー表示</title>
<link rel="stylesheet" href="assets/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.9.0/main.min.js"></script>
<script src='https://cdn.jsdelivr.net/npm/rrule@2.6.4/dist/es5/rrule.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.14/index.global.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/rrule@6.1.14/index.global.min.js'></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
fetch('json.json')
.then(response => response.json())
.then(events => {
var eventsByDate = {};
events.forEach(function(event) {
var date = new Date(event.start).toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' });
if (!eventsByDate[date]) {
eventsByDate[date] = [];
}
eventsByDate[date].push(event);
});
var calendar = new FullCalendar.Calendar(calendarEl, {
events: events,
locale: 'ja',
height: 'auto',
displayEventEnd: true,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
footerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
buttonText: {
today: '今月',
month: '月',
list: 'リスト',
week: '週',
day: '日',
},
initialView: 'dayGridMonth',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit'
},
//土日の背景色を変更
dayCellDidMount: function(info) {
var dow = info.dow;
if (dow === 0) { // 日曜日
info.el.style.backgroundColor = '#FFE8EA';
} else if (dow === 6) { // 土曜日
info.el.style.backgroundColor = '#C9F7FF';
}
var date = info.date.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' });
var eventsForDate = eventsByDate[date] || [];
eventsForDate.forEach(function(event) {
if (event.bgcolor) {
info.el.style.backgroundColor = event.bgcolor;
}
});
}
});
calendar.render();
})
.catch(error => console.error('Error fetching events:', error));
});
</script>
</head>
<body>
<div id="calendar">
<div id="calendar"></div>
</div>
</body>
</html>
これはFullCalendarのサイトに詳しく書かれていますので特に問題は無いと思います。icsファイルを解析する際にFullCalendarが解釈できるよう整形してjsonファイルへ書き込んでいますので、読み込むだけです。あとは日本語にしたりカラーを変更したりなど調整します。
LINE WORKSに関して
iCalendar形式で書き出されますのでGoogleカレンダーとフォーマットは同じなのですが、単一予定へのプライバシー設定が可能なようで、LINE WORKS上で非表示にすることができるようです。ただ、これはあくまでLINE WORKS上での話しで、iCalendar側には載ってきてしまうケースが有りました。
内容はBusyと表示されますので漏洩にはなりませんが、一応気をつけたい点かと思います。
以上、iCalendarで複数サービスのカレンダーを纏めて表示する方法のご紹介でした!