TimeTreeAPIを使って共有カレンダーをGoogleカレンダーへ同期する

夫婦のスケジュール共有に2年ほどTimeTreeというアプリを使っています

操作性が良くて気に入っているのですが、TimeTreeの共有カレンダーを外部サービス(例えばGoogleカレンダー)とシンプルには同期できないという不満がありました。

Googleカレンダーと同期することが出来たらアレクサに「今日の予定は?」と聞くだけで予定の確認ができるので便利になるのにと思っていて早2年。運営元に「有料アカウント化してもいいからAPI作って」と問い合わせまで送っていました。

想いが通じて(?)先日、予定取得APIが公開されたので、早速TimeTree共有カレンダー→Googleカレンダー→アレクサで予定確認を作ってみました。

Googleカレンダーへの同期ということで、API取得はGoogleAppsScript(GAS)で作成。TimeTree側に予定が追加されたかを定期的に取得するためにGASにトリガーで定期実行を仕込んでいます。

コードは最後に載せますが、作成にあたっていくつかポイントが

TimeTreeの予定は今日~最大7日分しか取得できない

この制限自体はAPIドキュメントに書いてあるので不便ですが問題ではないでしょう

TimeTreeAPIのタイムゾーンはUTC固定

タイムゾーンを変更できません。
このため日本時間AM9時が日付の切り替わり基準になります。なんとAM9時を過ぎると0時~8時59分までの予定が取得できなくなります。APIドキュメントにUTCと書いてあって嫌な予感したので境界値テストしたら案の定でした。
過去の予定は同期済みであることが期待できるので実用上は大丈夫かと思われます。

GoogleカレンダーAPIの終日予定 EndTimeが1日ズレる

こちらはGoogleカレンダーAPIの仕様のようです。
1月10日~1月13日までという予定をAPIで取得すると、API上は終了日がなぜか1月14日を返してきます。
逆に登録時に 1月10日~1月13日というデータを素直に登録しようとすると、カレンダー上は 1月10日~1月12日の予定として作成されます。
フォーラムでも不可解な動作として疑問を抱いている人が多かったですが、「終日予定の終了時間は翌日の0時0分」というのがGoogleさんの判断のようです。


TimeTree→Googleカレンダーへ同期するGoogleAppsScript

getCalenderId() で同期したい共有カレンダーのIDを調べて、getUpcomingEvents()をトリガーで定期実行する感じです。

Timetreeのパーソナルアクセストークンの取得とかは適当にググりましょう。

//アクセスできるカレンダー一覧の取得
function getCalenderId() {
  //ファイル→プロジェクトのプロパティ→スクリプトのプロパティにて下記プロパティを設定すること
  //プロパティ名:TIMETREE_ACCESS_TOKEN
  //値:Timetreeで発行したパーソナルアクセストークン
  var timetreeAccessToken = PropertiesService.getScriptProperties().getProperty('TIMETREE_ACCESS_TOKEN');
  var options = {
    'method': 'get',
    'contentType': 'application/json',
    'headers': {
      'Accept': 'application/vnd.timetree.v1+json',
      'Authorization': 'Bearer ' + timetreeAccessToken
    }
  };
  
  var timetreeResponse = UrlFetchApp.fetch('https://timetreeapis.com/calendars', options);
  var timetreeJsonData = JSON.parse(timetreeResponse.getContentText());
  for (var i= 0 ;i < timetreeJsonData.data.length ; i++){
    //カレンダー名:カレンダーID をログに表示(表示→ログから確認)
    Logger.log(timetreeJsonData.data[i].attributes.name + ' : ' + timetreeJsonData.data[i].id);
  }
}

function getUpcomingEvents() {
  //ファイル→プロジェクトのプロパティ→スクリプトのプロパティにて下記プロパティを設定すること
  //プロパティ名:TIMETREE_ACCESS_TOKEN
  //値:Timetreeで発行したパーソナルアクセストークン
  var timetreeAccessToken = PropertiesService.getScriptProperties().getProperty('TIMETREE_ACCESS_TOKEN');

  //ファイル→プロジェクトのプロパティ→スクリプトのプロパティにて下記プロパティを設定すること
  //プロパティ名:TIMETREE_CALENDER_ID
  //値:getCalenderId()で取得したGoogleカレンダーと同期したいカレンダーID
  var timetreeCalenderId = PropertiesService.getScriptProperties().getProperty('TIMETREE_CALENDER_ID');
  var options = {
    'method': 'get',
    'contentType': 'application/json',
    'headers': {
      'Accept': 'application/vnd.timetree.v1+json',
      'Authorization': 'Bearer ' + timetreeAccessToken
    }
  };

  //upcoming_events APIでは当日から7日間分のみ取得可能
  //過去および8日以降の予定は取得できない
  //日付の判定はUTCのためAM9時を過ぎると8:59以前の予定は取得できなくなる
  var getDays = 7;//1-7で指定
  
  //TimeTreeの予定を取得
  var timetreeResponse = UrlFetchApp.fetch('https://timetreeapis.com/calendars/' + timetreeCalenderId + '/upcoming_events?days=' + getDays, options);
  var timetreeJsonData = JSON.parse(timetreeResponse.getContentText());

  //ファイル→プロジェクトのプロパティ→スクリプトのプロパティにて下記プロパティを設定すること
  //プロパティ名:GOOGLE_CALENDER_ID
  //値:同期先のGoogleカレンダーID
  var googleCalendarId = PropertiesService.getScriptProperties().getProperty('GOOGLE_CALENDER_ID');
  
  //Googleカレンダーの予定を取得
  var googleCalendar = CalendarApp.getCalendarById(googleCalendarId);

  var startTime = new Date(Utilities.formatDate(new Date,"JST", "yyyy/MM/dd"));
  var endTime = new Date(Date.parse(startTime) + (getDays * 60 * 60 * 24 * 1000));
  var googleCalendarEvents = googleCalendar.getEvents(startTime, endTime);
  var googleCalendarEventsObjects = {};
  //googleカレンダーの予定を確認
  for (var i in googleCalendarEvents) {
    //予定の説明にtimetreeのeventIdを入れているので、eventIdをkeyにした配列を作る
    googleCalendarEventsObjects[googleCalendarEvents[i].getDescription()] = googleCalendarEvents[i];
  }

  //Timetreeの予定を確認
  for (var i= 0 ;i < timetreeJsonData.data.length ; i++){
    var timetreeEvent = timetreeJsonData.data[i];

    if(googleCalendarEventsObjects[timetreeEvent.id]){
      //googleカレンダーに予定が存在する
      var googleCalendarEvent = googleCalendarEventsObjects[timetreeEvent.id];

      //終日予定かチェック
      if(googleCalendarEvent.isAllDayEvent() == true && timetreeEvent.attributes.all_day == true){
        //どちらも終日予定
        var googleCalendarStartDate = Utilities.formatDate(googleCalendarEvent.getStartTime(),"JST", "yyyy/MM/dd");
        var googleCalendarEndTime = new Date(Utilities.formatDate(googleCalendarEvent.getEndTime(),"JST", "yyyy/MM/dd"));
        var googleCalendarEndDate = Utilities.formatDate(new Date(googleCalendarEndTime.getYear(),googleCalendarEndTime.getMonth(),googleCalendarEndTime.getDate()-1),"JST", "yyyy/MM/dd");//googleカレンダーの終日予定の最終日は1日多く返ってくるので減算
        var timetreeStartDate = Utilities.formatDate(new Date(timetreeEvent.attributes.start_at),"JST", "yyyy/MM/dd");
        var timetreeEndDate = Utilities.formatDate(new Date(timetreeEvent.attributes.end_at),"JST", "yyyy/MM/dd");
        //タイトル、開始、終了日が一致するかチェック
        if(
          googleCalendarEvent.getTitle() == timetreeEvent.attributes.title
          && googleCalendarStartDate == timetreeStartDate
          && googleCalendarEndDate == timetreeEndDate
        )
        {
          //予定変更なし。そのまま
        }else{
          //予定が変更されている。Googleカレンダー側を削除して追加しなおす
          googleCalendarEvent.deleteEvent();
          addEvents(googleCalendar,timetreeEvent);
        }
      }else if(googleCalendarEvent.isAllDayEvent() == false && timetreeEvent.attributes.all_day == false){
        //どちらも終日予定ではない
        //タイトル、開始、終了日時が一致するかチェック
        if(
          googleCalendarEvent.getTitle() == timetreeEvent.attributes.title
          && Date.parse(googleCalendarEvent.getStartTime()) ==  Date.parse(new Date(timetreeEvent.attributes.start_at))
          && Date.parse(googleCalendarEvent.getEndTime()) ==  Date.parse(new Date(timetreeEvent.attributes.end_at))
        )
        {
          //予定変更なし。そのまま
        }else{
          //予定が変更されている。Googleカレンダー側を削除して追加しなおす
          googleCalendarEvent.deleteEvent();
          addEvents(googleCalendar,timetreeEvent);
        }
      }else{
        //予定が変更されている。Googleカレンダー側を削除して追加しなおす
        googleCalendarEvent.deleteEvent();
        addEvents(googleCalendar,timetreeEvent);
      }
      //予定の確認が出来たので配列から削除
      delete googleCalendarEventsObjects[timetreeEvent.id];
    }else{
      //evet_idに一致する予定がgoogleカレンダーに存在しないので追加       
      addEvents(googleCalendar,timetreeEvent);
    }
  }
  
  //Timetree側で削除された予定をGoogleカレンダーからも削除
  for(var key in googleCalendarEventsObjects) {
    googleCalendarEventsObjects[key].deleteEvent();
  }  
}

function addEvents(googleCalendar,timetreeEvent) {
  var title = timetreeEvent.attributes.title;
  var options = {
    //予定の説明欄にtimetree側のeventIdを入れておく。修正や削除判定に利用
    description: timetreeEvent.id
  }
  
  var nowDate = new Date();
  
  if(timetreeEvent.attributes.all_day){
    //終日予定
    var startDate = new Date(timetreeEvent.attributes.start_at.slice(0,10));
    var endDate = new Date(timetreeEvent.attributes.end_at.slice(0,10));
    
    
    //終日予定が複数日にまたがるかチェック
    if(timetreeEvent.attributes.start_at == timetreeEvent.attributes.end_at){
      //1日のみの終日予定

      //終了が過去のものは追加せずにスキップ
      var tmpDate = new Date();
      tmpDate.setDate(endDate.getDate() + 1);
      if(nowDate > tmpDate){
        return;
      }
      googleCalendar.createAllDayEvent(title, startDate,options);
    }else{
      //2日以上の終日予定
      endDate.setDate(endDate.getDate() + 1);//createAllDayEventの不具合?終了日が1日少なくなるので加算
      //終了が過去のものは追加せずにスキップ
      if(nowDate > endDate){
        return;
      }
      googleCalendar.createAllDayEvent(title, startDate, endDate,options);
    }
  }else{
    //時間指定の予定
    var startTime = new Date(timetreeEvent.attributes.start_at);
    var endTime = new Date(timetreeEvent.attributes.end_at);

    //終了が過去のものは追加せずにスキップ
    if(nowDate > endTime){
      return;
    }
    
    //予定を追加
    googleCalendar.createEvent(title, startTime, endTime,options);      
  }
}

これにて「アレクサ今日の予定は?」が実現しました。めでたしめでたし。