📓

[GAS]Googleカレンダーから空き時間をテキスト形式で表示する

2022/03/02に公開

背景

昨今は予定の共有を、自分のカレンダーと同期させながら行うこともでき便利な世の中になったなーと思いつつ、
外部の人とやり取りする際は結局空き時間を文面で伝える必要が出てくるケースが多く、
めんどくさ!って思い色々調べたらまあまあ簡略化することができたので、その方法や過程を記す。

改訂履歴

  • 2022/3/2 初版公開
  • 2022/3/2 JavaScriptでの32bitを超える数値のbit演算時について加筆、修正

イメージ

このような予定があったら、

「9:00-10:00,11:00-12:00,13:00-14:00,16:30-17:30,18:30-20:00」
と自動で出力してくれるみたいな。

前提

使用

  • Googleカレンダーを使用している
  • GAS

選定理由

  • Googleカレンダーで予定を管理していたため
  • GASは、同じGoogleのシステムで連携が容易なのと、無料で使えるため(大事)

手順

  1. カレンダーの情報を取得する
  2. 取得した情報から、指定時間帯の空き時間を割り出す
  3. テキスト形式で表示する

2について

悩んだのは2について、日付の比較をどう解決するかでした。
そこで調べたところ以下の記事を発見し、なるほど!と思いめちゃくちゃ参考にしつつ実装に起こしました。
(ぶっちゃけこの方の方策をJavaScriptに書き直しただけ・・)

https://blowup-bbs.com/python-compute-free-schedule-by-bit/

ここでも簡潔に記すと、日時を2進数で表現し、その論理和を求める、です。

以下、実装の際にも用いている判例ですが、
1時間という区間を15分で区切り、0=予定なし、1=予定あり、と定義します。
抽出したい時間区間を9:00-20:00とした時、それを

0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000

と表します。(ここでは分かりやすさのため_(アンダースコア)を入れてますが実際は使わないです)

内、12:00-12:30が予定ありの場合

0000_0000_0000_1100_0000_0000_0000_0000_0000_0000_0000

と表現します。

また、同日に,14:15-15:45にも予定がある場合、それを

0000_0000_0000_0000_0000_0111_1110_0000_0000_0000_0000

と表現します。

2つの予定の論理和を求めると、

0000_0000_0000_1100_0000_0111_1110_0000_0000_0000_0000

となり、「9:00-12:00,12:30-14:15,15:45-20:00」が空き時間だということが求められます。

2022/3/2 追記

JavaScriptでは、通常のNumber型では32bitを超える演算が出来ないようで、10時台など、左寄りの桁についての演算が期待通りにならないバグがありました。

http://katwat.s1005.xrea.com/wp/5164

その対応として、BigInt型に変換して演算を行うことで期待通りに動きました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/BigInt

実装

const CALENDAR_ID = "<取得したいカレンダーのID>"

/**
 * 起点
 */
function myFunction() {
  // 今日から何日後までのスケジュールを取るか
  const count = 30;
  // カレンダー取得
  const events = fetchEvents(count);
  // 取得したカレンダーから空き時間を2進数形式で抽出
  const bitEmptyTimeOfDays = calculateEmptyTimeOfDays(events);
  // Date形式に戻す
  const emptyTimeOfDays = changeBitToDate(bitEmptyTimeOfDays);
  // 出力
  displayDays(emptyTimeOfDays);
  
   // 日毎に、空き時間が「10:00-11:30,14:15-15:45」のような形式で出力される
}

/**
 * カレンダーのイベントを取得
 * @param afterDates 何日後か
 */
function fetchEvents(afterDates) {
  const calendar = CalendarApp.getCalendarById(CALENDAR_ID);

  const startDate = new Date();
  const endDate = new Date();
  endDate.setDate(startDate.getDate() + afterDates);
  const events = calendar.getEvents(startDate, endDate);

  return events;
}

/**
 * 空き日程をbit形式で返却
 */
function calculateEmptyTimeOfDays(events) {
  const bitDays = [];

  events.forEach(e => {
    // 「終日」の予定は除外
    if(!e.isAllDayEvent()){
      const bitDay = changeDateToBit(e);
      const {key, value} = bitDay;

      if(bitDays[key] !== undefined){
        // 論理和を取る!
	// 追記:BigInt型にキャストして演算を行う
        bitDays[key] = "0b" + (BigInt(bitDays[key]) | BigInt(value)).toString(2).padStart(44, '0');
      }else{
        bitDays[key] = value;
      }
    }
  })

  return bitDays;
}

/**
 * bit形式に変換
 * @return {"key": key, "value": value}, key = 日付, value = bit
 * bit定義 → 15分区切りで表現、0 = 予定なし、1 = 予定あり
 * 例(_は実際には含まれない):11:00-12:15予定あり → 「0000_0000_1111_1000_0000_0000_0000_0000_0000_0000_0000」
 * 4 * 11 = 44bit
 */
function changeDateToBit(event){
  const start = event.getStartTime();
  const end = event.getEndTime();

  // 対象の時間
  clockIn = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 9, 0);
  clockOut = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 20, 0);

  let dateBit = "0b";
  let checkDuration = new Date(clockIn);

  for(let i = 0; i < 44; i++) {
    if(start <= checkDuration && checkDuration < end){
      dateBit += "1";
    }else {
      dateBit += "0";
    }

    if (checkDuration >= clockOut){
      break;
    }
    checkDuration.setMinutes(checkDuration.getMinutes() + 15);
  }

    const dateKey = getDateLabel(start);
    return {"key": dateKey, "value": dateBit};
}

/**
 * Bit形式の日付をDate型に戻す
 * @return {<日付> : {"start": <日時>, "end": <日時>}[]}[]
 */
function changeBitToDate(bitDays){
  const freeTimes = [];
  Object.keys(bitDays).forEach(key => {
    // 先頭の「0b」除去
    const dateBit = bitDays[key].replace(/^0b/,"");

    // 範囲の始点を設定
    const clockIn = new Date(key);
    clockIn.setHours(9);
    clockIn.setMinutes(0);

    let start;
    let end;
    const freeDurations = [];
    for(let i = 0; i < dateBit.length; i++){
      const bit = dateBit.charAt(i);
      if(!start){
        if(bit === '0'){
          start = new Date(clockIn);
          start.setMinutes(start.getMinutes() + (15 * i));
        }
      } else{
          if(bit === '1' || i === dateBit.length - 1){
            if(bit === '1'){
              end = new Date(clockIn);
              end.setMinutes(end.getMinutes() + (15 * i));
            }else{
              end = new Date(clockIn);
              end.setMinutes(end.getMinutes() + (15 * (i + 1)));
            }
	    freeDurations.push({"start":getTimeLabel(start),"end":getTimeLabel(end)})
            // リセット
            start = undefined;
            end = undefined;
          }
        }
    }
    freeTimes[key] = freeDurations;
  })
  return freeTimes;
}

/**
 * 日時をシートに表示
 * @param days {<日付> : {"start": <日時>, "end": <日時>}[]}[]
 */
function displayDays(days){
  Object.keys(days).forEach((key, i) => {
    let value = "";
    days[key].forEach(e => {
      value += `${e.start}-${e.end}` + ",";
    })
    // 出力
    console.log(value.slice(0, -1));
  })

}

/**
 * YYYY/MM/DDを返す
 */
function getDateLabel(date) {
  return `${date.getFullYear()}/${('0' + (date.getMonth() + 1)).slice(-2)}/${('0' + date.getDate()).slice(-2)}`
}

/**
 * hh:mmを返す
 */
function getTimeLabel(date) {
  return `${('0' + date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}`
}

/**
 * YYYY/MM/DD hh:mmを返す
 */
function getDateTimeLabel(date) {
  return `${date.getFullYear()}/${('0' + (date.getMonth() + 1)).slice(-2)}/${('0' + date.getDate()).slice(-2)} ${('0' + date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}`
}

機能TODO

  • 予定が全くない日は表示されない
    • 全日など表示されるようにしたい
  • 各予定への参加有無での判定は対応していない
    • できるかは不明

参考

後書き

例えば自分の場合、
今日から何日後まで欲しいかを指定して実行、その結果をスプレッドシートと連携して、
シートに書き込む、みたいに活用しています。(必要な時にそれをコピペするだけ)

面接、面談調整などにご活用いただけたら😇笑

Discussion