仕様再掲。
- 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文字……バイトではない