[開発] Google CalendarのAtomフィードをiCalに変換

Google Calendarの出力するiCalをつかってスケジュールをiPodでみられるようにしたいのですが,
メイビィの戯言さんの記事

http://artnext.net/blog/2006/04/google_calendar_ipod.html

やBLOGKIDさんの記事

http://www.blogkid.com/weblog/archives/001451.html

などで指摘されているように,現時点ではGoogle CalendariCal出力は日本語が抜け落ちてしまいます.
そのうち改善されると思うのですが..

Atomフィードのほうにはきちんと日本語が出力されているので,AtomからiCal形式に変換してやることにしました.
書いたのは下のrubyスクリプト.飽くまでGoogleさんが日本語対応してくれるまでの応急処置なので,軽い気持ちで書いたけど..レポート一本分+αくらいの時間が費やされた気がする.

#!/usr/bin/ruby
# $Id: gcalatom2ical.rb 119 2006-06-18 05:06:32Z mazmura $
# Google Calendar のAtomフィードをiCalに
#
# * 能書き
# - Google Calendarの出力するAtomフィードをiCalに変換
# - 最後の実行日時をホームディレクトリに記録し,次回実行時は変更点のみicsファイルに落とす
#
# * 開発時環境
# - ruby 1.8.4 (2005-12-24) [i386-cygwin]
#
# * インストール&実行
# (1) rubygems をインストール
# (2a) $ gem install atom
# (2b) $ gem install vpim
# (3) このファイルの[1] 設定部分を修正
# (4) $ ruby gcalatom2ical.rb
# (5) $target_dirにicsファイルが作成される
# 
# * 既知の不具合/不都合
# - 「繰り返し」形式の予定は無視される
# - 25件以上新しいエントリがある場合に,それを超える分のエントリは無視される
# -- 大量に取得したければAtomフィードURLの後ろに ?max-results=150 を加えるなどして対処

$KCODE = 'utf-8'
require 'rubygems'
require 'atom'
require 'tmpdir'
require 'vpim/icalendar'
require 'logger'
require 'yaml'

# [1] 設定部分(グローバル変数)
begin
  # Google Calendar の XML(Atomフィード) アドレス
  $urls = [
  'http://www.google.com/calendar/feeds/matzun@gmail.com/private-xxxxxxxxxxx/basic', # Main
    'http://www.google.com/calendar/feeds/xxxxxxxxxxx@group.calendar.google.com/private-xxxxxxxxxxx/basic', # Events
    'http://www.google.com/calendar/feeds/xxxxxxxxxxx@group.calendar.google.com/private-xxxxxxxxxxx/basic', # Misc
    'http://www.google.com/calendar/feeds/xxxxxxxxxxx@group.calendar.google.com/private-xxxxxxxxxxx/basic', # Nayuta
    'http://www.google.com/calendar/feeds/xxxxxxxxxxx@group.calendar.google.com/private-xxxxxxxxxxx/basic'  # 就活
  ]

  # iCal形式ファイルの出力先ディレクトリ
  #$target_dir = "."
  $target_dir = "/cygdrive/e/Calendars"

  # ログ
  $log = Logger.new(STDOUT)
  $log.level = Logger::INFO
end
  

# [2] クラス定義
begin
  # 最後の実行日時をURLごとに記録するためのクラス
  class LastRunLog
    @@log_file = ENV['HOME'] + "/.gcalatom2ical.lrlog"

    def self.instance
      if File.exists?(@@log_file) then
        ret = nil
        File.open(@@log_file, "r") {|file| ret = YAML.load(file)}
        return ret
      else
        return LastRunLog.new
      end
    end

    def initialize
      @last_times = Hash.new
    end
    attr_accessor(:last_times)

    def store
      File.open(@@log_file, "w") {|file| YAML.dump(self, file)}
    end
  end

  # ひとつのカレンダー単位
  class CalendarUnit
    @@re_both = /^(\d\d\d\d)\-(\d?\d)\-(\d?\d)( (\d?\d):(\d?\d):(\d?\d))?$/
    @@re_time = /^(\d?\d):(\d?\d):(\d?\d)$/
    @@re_content = /When: ([0-9\-: ]+)to([0-9\-: ]+).+<br>(Duration:.+<br>)?(Where: (.+)<br>)?(Event Status: [A-Z]+<br>)?(Event Description:(.+))?/

    def initialize(url, id)
      @url = url
      @id = id
      @path_atom = Dir.tmpdir + "/atom_#{id}.xml"
    end

    # Atomフィードをローカルにダウンロード
    def download
      `wget -O #{@path_atom} #{@url}`
      return self
    end

    def parse_time(base_date, str)
      if @@re_both =~ str then
        return Time.gm($1, $2, $3, $5, $6, $7)
      elsif @@re_time =~ str then
        raise "Base date must be specified: #{str}" if base_date == nil
        return Time.gm(base_date.year, base_date.month, base_date.day, $1, $2, $3)
      end
      $log.warn("Parse error: '#{str}'")
      return nil
    end

    # AtomフィードをiCal形式に変換
    def convert(last_time = nil)
      @feed = Atom::Feed.new(IO.read(@path_atom))
  
      @cal = Vpim::Icalendar.create
  
      @feed.entries.each { |entry|
        # 更新日時(entry.updated)が最後のチェック日時(last_time)以前ならスキップ
        puts("entry.updated = #{entry.updated}, last_time = #{last_time}")
        if last_time != nil then
          if entry.updated <= last_time then
            $log.info("Skip..")
            next
          end
        end

        # スケジュールの内容をパース
        if (@@re_content =~ entry.content.value) != 0 then
          $log.warn("Ignored entry(illegal content): content = '#{entry.content.value}'")
          next
        end
  
        @cal.add_event {|event|
          dtstart = parse_time(nil, $1.strip)
          if dtstart == nil then
            $log.warn("Ignored entry(illegal date): content = '#{entry.content.value}'")
          else
            event.dtstart(dtstart)
            event.dtend(parse_time(dtstart, $2.strip))
            event.summary(entry.title)
            event.description($8) if $8 != nil
          end
        }
      }
      return self
    end

    # iCalを指定ディレクトリに出力
    def output(dir)
      t = Time.now.strftime("%Y%m%d_%H%M%S")
      File.open(dir + "/out_#{t}_#{@id}.ics", "w") {|file| file.puts(@cal)}
      return self
    end

    def clean
      `rm -f #{@path_atom}`
    end
  end
end


# [3] メインの処理
begin
  lrlog = LastRunLog.instance

  cnt = 0
  $urls.map {|url|
    $log.info("ID:#{cnt} started...")
    unit = CalendarUnit.new(url, cnt)
    unit.download
    unit.convert(lrlog.last_times[url])
    unit.output($target_dir)
    #unit.clean
    lrlog.last_times[url] = Time.now
    cnt += 1
  }

  lrlog.store
end

もう少し再利用を考えるならPlaggerなどでやると吉?
Cygwinrubyを起動する次のようなバッチファイルをつくって,クリック一つでiPodと同期.

@echo off
REM $Id: gcalatom2ical.bat 118 2006-06-17 17:44:10Z mazmura $

set CYGWIN=ntsec
set HOME=/home/mazmura
set MAKE_MODE=UNIX
set SHELL=/bin/bash

C:
chdir C:\Cygwin\bin

bash --login -i -c 'ruby /home/mazmura/workspaces/scripts/misc/gcalatom2ical.rb'

Google Calendarの正式な日本語対応が待ち遠しい..