MS Exchange/Outlook のカレンダーとGoogle Calendarの同期 その4

仕様再掲。

  • Exchange側をマスターとして、Google Calendarへ一方方向で反映
  • 前後30日分だけチェックして追加・変更・削除を反映
  • Exchange側から反映したのでないGoogle Calendar上で直接登録したイベントには触らない

3つめの仕様のために、Google Calendarに登録するときに、Exchangeからの反映であることをマークしないといけない。
使えそうな属性をみて、sourceという属性を使うことにする。ウェブページを元にイベントを登録するときにそのページのURLとタイトルを書くようだ。

2014-09-19修正

コメントで、Googleのdescriptionは長さ上限*1があると教えていただいたので、Ecal#initializeで8192文字までに制限。


全体のプログラム。

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

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

Dir.chdir File.dirname($0)

require "google/api_client"
require "google/api_client/client_secrets"
require "google/api_client/auth/installed_app"
require "google/api_client/auth/file_storage"
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.execute(:delete,{"eventId" => @id})
  end

  def add
    event = {
      "summary" => @title,
      "description" => @body,
      "start" => start,
      "end" => ende,
      "location" => @location,
      "reminders" => reminder,
      "source" => {"title" => "Exchange", "url" => "http://localhost"}
    }
    Gcal.execute(:insert,
      {},
      :body => JSON.dump(event),
      :headers => {"Content-Type" => "application/json"}
    )
  end

  private
  def start
    if @allday
      {"date" => @start.strftime("%Y-%m-%d")}
    else
      {"dateTime" => @start.iso8601}
    end
  end
  def ende
    if @allday
      {"date" => @end.strftime("%Y-%m-%d")}
    else
      {"dateTime" => @end.iso8601}
    end
  end
  def reminder
    if @reminder
      {"useDefault" => false, "overrides"=>[{"method"=>"popup","minutes"=>@reminder}]}
    else
      {"useDefault" => 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 = Google::APIClient.new(:application_name => "")
    
    authfile = Google::APIClient::FileStorage.new(auth_filename)
    if authfile.authorization
      @@client.authorization = authfile.authorization
    else
      client_secrets = Google::APIClient::ClientSecrets.load(secret_filename)
      flow = Google::APIClient::InstalledAppFlow.new(
        :client_id => client_secrets.client_id,
        :client_secret => client_secrets.client_secret,
        :scope => ["https://www.googleapis.com/auth/calendar"]
      )
      @@client.authorization = flow.authorize(authfile)
    end
    @@service = @@client.discovered_api("calendar", "v3")
  end

  def self.execute(cmd, params, opt={})
    x = @@client.execute({:api_method => @@service.events.send(cmd),
      :parameters => params.merge("calendarId" => @@cal_id)}
      .merge(opt))
    if x.error?
      raise "#{x.status} #{x.error_message}"
    end
    x
  end

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

  def self.list(from, to)
    execute(:list,
         {"orderBy" => "startTime",
          "timeMin" => from.iso8601,
          "timeMax" => to.iso8601,
          "singleEvents" => "True"}
    ).data.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
      @end    = event.end.date_time
      @allday = false
    end
    if event.reminders.use_default
      @reminder = 10 # 正確にはそのユーザーのデフォルト値を取得する必要がある
    elsif event.reminders.overrides[0]
      @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)[0,8192]
    @location = event.Location.encode(Encoding::UTF_8)
  end
end

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

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

# 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)}.map{|ev| ev.delete}

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

ハッシュのキーはシンボルでも良い場所もあるようだが、どうせ最後は文字列になって送られるので文字列のままにしておく。

*1:改行を1文字として8192文字……バイトではない