MS Exchange/Outlook のカレンダーとGoogle Calendarの同期 (続編)

id:otn:20150901 「MS Exchange/Outlook のカレンダーとGoogle Calendarの同期」のその後。

google-api-client が 0.9 になってそのままでは動かなくなった。0.8のまま使っていたのだが、認証がおかしくなった(毎回認証しないとタイムスタンプがおかしいというエラーになる)ので、0.9 対応に修正。いろいろあった。

認証については、googleauth の README.md を参考に。しかし、以前のように「ブラウザが自動的に開いて、認証するとアプリが続行」という風には出来ず、自分でブラウザを開いて、認証したあと、そこに表示された文字列を入力しないといけない。これは不便だがしょうが無い。

あともAPIが色々変わっており、ソースを追ったり、gemにデバッグプリントを入れたりしてなんとかなった。手こずったのがリマインダ。リマインダメソッド指定のキーが、"method" から :reminder_method に変わっている!

#! ruby
CAL_ID = "xxxxxxxxxxxxxxxxx@gmail.com" #GOOGLEアカウントのメールアドレス
DAYS = 30
AUTH_FILE = "authfile.yaml"
SECRET_FILE = "client_secret.json"

ENV["http_proxy"]  = "http://user:pass@proxyserver:port" # proxyの設定
ENV["https_proxy"] = "http://user:pass@proxyserver:port" # proxyの設定

Dir.chdir File.dirname($0)

require "googleauth"
require "googleauth/stores/file_token_store"
require "google/apis/calendar_v3"
require "google/api_client/client_secrets"
require "win32ole"

class Event
  def to_s # デバッグ用に書いたもの
    [@start.strftime("%Y-%m-%d %H:%M"),
    @end.strftime("%Y-%m-%d %H:%M"),
    @allday.to_s[0],
    @reminder.to_s,
    @title, @body.gsub(/\s/," "), @location].join(",")
  end

  def ==(other)
    @start    == other.instance_variable_get(:@start)  and
    @end      == other.instance_variable_get(:@end)    and
    @allday   == other.instance_variable_get(:@allday) and
    reminder_eql?(@start, @reminder, other.instance_variable_get(:@reminder)) and
    @title    == other.instance_variable_get(:@title)  and
    @body     == other.instance_variable_get(:@body)   and
    @location == other.instance_variable_get(:@location)
  end

  def delete
    Gcal.delete(@id)
  end

  def add
    event = {
      summary:      @title,
      description:  @body,
      start:        start,
      end:          ende,
      location:     @location,
      reminders:    reminder,
      source:       {title: "Exchange", url: "http://localhost"}
    }
    Gcal.add(event)
  end

  def is_holiday
    @allday and @location=="日本"
  end

  private
  def start
    if @allday
      {date: @start.strftime("%Y-%m-%d")}
    else
      {date_time: @start.iso8601}
    end
  end
  def ende
    if @allday
      {date:  @end.strftime("%Y-%m-%d")}
    else
      {date_time:  @end.iso8601}
    end
  end
  def reminder
    if @reminder
      {use_default: false, overrides: [{reminder_method: :popup, minutes: @reminder}]}
    else
      {use_default: false}
    end
  end
  def reminder_eql?(start, x, y)
    @@now ||= Time.now
    ( x == y ) or
    # リマインダー時刻を過ぎている場合はリマインダーの有無を無視
    ( x and not y and start - x < @@now ) or
    ( y and not x and start - y < @@now )
  end
end

# Googleカレンダー
class Gcal < Event
  def self.get_auth(calendar_id, auth_filename, secret_filename)
    @@cal_id = calendar_id

    client_id   = Google::Auth::ClientId.from_file(SECRET_FILE)
    scope       = "https://www.googleapis.com/auth/calendar"
    token_store = Google::Auth::Stores::FileTokenStore.new(file: AUTH_FILE)
    authorizer  = Google::Auth::UserAuthorizer.new(client_id, scope, token_store)

    credentials = authorizer.get_credentials(@@cal_id)
    if credentials.nil?
      url = authorizer.get_authorization_url(base_url: "urn:ietf:wg:oauth:2.0:oob")
	  system(%Q|CMD /c start "" "#{url}"|)
      print "Enter the resulting code: "
      code = gets
      credentials = authorizer.get_and_store_credentials_from_code(
                    user_id: @@cal_id, code: code, base_url: "urn:ietf:wg:oauth:2.0:oob")
    end

	@@client = Google::Apis::CalendarV3::CalendarService.new
	@@client.authorization = credentials
  end

  def self.delete(id)
    @@client.delete_event(@@cal_id, id)
  end

  def self.add(event)
    @@client.insert_event(@@cal_id, Google::Apis::CalendarV3::Event.new(event))
  end

  def self.is_exchange(item)
    item.source and item.source.title == "Exchange"
  end

  def self.list(from, to)
    @@client.list_events(@@cal_id,
      order_by:  "startTime",
      time_min:  from.iso8601,
      time_max:  to.iso8601,
      single_events:  "True"
    ).items
  end

  def initialize(event)
    if event.start.date
      @start  = Time.parse(event.start.date)
      @end    = Time.parse(event.end.date)
      @allday = true
    else
      @start  = event.start.date_time.to_time
      @end    = event.end.date_time.to_time
      @allday = false
    end
    if event.reminders.use_default
      @reminder = 10 # 正確にはそのユーザーのデフォルト値を取得する必要がある
    elsif event.reminders.overrides
      @reminder = event.reminders.overrides[0].minutes
    else
      @reminder = nil
    end
    @title = event.summary || ""
    @body  = event.description || ""
    @location = event.location || ""
    @id = event.id
  end
end

# Exchangeカレンダー
class Ecal < Event
  FolderCalendar = 9
  def self.list(from, to)
    @@calendar ||= WIN32OLE.new("Outlook.Application")
      .GetNamespace("MAPI").GetDefaultFolder(FolderCalendar)

    items = @@calendar.Items
    items.Sort "[Start]"
    items.IncludeRecurrences = true
    item = items.Find(%Q/[Start] < "#{to.strftime("%Y-%m-%d %H:%M")}" AND [End] >= "#{from.strftime("%Y-%m-%d %H:%M")}"/)

    Enumerator.new do |y|
      while item 
        y << item
        item = items.FindNext
      end
    end
  end

  def initialize(event)
    @start = event.Start
    @end   = event.End
    @allday = event.AllDayEvent
    @reminder = event.ReminderSet ? event.ReminderMinutesBeforeStart : nil
    @title  = event.Subject.encode(Encoding::UTF_8)
    @body   = event.Body.encode(Encoding::UTF_8)
    @location = event.Location.encode(Encoding::UTF_8)
  end
end

# 期間設定
today = Time.now
time_min = today - 3600*24*DAYS
time_max = today + 3600*24*DAYS

# Exchangeイベント取得
e_events = Ecal.list(time_min, time_max).map{|ev| Ecal.new(ev)}.reject(&:is_holiday)

# Google認証
Gcal.get_auth(CAL_ID, AUTH_FILE, SECRET_FILE)

# Googleイベント取得
g_events = Gcal.list(time_min, time_max)
  .select{|ev| Gcal.is_exchange(ev)}.map{|ev| Gcal.new(ev)}

# イベント削除
g_events.reject{|ev| e_events.include?(ev)}.each{|ev| ev.delete}

# イベント追加
e_events.reject{|ev| g_events.include?(ev)}.each{|ev| ev.add}