ガルーンからGoogleカレンダーに予定を移行するプログラムをRubyで作成してみた

#1.背景
ガルーンからGoogleカレンダーへの移行を進める上での大きな課題のひとつが、
「ガルーンの既存の予定の移行」です。
「カレンダーの切り替え日を決め、予定の日時によりガルーンとGoogleカレンダーを使い分ける」という移行方法が一般的だとは思いますが、ガルーンとGoogleカレンダーの両方を使い分ける必要があるため、カットオーバー前後の利用者側の負担が大きくなります。
今回は利用者側の負担を極力小さくするため、
「ガルーンの設備予約予定をすべて、Googleカレンダーにインポートする」というプログラムをRubyで作成してみました。

### 【前置き】
- 今回作成したプログラムは100%の精度で移行できるものではなく、ガルーンの運用や予定の登録方法によってはイレギュラーケースも発生し得ることをご了承ください。
- コーディングが本業ではないので、コードの書き方に稚拙な箇所が多々あるかもしれませんが、そこは見逃して頂けると助かります。
- ガルーンはパッケージ版ではなくcybozu.com版を使用しています。パッケージ版ではコードが若干変わるかもしれません。
- ガルーンとGoogleカレンダーのそれぞれの仕様の理解が必要となります。あらかじめ各ツールの特性を把握しておいてください。
- 解説の都合上、コード部分は断片的に抜粋して記述しています。


#2.ツールの検討
はじめに、「プログラムを自作しよう」という結論に至るまでの、既存の移行ツールの検討結果を記載しておきます。

##2.1 サードパーティ制の移行ツールを利用する
具体的な社名やツールは伏せますが、ガルーンからGoogleカレンダーへの移行ツールを開発・販売している会社もいくつかあります。
一通りの製品を調べてみましたが、「非公開予定は移行不可」「設備予約は移行不可」といった制限事項が設けられている物ばかりでした。
ガルーンとGoogleカレンダーのデータ形式・設計思想が根本的に異なるため、確実な精度での移行を保証することはやはり難しく、致し方ないかと思われます。

##2.2 iCal形式で予定をインポート/エクスポートする
ガルーンでは、利用者自身がiCal形式で予定のエクスポートが可能です。
GoogleカレンダーではiCalファイルのインポートが可能なため、こちらを利用して予定のエクスポート→インポートをする方法。
この方法でインポートできる予定は、あくまで「自身のカレンダーの予定のみ」であり、参加者や設備予約は移行されません。
また、
・非公開予定が公開予定としてインポートされてしまう
・ガルーンに登録されている予定の文字列によっては、iCalのファイル形式が崩れてインポートできない
といったトラップもあり、現実的な方法ではありませんでした。

##2.3 CSV出力した予定をGASで登録する
ガルーンの管理者権限があれば、対象の設備の予定の一覧をCSVエクスポートすることが可能です。
出力したCSVをスプレッドシートに変更し、GAS（Google Apps Script）を使ってGoogleカレンダーに登録する方法。
GASであればプログラミングに比べて敷居も低く、開発も容易ではありますが、様々な制限に引っかかり、最終的には断念しました。

まず、ガルーンの管理者画面から出力できるCSVの情報が少なすぎます。
特に予定を識別するユニークな「ID」が無く、各参加者・設備ごとに1レコードずつデータが出力されるため、予定のグルーピングが非常に困難で、元データとするには使い物になりません。
また、GASのCalendarAPIについても、「ゲストの予定変更権限」や「公開・非公開」が設定できないなど、扱えるプロパティが不十分でした。
簡易的な予定の移行であればこの方法でも問題ありませんが、今回の移行要件を満たすには厳しかったです。

（参考）[GAS CalendarAPIリファレンス](https://developers.google.com/apps-script/reference/calendar/)

上記のような検討を踏まえ、**「もうこれはプログラムを自作するしかない…！」**という結論に至りました。
結果的に、個別ケースの移行にも柔軟に対応できたので、良かったかもしれません。

#3.環境の準備
前置きが非常に長くなってしまいましたが、ここからがやっと本題です。
以下の2つのプログラムを作成します。

1. ガルーンAPIから、予定を取得してCSV出力するプログラム
2. 出力されたCSVをインポートし、Googleカレンダーに予定を登録するプログラム

##3.1 言語
元々Rubyを少し触っていたこともあり、APIを扱うライブラリも豊富なため、言語はRubyを採択しました。
WindowsでもMacでも簡単に環境構築できますし、Web上のドキュメントも豊富なので、Ruby環境の構築方法は割愛します。

##3.2 エディタ
これはもう完全に好みで選んでしまっていいと思います。
私はAtomを使用していました。
こちらもWeb上に情報が沢山ありますので、インストール方法は割愛します。

##3.3 ガルーン・Googleカレンダーの管理者権限
これが無いと話になりません。
システム管理者にお願いして、それぞれの管理者権限をもらっておきましょう。

##3.4 ガルーンの非公開予定に、移行用ユーザーを登録しておいてもらう
ガルーンでは、**システム管理者権限を持っていても、非公開予定の詳細を参加者以外が閲覧することはできません。**
サポートにも確認しましたが、これは仕様上どうしようもないとのこと。
とはいえ面接などの重要な予定はほぼ非公開で登録されているため、非公開予定を移行できないと意味がありません。
そこで、ガルーンに「移行用ユーザー」といったユーザーを作成しておき、移行が必要な予定の「参加者」に「移行用ユーザー」を追加しておいてもらいました。
こればかりは各利用者の協力が必要ですので、早めにアナウンスして作業しておいてもらいましょう。

##3.5 ガルーンとGoogleカレンダーの設備対応表を用意する
ガルーンの「設備」とGoogleカレンダーの「リソース」の関連付け用テーブルが必要となります。
以下のようなCSVを用意しておきましょう。

```FacilityList.csv
| facility_name | id | resource_id |
| 会議室A | 1 | yourdomain.com_12345678901@resource.calendar.google.com |
| 会議室B | 2 | yourdomain.com_12345678902@resource.calendar.google.com |
| 会議室C | 3 | yourdomain.com_12345678903@resource.calendar.google.com |
```

"id"にはガルーンの設備IDを、"resource_id"には対応するGoogleカレンダーのリソースIDをセットします。
なお、ガルーンの設備IDは設備一覧をエクスポートしても出力はされず、設備へのリンクURLから確認する必要があるのでご注意ください。

##3.6 ガルーンのユーザーリストを用意する
ガルーンのAPIから取得した予定の参加者は、ユーザーIDではなく氏名で出力されます。
もうこれも仕様上どうしようもない為、氏名とメールアドレスを紐付ける為の対応表を用意しておきましょう。
※同姓同名のユーザーが存在する場合は、ガルーン側で重複しないように氏名を変更しておく必要があります

```GaroonUsers.csv
| DisplayName | Email |
| 山田太郎 | taro-yamada@yourdomain.com |
| 鈴木一郎 | ichiro-suzuki@yourdomain.com |
```

なお、氏名や予定には環境依存文字が使用されている可能性が高いため、
このCSVに限らず、**一連の作業で使用するCSVはすべてUTF-8で作成してください。**
Windows上でExcelを使ってCSVファイルを編集する際は要注意です。
ちなみに、CSVファイルをBOM付UTF-8で保存すると、UTF-8のままExcelで開くことが可能です。

##3.7 Googleカレンダーの全設備予定をブロックしておく
Googleカレンダーでは、予定を登録する際に対象の設備が既に埋まっていても、予定を登録できてしまいます。（予定登録後、設備のみが「自動辞退」となる）
カットオーバー日以降の全設備予定は、先に予定を登録してブロックしておきましょう。


#4.ガルーンの予定をAPIで取得するプログラム
まずはガルーンの予定一覧を出力するプログラムを作成します。
ガルーンでは各種APIがSOAPで提供されています。
まずはこちらのリファレンスに一通り目を通しておきましょう。

[Garoon SOAP APIの共通仕様](https://developer.cybozu.io/hc/ja/articles/202228464)

今回は設備単位で予定を取得していくので、"ScheduleGetEventsByTarget"を使用します。
https://developer.cybozu.io/hc/ja/articles/202463180

以下の流れで、対象の設備の予定をAPIから取得します。
1.Http.Requestを"ScheduleGetEventsByTarget"に投げる
2.Http.Responseをxmlに保存
3.Responseを解析し、Googleカレンダー用にデータを整形しつつ、1予定1レコードとしてCSVに追記していく
4.1〜3を、全設備分繰り返す

以下、セクションごとに解説していきます。

##4.1 ガルーンの予定をAPIから取得し、Responseをxmlファイルに保存する
Http.Requestを作成してガルーンのAPIにPOSTし、Responseを取得します。
コード中のURL・ID・パスワードは環境に合わせて適宜書き換えてください。
なお、ガルーンの認証ユーザーには、非公開予定参照用に事前準備した「移行用ユーザー」を使用してください。
「移行用ユーザー」にガルーンの全設備の参照権限を付与しておくこともお忘れなく。

```GetEventByFacility.rb
require 'net/http'
require 'uri'
require 'rexml/document'
require 'OpenSSL'
require 'csv'
require 'date'

FACILITY_FILE_NAME = 'FacilityList.csv'
USER_FILE_NAME = 'GaroonUsers.csv'
OUTPUT_FILE_NAME = 'GetEventByFacility.csv'

# 予定を取得する対象期間を指定（UTC+0000）
TARGET_DATE_FROM = '2018-06-08T15:00:00'
TARGET_DATE_TO = '2019-12-31T14:59:59'

# ガルーンのユーザー一覧、設備一覧CSVを読み込んでテーブルにしておく
# ※文字化け回避のため、SJISから変換せずにUTF-8のまま扱う
# ※CSVのヘッダは読み込み時にすべて小文字に変換されるので注意
USER_TABLE = CSV.table(USER_FILE_NAME, encoding: 'UTF-8')
FACILITY_TABLE = CSV.table(FACILITY_FILE_NAME, encoding: 'UTF-8')

# アウトプットのヘッダを書き込み
csv = CSV.open(OUTPUT_FILE_NAME,'w')
csv << ['取得対象会議室','イベントID','イベントタイプ','公開タイプ','予定区分','予定件名','予定詳細','タイムゾーン（開始）','タイムゾーン（終了）','終日予定フラグ','開始時刻のみフラグ','開始時刻','終了時刻','参加者','参加者（メールアドレス）','公開先','公開先（メールアドレス）','設備','設備（メールアドレス）','繰り返し条件','繰り返し除外日時']
csv.close


def get_event_by_facility(target_facility_name,target_facility_id,start_time,end_time)
  uri = URI.parse('https://yourdomain.cybozu.com/g/cbpapi/schedule/api.csp?')
  response = nil

  request = Net::HTTP::Post.new(uri.request_uri)
  request.body = <<EOS
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://www.w3.org/2003/05/soap-envelope">
<soapenv:Header>
<Action>
  ScheduleGetEventsByTarget
</Action>
<Security>
  <UsernameToken>
  <Username>ikou-user</Username>
  <Password>password</Password>
  </UsernameToken>
</Security>
<Timestamp>
  <Created>2010-08-12T14:45:00Z</Created>
  <Expires>2037-08-12T14:45:00Z</Expires>
</Timestamp>
<Locale>jp</Locale>
</soapenv:Header>
<soapenv:Body>
  <ScheduleGetEventsByTarget>
    <parameters start="#{start_time}" end="#{end_time}">
      <facility id="#{target_facility_id}"></facility>
    </parameters>
  </ScheduleGetEventsByTarget>
</soapenv:Body>
</soapenv:Envelope>
EOS

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE

  http.start do |h|
    response = h.request(request)
  end

  File.open('GetEventByFacility.xml', 'w') do |f|
    f.puts(response.body)
  end

```

ここまでのコードが実行されると、「GetEventByFacility.xml」という名前のファイルが生成されます。
生成されたxmlファイルを開くと、対象設備の予定がずらっと記載されているはずです。
エラーが記載されている場合は、APIを正しく実行できていないので、パラメータ等を見直してください。

##4.2 Responseのxmlファイルを解析し、整形してCSVに出力する
生成されたxmlファイルを実際に開いてみましょう。

```GetEventByFacility.xml
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:schedule="http://wsdl.cybozu.co.jp/schedule/2008">
 <soap:Header><vendor>Cybozu</vendor><product>Garoon</product><product_type>2</product_type><version>4.9.0</version><apiversion>1.12.0</apiversion></soap:Header>
 <soap:Body><schedule:ScheduleGetEventsByTargetResponse>
<returns>

<schedule_event id="123456789" event_type="normal" public_type="private" detail="テスト予定" description="テスト予定です" version="123456789" timezone="Asia/Tokyo" end_timezone="Asia/Tokyo" allday="false" start_only="false">
 <members xmlns="http://schemas.cybozu.co.jp/schedule/2008">
 <member>
   <user id="1" name="山田太郎" order="0"/>
 </member>
 <member>
   <user id="2" name="鈴木一郎" order="1"/>
 </member>
 <member>
   <user id="999" name="移行用ユーザー" order="3"/>
 </member>
 <member>
   <facility id="1" name="会議室A" order="4"/>
 </member>
 </members>
 
 <when xmlns="http://schemas.cybozu.co.jp/schedule/2008">
   <datetime start="2018-06-11T02:30:00Z" end="2018-06-11T03:00:00Z"/>
 </when>
</schedule_event>

```

\<schedule_event/>から\</schedule_event> までが1つの予定です。
その間に、予定の詳細や参加者などの情報がすべて記載されています。
XMLの内容を解析し、整形しながらCSVに出力していきます。
Rubyに標準で用意されているREXMLが便利なので、そちらを使って解析していきます。

```GetEventByFacility.rb

# responseをXML解析してCSV出力
  doc = REXML::Document.new(response.body)
  csv = CSV.open(OUTPUT_FILE_NAME,'a')
  doc.elements.each('soap:Envelope/soap:Body/schedule:ScheduleGetEventsByTargetResponse/returns/schedule_event'){ |event|
      line = []
      line << target_facility_name.chomp.encode('UTF-8')
      line << event.attributes['id']
      line << event.attributes['event_type']
      line << event.attributes['public_type']
      line << event.attributes['plan']
      line << event.attributes['detail']
      line << event.attributes['description']
      line << event.attributes['timezone']
      line << event.attributes['end_timezone']
      line << event.attributes['allday']
      line << event.attributes['start_only']

      # 時刻
      event.elements.each('when/datetime'){ |time|
          line << time.attributes['start']
          line << time.attributes['end']
      }

      # 参加者
      member_list = ''
      member_email_list = ''

      event.elements.each('members/member/user'){ |member|
          display_name = member.attributes['name']
          member_list += display_name + "\n"

          # ガルーンユーザーCSVから参加者のメールアドレスを取得
          user = USER_TABLE.find{|user_row| user_row[:displayname] == display_name}
          if user.nil?
            user_email = '不明ユーザー'
          else
            if user[:email].nil?
              user_email = 'メールアドレスなし'
            else
              user_email = user[:email]
            end
          end

          member_email_list += user_email + "\n"
      }
      line << member_list.chomp
      line << member_email_list.chomp

      # 公開先
      observer_list = ''
      observer_email_list = ''
      event.elements.each('observers/observer/user'){ |obs|
          display_name = obs.attributes['name']
          observer_list += display_name + "\n"

          # ガルーンユーザーCSVから公開先のメールアドレスを取得
          user = USER_TABLE.find{|user| user[:displayname] == display_name}
          if user.nil?
            user_email = '不明ユーザー'
          else
            if user[:email].nil?
              user_email = 'メールアドレスなし'
            else
              user_email = user[:email]
            end
          end

          observer_email_list += user_email + "\n"
      }
      line << observer_list.chomp
      line << observer_email_list.chomp

      # 設備
      facility_list = ''
      facility_email_list = ''
      event.elements.each('members/member/facility'){ |facility|
          facility_name = facility.attributes['name']
          facility_list += facility_name + "\n"

          # ガルーン設備CSVから設備カレンダーのメールアドレスを取得
          facility = FACILITY_TABLE.find{|facility_row| facility_row[:facility_name] == facility_name}

          if facility.nil?
            facility_email = '不明な設備'
          else
            if facility[:resource_id].nil?
              facility_email = '不明な設備（メールアドレスなし）'
            else
              facility_email = facility[:resource_id]
            end
          end

          facility_email_list += facility_email + "\n"
      }
      line << facility_list.chomp
      line << facility_email_list.chomp

```

各エレメントの説明は省略しますが、実際のXMLファイルの中身を見れば、どの項目が何にあたるのかは推測できるかと思います。
参加者や設備は名称で取得されるので、事前に用意していたCSVを参照してメールアドレスに変換しておきます。

予定のタイプが「繰り返し予定」となっている場合は、"repeat_info"が出力されます。
ガルーンの繰り返し予定は、独自の値で繰り返し条件を保持しています。
一方、Googleカレンダーでは「RRULE」というiCal形式では一般的な記法で繰り返し条件を設定します。
RRULE記法への変換はインポート側のプログラムで対応するので、ひとまず出力されている値をそのまま保持しておきます。

```GetEventByFacility.rb

      # 繰り返し条件
      repeat_condition = ''
      event.elements.each('repeat_info/condition'){ |condition|
        repeat_condition = 'type:' + condition.attributes['type'] + "\n" \
                + 'day:' + condition.attributes['day'] + "\n" \
                + 'week:' + condition.attributes['week'] + "\n" \
                + 'start_date:' + condition.attributes['start_date'] + "\n" \
                + 'end_date:' + condition.attributes['end_date'] + "\n" \
                + 'start_time:' + condition.attributes['start_time'] + "\n" \
                + 'end_time:' + condition.attributes['end_time']
      }
      line << repeat_condition

      # 繰り返し除外日時
      repeat_list = ''
      event.elements.each('repeat_info/exclusive_datetimes/exclusive_datetime'){ |repeat_info|
          # 除外日時は開始日時のみ取得すればOK
          repeat_list += DateTime.parse(repeat_info.attributes['start']).strftime('%Y%m%d') + "\n"
      }
      line << repeat_list.chomp

      csv << line
  }

  csv.close
end

```

##4.3 全設備の予定取得を繰り返す
ここまで作成できれば、後は全設備に対して同一の処理を繰り返すだけです。
"FacilityList.csv"に記載された全レコード分、処理を回します。

```GetEventByFacility.rb

FACILITY_TABLE.each do |row|
  get_event_by_facility(row[:facility_name],row[:id],TARGET_DATE_FROM,TARGET_DATE_TO)
end
```

完成した"GetEventByFacility.rb"を実行すると、全設備の予定が記載された"GetEventByFacility.csv"というファイルが作成されるはずです。


##4.4 重複した予定を削除する
出力された"GetEventByFacility.csv"を開き、中身を確認してみましょう。

```GetEventByFacility.csv
| 取得対象会議室 | イベントID | イベントタイプ | 公開タイプ | 予定区分 | 予定件名 | 予定詳細 | タイムゾーン（開始） | タイムゾーン（終了） | 終日予定フラグ | 開始時刻のみフラグ | 開始時刻 | 終了時刻 | 参加者 | 参加者（メールアドレス） | 公開先 | 公開先（メールアドレス） | 設備 | 設備（メールアドレス） | 繰り返し条件 | 繰り返し除外日時 |
| 会議室A | 123456 | normal | public |  | テスト予定 | テスト予定です | Asia/Tokyo |  | FALSE | FALSE | 2018-06-11T00:45:00Z | 2018-06-11T02:00:00Z | 山田太郎 | taro-yamada@yourdomain.com |  |  | 会議室A | yourdomain.com_12345678901@resource.calendar.google.com | | |
```

1予定1レコードで出力されており、イベントIDもユニークになっているはずですが、ここで厄介なのが、「複数の設備を使用する予定」と「繰り返し予定」です。
今回、設備単位で予定の取得を行っているので、「会議室A、会議室B、会議室Cの3部屋を使う」といった予定の場合、同じイベントIDのレコードが3つ作成されてしまいます。
また、繰り返し予定の場合も同様に、「開始時刻」「終了時刻」のみが異なる、同一イベントIDのレコードが出力されてしまいます。
※繰り返し予定中の予定を個別に変更した場合は、異なるイベントIDが付与された単独予定として出力されます

このままインポートしてしまうと予定が重複してしまうので、事前に上記のレコードを重複排除しておきます。
面倒だったので私はExcelで重複排除をしました。
「開始時刻」「終了時刻」を除くすべてのカラムを条件に、重複排除しましょう。
データ量によってはExcelで開けないサイズになる場合もありますので、その場合はRubyでの重複排除を実装した方がいいかもしれません。

また、取得できなかった非公開予定はイベントIDが0となりますので、これらのレコードも削除しておきます。
これでインポート元となるCSVの準備が完了です。
"ImportEvent.csv"という名前で保存しておきます。

#5.Googleカレンダーに予定をインポートするプログラム
続いて、ガルーンからエクスポートした予定CSVをGoogleカレンダーにインポートするプログラムを作成します。
こちらでは「Google Calendar API」を使用します。
[Google Calendar API Reference](https://developers.google.com/calendar/v3/reference/)

##5.1 API実行ユーザーの準備
Calendar APIを実行するユーザーの事前準備が必要となります。
特権ユーザーを持ったG Suiteアカウントを用意し、Developer ConsoleでCalendar APIを有効にして、認証用のJSONファイルをダウンロードしておきましょう。
また、Calendar APIの「ユーザー毎の一日のリクエスト数の上限」の値がデフォルト値だと小さめになっているので、不安がある場合は上限数を引き上げておくことを推奨します。

##5.2 Google認証
Google APIを実行する為には、Googleの認証を通す必要があります。
Googleが提供している「googleauth」と「google-api-ruby-client」のgemを使用するので、gemをインストールしておきましょう。

```
$ gem install googleauth
$ gem install google-api-ruby-client
```

Google認証部分のコードは、以下のエントリを参考にさせて頂きました。
[Googleカレンダーからrubyで今年の祝日を取得する](https://qiita.com/yasushiito/items/2db0902ecce3b4a1f88e)

```ImportGoogleCalendar.rb
require 'google/apis/calendar_v3'
require 'googleauth'
require 'googleauth/stores/file_token_store'
require 'csv'
require 'date'
require 'time'

  OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'
  dir_path = File.dirname(__FILE__)

  # Google Calendar APIを実行する管理者権限Googleアカウント
  user_id = 'admin@yourdomain.com'
  # アプリケーション名
  app_name = 'ImportGoogleCalendar'

  # トークンを保存するファイル
  store_file = dir_path + 'google_api.yaml'
  # Google API Console からダウンロードしたclient idファイル
  client_id = Google::Auth::ClientId.from_file("#{dir_path}/client_id.json")
  token_store = Google::Auth::Stores::FileTokenStore.new(file: store_file)
  scope = 'https://www.googleapis.com/auth/calendar'
  authorizer = Google::Auth::UserAuthorizer.new(client_id, scope, token_store)

  credentials = authorizer.get_credentials(user_id)
  if credentials.nil?
    # トークンが設定されていない、または期限切れの場合
    url = authorizer.get_authorization_url(base_url: OOB_URI)
    File.write 'url.txt', url
    puts 'ブラウザで次のURLを開いてAPIの利用を許可してください'
    puts url
    puts 'URLは(url.txt)にも出力されています'
    puts '応答ページに表示されるコードを入力してenterを押してください'
    puts 'code:'
    code = gets
    credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code, base_url: OOB_URI)
  end

  service = Google::Apis::CalendarV3::CalendarService.new
  service.client_options.application_name = app_name
  service.authorization = credentials

```

##5.2 予定のインポート
APIを実行する準備ができたので、実際に予定をインポートするコードを書いて行きます。
各予定のインポート結果は、ログファイルに残すようにしておきます。
また、処理件数が多いので、進捗状況をターミナルに表示するようにしておきます。

```ImportGoogleCalendar.rb

  #インポートファイルを指定
  IMPORT_FILE_NAME = 'ImportEvent.csv'

  #ログファイルを指定
  LOG_FILE_NAME = 'ImportEvent_log.csv'

  # インポートリストを読み込み
  # 文字化け回避のため、SJISから変換せずにUTF-8のまま扱う
  import_csv = CSV.read(IMPORT_FILE_NAME, headers: true, encoding: 'UTF-8')

  #ログファイルを開く
  log_file = CSV.open(LOG_FILE_NAME, 'w')
  log_file << ['レコードNo','イベントID','イベント名','実行結果','実行結果詳細']

  import_csv.each_with_index do |row,rownum|

    #
    #ここに後述のインポート処理を記述
    #

    # 進捗表示
    p (rownum + 1).to_s + ' rows completed.' if (rownum + 1) % 10 == 0
  end

  log_file.close

  puts "インポートが完了しました。"
```

ガルーンの予定をGoogleカレンダーにインポートするにあたり、データの整形・変換が必要になります。
以下に、各項目の詳細を解説していきます。

###5.2.1 参加者・設備
ガルーンでの「参加者」、Googleカレンダーでの「ゲスト」にあたる項目。
Googleカレンダーでは、ユーザーごとにカレンダーを保持していますが、予定ごとに「主催者」を決める必要があり、主催者IDが予定登録の際の対象カレンダーIDとなります。
しかし、ガルーンでは予定の「登録者」をAPIから取得できないので、先頭の参加者を「主催者」に決め打ちし、登録先のカレンダーIDとして保持しておきます。
ちなみに、設備（リソース）を予定登録先カレンダーとすると、ゲストが予定を削除できなくなるので、いずれかの参加者を主催者にする必要があります。

まず、全参加者のメールアドレスを配列に保持します。
この際、ガルーンからエクスポートした際にメールアドレスを取得できなかった特殊なユーザーは、除外しておきます。

```ImportGoogleCalendar.rb

    # 参加者リストを配列に変換
    attendees_ary = []
    cal_id = ''

    unless row["参加者（メールアドレス）"].nil?
      guests = row["参加者（メールアドレス）"].each_line.map(&:chomp)

      # 「不明ユーザー」と「メールアドレスなし」のユーザーは除外する
      guests.delete("不明ユーザー")
      guests.delete("メールアドレスなし")

      # 参加者
      guests.each_with_index do |guest,index|
        # ガルーンAPIからは「登録者」を取得できないので、参加者の一番上を登録対象カレンダーとする
        cal_id = guest if index == 0
        attendees_ary << {email: guest , attendees_omitted: true}
      end
    end

    if cal_id.empty?
      log_file << [rownum + 1,row["イベントID"],row["予定件名"],'登録先カレンダーが存在しません']
      next
    end

```

続いて、設備情報も同様に配列として保持しておきます。
Googleカレンダーでは、設備もユーザーと同様に「ゲスト」という扱いになるので、同じ"attendees_ary"に入れてしまいます。
なお、Googleカレンダーでは「現在日時より過去の日時で予定を登録した場合、設備の自動承諾が動作しない」という仕様があるので、テストの際は注意しましょう。

```ImportGoogleCalendar.rb

    # 設備
    resources = row["設備（メールアドレス）"].each_line.map(&:chomp)
    resources.each do |resource|
      # resourceはReadOnlyプロパティなので、指定する必要はないかも
      attendees_ary << {email: resource , resource: true}
    end

```


###5.2.2 繰り返し予定
続いて、繰り返し予定をGoogleカレンダー形式に整形します。
ここが厄介で、ガルーンが独自の繰り返し記法なのに対し、GoogleカレンダーはRRULE形式なので、多少強引に変換します。
ガルーン側の繰り返し予定の仕様が[リファレンス](https://developer.cybozu.io/hc/ja/articles/202463250#step6)にそこまで詳細に記載されておらず、値を推測しながら実装したので、考慮漏れのパターンがあるかもしれませんので、ご了承ください。

なお、Googleカレンダーでは、繰り返し予定の中の日程に1つでも設備予約が埋まっているものがあれば、全日程の設備予約が辞退となるので注意してください。（あらかじめ設備予約をブロックしていれば発生はしないはずですが）

```ImportGoogleCalendar.rb

    # 繰り返し予定
    recurrence_ary = []
    if row["イベントタイプ"] == 'repeat'
      # セル内改行で保持されていた繰り返し条件から、各値を取得
      condition_ary = row["繰り返し条件"].each_line.map(&:chomp)

      repeat_type = condition_ary.grep(/type:/).join.gsub!(/type:/,'')
      repeat_day = condition_ary.grep(/day:/).join.gsub!(/day:/,'')
      repeat_week = condition_ary.grep(/week:/).join.gsub!(/week:/,'')
      repeat_start_date = condition_ary.grep(/start_date:/).join.gsub!(/start_date:/,'').delete('-')
      repeat_end_date = condition_ary.grep(/end_date:/).join.gsub!(/end_date:/,'').delete('-')
      repeat_start_time = condition_ary.grep(/start_time:/).join.gsub!(/start_time:/,'').delete(':')
      repeat_end_time = condition_ary.grep(/end_time:/).join.gsub!(/end_time:/,'').delete(':')

      # 曜日指定を数値から文字列に変換
      case repeat_week
      when '1' then repeat_week = 'MO'
      when '2' then repeat_week = 'TU'
      when '3' then repeat_week = 'WE'
      when '4' then repeat_week = 'TH'
      when '5' then repeat_week = 'FR'
      when '6' then repeat_week = 'SA'
      when '7' then repeat_week = 'SU'
      else
        log_file << [rownum + 1,row["イベントID"],row["予定件名"],'失敗：想定外の繰り返し条件です。（曜日エラー）']
        next
      end

      # ガルーンの繰り返し条件をRRULE形式に変換
      rrule = ''

      # 繰り返し終了日は、今回はTimeZone変換がない前提で決め打ち
      until_str = Time.parse(repeat_end_date).strftime("%Y%m%d") + 'T000000Z'

      case repeat_type
      when 'day' then # 毎日
        rrule = 'RRULE:FREQ=DAYLY;UNTIL=' + until_str
      when 'weekday' then # 毎日（土日除く）
        rrule = 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=' + until_str
      when 'week' then #毎週X曜日
        rrule = 'RRULE:FREQ=WEEKLY;BYDAY=' + repeat_week + ';UNTIL=' + until_str
      when '1stweek' then #毎月第1X曜日
        rrule = 'RRULE:FREQ=MONTHLY;BYDAY=1' + repeat_week + ';UNTIL=' + until_str
      when '2ndweek' then #毎月第2X曜日
        rrule = 'RRULE:FREQ=MONTHLY;BYDAY=2' + repeat_week + ';UNTIL=' + until_str
      when '3rdweek' then #毎月第3X曜日
        rrule = 'RRULE:FREQ=MONTHLY;BYDAY=3' + repeat_week + ';UNTIL=' + until_str
      when '4thweek' then #毎月第4X曜日
        rrule = 'RRULE:FREQ=MONTHLY;BYDAY=4' + repeat_week + ';UNTIL=' + until_str
      when 'lastweek' then #毎月最終X曜日
        rrule = 'RRULE:FREQ=MONTHLY;BYDAY=-1' + repeat_week + ';UNTIL=' + until_str
      when 'month' then #毎月X日
        #ガルーンで毎月末は「day=0」となるので、「BYMONTHDAY-1」に変換
        if repeat_day == '0'
          rrule = 'RRULE:FREQ=MONTHLY;BYMONTHDAY=-1' + ';UNTIL=' + until_str
        else
          rrule = 'RRULE:FREQ=MONTHLY;BYMONTHDAY=' + repeat_day + ';UNTIL=' + until_str
        end
      else
        log_file << [rownum + 1,row["イベントID"],row["予定件名"],'失敗：想定外の繰り返し条件です。']
        next
      end

      recurrence_ary << rrule

      #繰り返し除外日時
      unless row["繰り返し除外日時"].nil?
        row["繰り返し除外日時"].each_line do |exdate|
          recurrence_ary << 'EXDATE:' +  exdate.chomp
        end
      end
    end

```

###5.2.3 その他プロパティの設定
残りのプロパティの値をセットしていきます。
こちらは特に難しくないので、コード内のコメントで補足します。
また、各値の設定は、実際の運用によってカスタマイズしてください。
例えば、"guests_can_modify"は「ゲストに予定の変更権限を許可する」かどうかの値で、GoogleカレンダーではデフォルトでOFFとなっています。
しかしガルーンでは参加者でも自由に予定の変更ができる仕様なので、ここの運用を揃えて置きたい場合は、trueにしておくのがいいでしょう。

```ImportGoogleCalendar.rb

    # Googleカレンダーには「予定区分」がないので、予定件名のプレフィックスとして設定
    summary_str = '【' + row["予定区分"] + '】' + row["予定件名"]

    # ガルーンの非公開予定・限定公開予定は、非公開予定とする
    if row["公開タイプ"] == 'public'
      visibility_str = 'default'
    else
      visibility_str = 'private'
    end

    # 開始・終了時刻のTimeZoneがブランクの場合は、JSTに固定
    start_time_zone = row["タイムゾーン（開始）"]
    start_time_zone = 'Asia/Tokyo' if start_time_zone.nil?

    end_time_zone = row["タイムゾーン（終了）"]
    end_time_zone = 'Asia/Tokyo' if end_time_zone.nil?

    # 各値をハッシュとして保存
    event = {
        summary: summary_str,
        description: row["予定詳細"],
        guests_can_modify: true,
        attendees_omitted: true,
        visibility: visibility_str,
        start: {
          date_time: row["開始時刻"],
          time_zone: start_time_zone
        },
        end: {
          date_time: row["終了時刻"],
          time_zone: end_time_zone
        },
        attendees: attendees_ary,
        recurrence: recurrence_ary
    }

```

###5.2.4 特殊ユーザーへの対応
ガルーンで個人に紐付かない特殊なユーザーを作成して、そこに共用予定を登録するといった運用をしている会社も多いと思います。
その場合は、Googleカレンダーにも共用カレンダーを作成しておいて、そちらのカレンダーに予定を登録するといったカスタマイズが必要になります。
例）ガルーンに「面接予定」というユーザーを作成して、そのユーザーに面接予定を登録していた場合、
　Googleカレンダーでも同様に「面接予定」という共有カレンダーを作成し、そこに移行したい。

上記のような場合は、あらかじめGoogleカレンダーに共用カレンダーを作成しておき、
「特定のユーザーが参加者に含まれる場合は、登録先のカレンダーIDを変換する」
という方法で対応が可能です。

```ImportGoogleCalendar.rb

    unless row["参加者"].nil?
      guests_name = row["参加者"].each_line.map(&:chomp)
      cal_id = 'shared_calendar_id1@group.calendar.google.com' if guests_name.include?('面接予定')
      cal_id = 'shared_calendar_id2@group.calendar.google.com' if guests_name.include?('セミナー予定')
    end

```

また、今回は使用しませんでしたが、ガルーンの「公開先」に設定されていたユーザーをゲストとして追加するなどのカスタマイズも可能ですので、必要に応じて実装してください。


###5.2.5 予定登録APIの実行
各値の設定が完了したら、いよいよAPIを実行してGoogleカレンダーに予定を登録してみましょう。
正常に登録できた場合は登録した予定のURLを、エラーが発生した場合は例外をログに記録します。

```ImportGoogleCalendar.rb

    begin
      cal_event = Google::Apis::CalendarV3::Event.new(event)
      result = service.insert_event(cal_id, cal_event)
      log_file << [rownum + 1,row["イベントID"],row["予定件名"],'成功：',result.html_link]
    rescue => e
      log_file << [rownum + 1,row["イベントID"],row["予定件名"],'失敗：',e]
    end

```

##5.3 インポートプログラムの実行
完成した"ImportGoogleCalendar.rb"を実行してみましょう。
正常に動作すれば、Googleカレンダーに予定が登録されて行きます。
実行結果はログファイルを確認してください。
※本番実行の際は、全設備のブロック解除をお忘れなく
※設備の自動承認メールが大量にユーザーに送信されるので、プログラム実行中のみ
　Gmailのコンプライアンスルールでリソースからのメールを遮断しておくことを推奨します

なお、冒頭で述べた通り、ガルーンの予定の登録方法や運用によっては、考慮漏れのエラーが発生する可能性があります。
本番移行は一発勝負となりますので、テスト用の設備やユーザーを用意して入念にテスト・リハーサルを行い、イレギュラーパターンを潰しておきましょう。
特に、繰り返し予定には様々なパターンが存在するので、一通りのパターンを試しておくことを推奨します。
実際に今回のプログラムでは、以下の想定外の挙動が発生しました。

1.繰り返し予定の終了日が条件マッチする場合、最終日の予定がGoogleカレンダーに登録されない
2.繰り返し予定の「繰り返し除外日時」が反映されていない

いずれもRRULEでのUNTILとEXDATEのタイムゾーン考慮漏れが原因と思われます。
上記の修正までは今回できなかったので、実際にこのコードを流用する場合は、
これらのパターンの対応・動作検証も実施するようにしてください。

#6.おわりに
実際にこのプログラムを使用して、ガルーンから約3000件（※1）の予定をエクスポートし、Googleカレンダーにインポートを実施したところ、数十分でインポートが完了し、先述の繰り返し予定の考慮漏れ以外は問題なく移行ができました。精度としては95%くらいでしょうか。
※1 重複排除後の件数。ガルーンからエクスポートしたrowデータは約30000レコード。

ガルーンのAPIが思った以上にイケてなかったり、Googleカレンダーの仕様の理解に手こずったりしたせいで、プログラムの作成にはかなり苦戦しましたが、手作業では到底終わらない移行作業を短時間でオペミスなく終わらせることができるのは、プログラムならではの強みだと思います。

本エントリが、ガルーンからGoogleカレンダーへの移行に困っている方々の何かしらの助けになれば幸いです。

---
各プログラムをGistで公開しました。
https://gist.github.com/W-Yoshida/df2765c8438e79929ed223c5bf3af0a0

実行環境によってはそのままでは動作しない場合もありますので、適宜修正してお使いください。