1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

会社の事情でめんどくさくなったスケジュール管理を自動化したら周りも少し楽にできた

Last updated at Posted at 2024-02-06

概要

会社の事情で2つのカレンダーアプリを使わざるを得ない状況となり、Google CalendarとGaroonを連携するスクリプトを書いて、自分(+同じ状況の人たち)のちょっとした「めんどくさい」を解決した話です。

(2024年3月更新)
会社がGoogleカレンダーに統一を決定し、移行期間になんと自分のスクリプトの使用が推奨されました!
それに伴い本スクリプトをGoogleとGaroonの双方向でコピーできるようスクリプトを改修し、自部署以外でも使ってもらうことができ、自社の多くのメンバーをスケジュール転記作業から解放することにつながりました✨

背景

みなさん職場でスケジュール管理アプリは何を使っていますか?

自分の職場ではGaroonスケジュールGoogle Calendarの2つを使っています。

...そう、めんどくさいことに2つ使っています。
背景・理由は以下の通り。

  • 正社員はGaroon・Google共に利用可能
  • 会社都合で業務委託者にはGaroonアカウントを付与できない
  • 自分のチームメンバーは大部分が業務委託者

普通にあなたもGoogleカレンダー使えばいいじゃんと思うかもしれませんが、
部署によっては正社員が多く、会社的にはGaroonがメインで使われているので以下の状況でした。

  • 自チームとのMTGなどはGoogleカレンダーに登録される
  • 一方で他部署との連携もそれなりに多いためGaroonにもスケジュールを記録しておく必要がある

こういった状況だったのでGoogleカレンダーに登録された予定をGaroonにも転記するという何も生まれない地獄のような作業が発生してました。
また、2つのアプリの仕様差によるめんどくさい点もありました。具体的な例としては、隔週の予定をいれるとき、Googleカレンダーでは「2週間」で登録可能ですが、Garoonでは「第1月曜日と第3月曜日」として登録する必要があったり、MeetingURLもコピペしたりとまあ控えめに言っても面倒でした。

なので作りました。GoogleカレンダーからGaroonにコピーするスクリプト。

やったこと

要件定義

せっかくなのでエンジニアっぽく要件定義的に必要な機能を書き出しました。

image.png

技術選定はサーバー構築不要かつGoogleカレンダーだし相性よさそうということでGASを選択。(というかほぼ一択だった)
書き出してみるとコピーするだけでも結構必要な機能が多いな〜と思いました。

プライベートな時間を使っていたこともあり、あまり拘りすぎても他に勉強したいことができなくなっちゃうので、「Googleをメイン、Garoonは人に予定を見せるだけ」というようにそれぞれの役割を定義してそれに対する不要な機能の実装は捨てました。
そのおかげで要件定義〜実装までで実質2週間弱程度でできました。

スクリプトの作成

注1)会社の縛りや2つのアプリの仕様で結構むりやり頑張っているところあります。(備考欄とか)
注2)リファクタしていないので結構なクソコードになっております😇

作成したスクリプト(興味あればどうぞ!)
setting.gs
function initialize(){
  PropertiesService.getUserProperties().setProperties({
    "GaroonApiUrl": "[会社のgaroonURL]/api/v1/schedule/events",
    "GaroonUser": "[email]", // 自身のメールアドレス(GaroonID)
    "GaroonPassword": "[password]", // 自身のGaroonパスワード
    "SyncDaysBefore": 7,
    "SyncDaysAfter": 30,
    // "CalendarId": "[GoogleCalendarID]", // GoogleカレンダーID(カレンダー1つのみなら不要)
  })
}
syncGoogle2Garoon.gs
function main() {
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const syncDaysBefore = JSON.parse(userProperties["SyncDaysBefore"]);
  const syncDaysAfter = JSON.parse(userProperties["SyncDaysAfter"]);

  const today = new Date();
  const dateStart = new Date(today);
  const dateEnd = new Date(today);

  dateStart.setDate(today.getDate() - syncDaysBefore);
  dateStart.setHours(0, 0, 0, 0);
  dateEnd.setDate(today.getDate() + syncDaysAfter);
  dateEnd.setHours(23, 59, 59, 0);
  console.log([dateStart, dateEnd]);

  const garoonEvents = getGaroonEvents(dateStart, dateEnd);
  const garoonFormat = convertToGaroonFormat(dateStart, dateEnd, garoonEvents);
  postForGaroon(garoonFormat);
}

/**
 * 取得
 */
// garoonのスケジュールを取得(実行日7日前~実行日30日後)
function getGaroonEvents(start, end) {
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const garoonApiUrl = userProperties["GaroonApiUrl"];
  const garoonUser = userProperties["GaroonUser"];
  const garoonPass = userProperties["GaroonPassword"];

  const response = UrlFetchApp.fetch(garoonApiUrl + "?rangeStart="
    + start.toISOString() + "&rangeEnd=" + end.toISOString() + "&orderBy=start%20asc&limit=200", {
    method: "get",
    headers: {
      "X-Cybozu-Authorization": Utilities.base64Encode(garoonUser + ":" + garoonPass),
      "Content-Type": "application/json"
    }
  });
  return JSON.parse(response.getContentText()).events;
}


// 該当期間のGoogleCalenderのイベントを取得する関数
function getGcalEvents(start, end) {
  // メインカレンダー以外がある場合は以下でカレンダーを指定する
  // const userProperties = PropertiesService.getUserProperties().getProperties();
  // const calendarId = userProperties["CalendarId"];
  const events =  Calendar.Events.list("primary", {
    timeMin: start.toISOString(),
    timeMax: end.toISOString(),
  });
  return events;
}


// garoonEventが持っているgoogleCalendarIdを配列化
function extractGoogleCalendarIds(garoonEvents) {
  const googleEventIds = [];
  for (const garoonEvent of garoonEvents) {
    const match = garoonEvent.notes.match(/googleEventId: (\S+)/);
    if (match && match[1]) {
      googleEventIds.push(match[1]);
    }
  };
  return googleEventIds;
}

// iCalUIDでリピートイベントを取得
function getGcalRepeatEvents(start, end, iCalUids) {
  let iCalEvents = [];
  for (const iCalUid of iCalUids) {
    const optionalArgs = {
      iCalUID: iCalUid,
      showDeleted: false,
      singleEvents: true,
      orderBy: 'startTime',
      timeMin: start.toISOString(),
      timeMax: end.toISOString(),
    };
    const response = Calendar.Events.list('primary', optionalArgs).items;
    iCalEvents = iCalEvents.concat(response)
  }
  return iCalEvents
}


// googleCalendarのスケジュールを取得し、garoon形式にするメソッド
function convertToGaroonFormat(start, end, garoonEvents) {
  const events = getGcalEvents(start, end);
  const googleEventIds = extractGoogleCalendarIds(garoonEvents);
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const garoonUser = userProperties["GaroonUser"];

  const bodies = [];
  const checkedGoogleEventIds = [];
  const iCalUids = [];

  // googleCalendarのデータをGaroon形式に変換する機能
  if (events.items && events.items.length > 0) {
    for (const event of events.items) {
      // 繰り返し予定ならスキップし、iCalUIDを取り出す
      if (event.recurrence) {
        iCalUids.push(event.iCalUID)
        continue;
      }
      const isUpdated = isUpdatedEvent(event, garoonEvents)
      // キャンセル済みか、繰り返しイベントのインスタンスはスキップ
      if (event.status === 'cancelled' || event.recurringEventId) continue;
      // 既にgaroonに登録済みかつ更新不要のイベントはスキップ
      if (googleEventIds.includes(event.id)) {
        // pushされないイベントは削除される
        checkedGoogleEventIds.push(event.id);
        if (!isUpdated) {
          continue;
        }
      };

      const members = event.attendees ? event.attendees.map(attendee => {
        const namePart = attendee.email.split('@')[0];
          return namePart.replace(/\./g, ' ');
        }) : [];

      let notes = "meetURL: " + (event.hangoutLink || "");
      if (event.description) {
        notes += "\n" + event.description;
      }
      notes += "\n参加者: " + members.join(', ');

      // Schedule datastoreを使えば専用の項目を追加できてnotesから抽出が不要になるっぽい
      // https://cybozu.dev/ja/garoon/docs/rest-api/schedule/add-schedule-datastore/
      notes += "\ngoogleEventId: " + event.id;
      notes += "\nupdated: " + event.updated;

      // 終日判定(終日予定はdateTimeがnullのため)
      if (!event.start.dateTime) {
        event.start.dateTime = `${event.start.date}T00:00:00+09:00`;
        event.start.timeZone = 'Asia/Tokyo';
      }
      if (!event.end.dateTime) {
        // event.end.dateは+1日されているので暫定的にevent.start.dateを使用する
        event.end.dateTime = `${event.start.date}T23:59:59+09:00`;
        event.end.timeZone = 'Asia/Tokyo';
        event.isAllDay = true;
      }

      const body = {
        "eventType": "REGULAR",
        // サイボウズと同じメニューをGCalに作れば対応可能
        "eventMenu": null,
        "subject": event.summary,
        "notes": notes,
        "start": event.start,
        "end": event.end,
        "isAllday": event.isAllday,
        // 公開・非公開
        "visibilityType": event.visibility === 'private' ? event.visibility.toUpperCase() : 'PUBLIC',
        "attendees": [
          {
            "type": "USER",
            "code": garoonUser
          }
        ],
      }
      if (isUpdated) {
        const targetGaroonId = extractGaroonIdForUpdate(garoonEvents, event.id);
        updateGaroonEvent(targetGaroonId, body);
        continue;
      }
      bodies.push(body);
    }
  }
  // iCalUIDを渡す、結果をbodiesに追加する
  const repeatEvents = getGcalRepeatEvents(start, end, iCalUids);
  for (const repeatEvent of repeatEvents) {
    const isUpdated = isUpdatedEvent(repeatEvent, garoonEvents)
    // キャンセル済みか、既にgaroonに登録済みかつ更新不要のイベントはスキップ
    if (repeatEvent.status === 'cancelled') continue;
    if (googleEventIds.includes(repeatEvent.id)) {
      // pushされないイベントは削除される
      checkedGoogleEventIds.push(repeatEvent.id);
      if (!isUpdated) {
        continue;
      }
    };

    const members = repeatEvent.attendees ? repeatEvent.attendees.map(attendee => {
      const namePart = attendee.email.split('@')[0];
        return namePart.replace(/\./g, ' ');
      }) : [];

    let notes = "meetURL: " + (repeatEvent.hangoutLink || "");
    if (repeatEvent.description) {
      notes += "\n" + repeatEvent.description;
    }
    notes += "\n参加者: " + members.join(', ');

    // Schedule datastoreを使えば専用の項目を追加できてnotesから抽出が不要になるっぽい
    // https://cybozu.dev/ja/garoon/docs/rest-api/schedule/add-schedule-datastore/
    notes += "\ngoogleEventId: " + repeatEvent.id;
    notes += "\nupdated: " + repeatEvent.updated;

    const body = {
      "eventType": "REGULAR",
      // サイボウズと同じメニューをGCalに作れば対応可能
      "eventMenu": null,
      "subject": repeatEvent.summary,
      "notes": notes,
      "start": repeatEvent.start,
      "end": repeatEvent.end,
      "isAllday": repeatEvent.isAllday,
      // 公開・非公開
      "visibilityType": repeatEvent.visibility === 'private' ? repeatEvent.visibility.toUpperCase() : 'PUBLIC',
      "attendees": [
        {
          "type": "USER",
          "code": garoonUser
        }
      ],
    }
    if (isUpdated) {
      const targetGaroonId = extractGaroonIdForUpdate(garoonEvents, repeatEvent.id);
      updateGaroonEvent(targetGaroonId, body);
      continue;
    }
    bodies.push(body);
  }
  deleteGaroonEvents(googleEventIds, checkedGoogleEventIds, garoonEvents);
  return bodies
}


/**
 * 更新
 */
function updateGaroonEvent(targetGaroonId, body) {
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const garoonApiUrl = userProperties["GaroonApiUrl"];
  const garoonUser = userProperties["GaroonUser"];
  const garoonPass = userProperties["GaroonPassword"];

  const options = {
    "method": "patch",
    "headers": {
      "X-Cybozu-Authorization": Utilities.base64Encode(garoonUser + ":" + garoonPass),
      "Content-Type": "application/json"
    },
    "payload": JSON.stringify(body)
  }
  UrlFetchApp.fetch(garoonApiUrl + "/" + targetGaroonId, options)
}

// 更新対象のgaroonEventIdを抽出
function extractGaroonIdForUpdate(garoonEvents, eventId) {
  const targetGaroonEvent = garoonEvents.find(garoonEvent => {
    const match = garoonEvent.notes.match(/googleEventId: (\S+)/);
    return match && match[1] === eventId;
  });
  return targetGaroonEvent ? targetGaroonEvent.id : null;
}


// updateする必要があるeventかを判定
function isUpdatedEvent(event, garoonEvents) {
  // event.idと一致するgoogleEventIdを持つgaroonEventを取得
  const matchingGaroonEvent = garoonEvents.find(garoonEvent => garoonEvent.notes.includes(`googleEventId: ${event.id}`));

  if (matchingGaroonEvent) {
    // garoonEvent.notesからupdatedを取得
    const updatedMatch = matchingGaroonEvent.notes.match(/updated: (\S+)/);
    if (updatedMatch && updatedMatch[1]) {
      const garoonUpdated = new Date(updatedMatch[1]);
      const eventUpdated = new Date(event.updated);

      return eventUpdated > garoonUpdated;
    }
  }
  return false;
}

/**
 * 削除
 */
function deleteGaroonEvents(googleEventIds, checkedGoogleEventIds, garoonEvents) {
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const garoonApiUrl = userProperties["GaroonApiUrl"];
  const garoonUser = userProperties["GaroonUser"];
  const garoonPass = userProperties["GaroonPassword"];

  const targetGoogleIds = googleEventIds.filter(id => !checkedGoogleEventIds.includes(id));

  const targetIds = extractIds(garoonEvents, targetGoogleIds)

  for (const id of targetIds) {
    UrlFetchApp.fetch(garoonApiUrl + "/" + id, {
      method: "delete",
      headers: {
        "X-Cybozu-Authorization": Utilities.base64Encode(garoonUser + ":" + garoonPass),
        "Content-Type": "application/json"
      }
    })
  }
}

// 削除対象のgaroonEventIdを抽出
function extractIds(garoonEvents, targetGoogleIds) {
  return garoonEvents
    .filter(garoonEvent => {
      const match = garoonEvent.notes.match(/googleEventId: (\S+)/);
      return match && targetGoogleIds.includes(match[1]);
    })
    .map(garoonEvent => garoonEvent.id);
}


/**
 * 登録
 */
// garoonに登録するメソッド
function postForGaroon(bodies) {
  const userProperties = PropertiesService.getUserProperties().getProperties();
  const garoonApiUrl = userProperties["GaroonApiUrl"];
  const garoonUser = userProperties["GaroonUser"];
  const garoonPass = userProperties["GaroonPassword"];

  if (bodies.length > 0) {
    for (const body of bodies) {
      const options = {
        "method": "post",
        "headers": {
          "X-Cybozu-Authorization": Utilities.base64Encode(garoonUser + ":" + garoonPass),
          "Content-Type": "application/json"
        },
        "payload": JSON.stringify(body)
      }

      UrlFetchApp.fetch(garoonApiUrl, options)
    }
  }
}

※Googleカレンダーの終日予定の連携をうまくできていなくてGaroon側では0:00-23:59の予定となってしまう
※会議室、スケジュール共有化など諦めた機能は多いです

上記のスクリプトをコピペし、Calendarサービスを有効化すれば使えると思います。(動作保証なし)

設定手順

  1. setting.gsに自分の情報を入力する
  2. Service(Calendar API)の追加
  3. 必要に応じてテスト
    setting.gsを syncDayBefore = 3, syncDayAfter = 3 にして1週間分くらいで実行してみる
  4. トリガーの設定: main() を実行するトリガーを設置(例:毎日午前0~1時に実行)

※会社の設定によって定期的なGaroonのパスワード更新時期になるとエラーが起きます。Garoonでパスワード更新後、setting.gsを修正してください。

試行錯誤したこと

  • リピートイベントのstart, end問題
    リピートイベントの開始日時(start)・終了日時(end)の登録は特に苦労しました。
    Googleカレンダーでは、リピートイベントはrecurrence: [ 'RRULE:FREQ=WEEKLY;BYDAY=FR' ] というパラメータを使ってstart, endの正しい日付を算出している(RRULE)らしく、start/endは繰り返しの開始日の情報を持ってしまっていた。これがGASだとどうしようもなかった。外部ライブラリが限定されているのと、使えそうなmoment.jsもメンテ終了しているらしい。そもそもrecurrence自体だけだと情報が足りなくて算出は難しいのかも?
    ここは結構ハマったけど、ある日布団に入ったあと思いついた案で対応しました。(寝ることは大事)
    image.png

  • 全く同じiCalUIDを持つが、recurrenceを持たないイベントが存在してた
    これを対策できてなくて同じ予定が複数登録されちゃうバグになってました。幸い代わりとなるrecurrenceIdなるものを持ってたので条件に追加しました。
    親イベント(recurrence)とそれに紐づくインスタンス(recurrenceId )という関係らしいです。
    参考:https://developers.google.com/calendar/api/guides/recurringevents?hl=ja

やった感想

会社全体のルールを変えるよりは遥かに低いコスト(実質工数2週間弱)でできました。
個人的には地獄みたいなスケジュール転記作業から解放されたので、かけた時間に対しての効果は大きかったと思います。
また、少しずつですが同じ不便を感じていた同僚にも使ってもらえてるみたいで嬉しいです!

ちょっとした課題ではありますが、他部署でも使っていただけて会社の課題解決に貢献できたのはよかったです。

自動化することはかなり好きだということを再認識したので今後も自動化できることがあれば積極的にやっていきたいです。

参考

1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?