日本の祝日もちゃんと表示するjcalコマンドを作ろう

紙に印刷して使う目的なら、表計算アプリのカレンダーはけっこう使えるのだけど、画面で素早く確認したい時には、ちょっと仰々し過ぎる。特にターミナルで作業している時などは、そのままコマンドラインから素早くカレンダーを表示したい欲求がある。calコマンドはあるのだけど、日本の祝日は表示されない...。

ならば、日本の祝日もちゃんと表示するjcalコマンドを作ってみよう!と思い立った。

  • 外部からのカレンダー情報に頼らずに、祝日もちゃんと自力で計算して、jcalコマンドの中のみで完結するようにしたい。
  • 1900年から2099年までのカレンダー出力に対応したい。

作業環境

  • OSX 10.9.4
  • ruby 2.0.0p451 (2014-02-24 revision 45167) [universal.x86_64-darwin13]

祝日情報

  • 外部からのカレンダー情報に頼らないので、まずは日本の祝日をルール化した情報が必要になる。
  • 日付が未確定の祝日について...
    • ハッピーマンデーは、"monday 2"のように書いておく。(第2月曜日を意味する)
    • 春分の日秋分の日は、"spring_day"・"autumn_day"のように書いておく。
HOLIDAYS = [
  {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
  {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
  {month:11, day:12,          term:1990..1990, name:'即位の礼'},
  {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
  {month:1,  day:1,           term:   0..9999, name:'元旦'},
  {month:1,  day:15,          term:   0..1999, name:'成人の日'},
  {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
  {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
  {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
  {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
  {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
  {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
  {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
  {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
  {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
  {month:7,  day:20,          term:1996..2002, name:'海の日'},
  {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
  {month:8,  day:11,          term:2016..9999, name:'山の日'},
  {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
  {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
  {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
  {month:10, day:10,          term:1966..1999, name:'体育の日'},
  {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
  {month:11, day:3,           term:   0..9999, name:'文化の日'},
  {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
  {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
]

将来、法律改正で祝日が変化した時も、この祝日情報をメンテナンスすることで対応するのだ。

      • 場合によっては、対応できないこともあるのだけど...。

日付を求める

  • HOLIDAYSの情報から、すべて祝日を日付に変換する処理を考える。
  • :dayの値が「数値」の場合と「文字列」の場合で切り分ける。
  • 数値の場合は、悩むことなく素早く日付を求められる。
  • 文字列の場合は、後回し。とりあえず、文字列そのものを返す。
require 'date'

y = 2014
enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
  case h[:day]
  when Fixnum
    {date: Date.new(y, h[:month], h[:day])}.merge(h)
  when String
    h[:day]
  end
end

enable_holidays.each {|i| p i}
  • 実行してみると、数値の日付はこのように求められた。
$ ruby jcal_test.rb 
{:date=>#, :month=>1, :day=>1, :term=>0..9999, :name=>"元旦"}
"monday 2"
{:date=>#, :month=>2, :day=>11, :term=>1967..9999, :name=>"建国記念日"}
"spring_day"
{:date=>#, :month=>4, :day=>29, :term=>2007..9999, :name=>"昭和の日"}
{:date=>#, :month=>5, :day=>3, :term=>0..9999, :name=>"憲法記念日"}
{:date=>#, :month=>5, :day=>4, :term=>2007..9999, :name=>"みどりの日"}
{:date=>#, :month=>5, :day=>5, :term=>0..9999, :name=>"こどもの日"}
"monday 3"
"monday 3"
"autumn_day"
"monday 2"
{:date=>#, :month=>11, :day=>3, :term=>0..9999, :name=>"文化の日"}
{:date=>#, :month=>11, :day=>23, :term=>0..9999, :name=>"勤労感謝の日"}
{:date=>#, :month=>12, :day=>23, :term=>1989..9999, :name=>"天皇誕生日"}


次は文字列の処理。

  • "monday 2"から第2月曜日を求めたり、"spring_day"から春分の日を求めるのだ。
  • Dateクラスを拡張して、monday・spring_day・autumn_dayメソッドを追加してみた。
  • :dayの値が文字列の場合は、それらのメソッドを実行して日付を求めるのだ。
require 'date'

class Date
  def monday(w)
    self + 7 * w.to_i - ((self - 1).wday + 6) % 7 - 1
  end

  def spring_day
    dy = self.year - 1900
    Date.new(self.year, 3, (21.4471 + 0.242377*dy - dy/4).to_i)
  end

  def autumn_day
    dy = self.year - 1900
    Date.new(self.year, 9, (23.8896 + 0.242032*dy - dy/4).to_i)
  end
end

y = 2014
enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
  case h[:day]
  when Fixnum
    {date: Date.new(y, h[:month], h[:day])}.merge(h)
  when String
    {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
  end
end

enable_holidays.each {|i| p i}
  • 実行してみると、ハッピーマンデー・春分秋分の日付も求められた!
$ ruby jcal_test.rb 
{:date=>#, :month=>1, :day=>1, :term=>0..9999, :name=>"元旦"}
{:date=>#, :month=>1, :day=>"monday 2", :term=>2000..9999, :name=>"成人の日"}
{:date=>#, :month=>2, :day=>11, :term=>1967..9999, :name=>"建国記念日"}
{:date=>#, :month=>3, :day=>"spring_day", :term=>1900..2099, :name=>"春分の日"}
{:date=>#, :month=>4, :day=>29, :term=>2007..9999, :name=>"昭和の日"}
{:date=>#, :month=>5, :day=>3, :term=>0..9999, :name=>"憲法記念日"}
{:date=>#, :month=>5, :day=>4, :term=>2007..9999, :name=>"みどりの日"}
{:date=>#, :month=>5, :day=>5, :term=>0..9999, :name=>"こどもの日"}
{:date=>#, :month=>7, :day=>"monday 3", :term=>2003..9999, :name=>"海の日"}
{:date=>#, :month=>9, :day=>"monday 3", :term=>2003..9999, :name=>"敬老の日"}
{:date=>#, :month=>9, :day=>"autumn_day", :term=>1900..2099, :name=>"秋分の日"}
{:date=>#, :month=>10, :day=>"monday 2", :term=>2000..9999, :name=>"体育の日"}
{:date=>#, :month=>11, :day=>3, :term=>0..9999, :name=>"文化の日"}
{:date=>#, :month=>11, :day=>23, :term=>0..9999, :name=>"勤労感謝の日"}
{:date=>#, :month=>12, :day=>23, :term=>1989..9999, :name=>"天皇誕生日"}

安易な拡張をわきまえる

  • 祝日の日付は求められたのだけど、あまり褒められたことをしていない...。
  • Dateクラスを安易に拡張してしまっている。
  • 既存メソッドを上書きしている訳ではないが、mondayなどはメソッド名の衝突もあり得るかも。
  • Ruby 2.0から追加されたrefineを使って拡張してみた。
require 'date'

module DateEx
  refine Date do
    def monday(w)
      self + 7 * w.to_i - ((self - 1).wday + 6) % 7 - 1
    end

    def spring_day
      dy = self.year - 1900
      Date.new(self.year, 3, (21.4471 + 0.242377*dy - dy/4).to_i)
    end

    def autumn_day
      dy = self.year - 1900
      Date.new(self.year, 9, (23.8896 + 0.242032*dy - dy/4).to_i)
    end
  end
end
using DateEx


y = 2014
enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
  case h[:day]
  when Fixnum
    {date: Date.new(y, h[:month], h[:day])}.merge(h)
  when String
    {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
  end
end

enable_holidays.each {|i| p i}
refineはsendに効かない
  • これでDateクラスの拡張は、このファイルの中でしか有効にならないはず...なのだけど、エラー発生。
    • Date.new(2014, 1).send('monday', 2)では、mondayメソッドが未定義と警告されてしまった。
    • Date.new(2014, 1).monday(2)なら、問題なく第2月曜日が求められた。
  • どうやらrefineによる拡張は、sendメソッドには効かないようだ。
refineはevalには効く
  • ならば、evalを試してみる。
# {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
  method, argument = *h[:day].split
  eval("{date:Date.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
  • すると、evalなら機能した。
$ ruby jcal_test.rb
/Users/bebe/Desktop/jcal_test.rb:33: warning: Refinements are experimental, and the behavior may change in future versions of Ruby!
{:date=>#, :month=>1, :day=>1, :term=>0..9999, :name=>"元旦"}
{:date=>#, :month=>1, :day=>"monday 2", :term=>2000..9999, :name=>"成人の日"}
{:date=>#, :month=>2, :day=>11, :term=>1967..9999, :name=>"建国記念日"}
{:date=>#, :month=>3, :day=>"spring_day", :term=>1900..2099, :name=>"春分の日"}
{:date=>#, :month=>4, :day=>29, :term=>2007..9999, :name=>"昭和の日"}
{:date=>#, :month=>5, :day=>3, :term=>0..9999, :name=>"憲法記念日"}
{:date=>#, :month=>5, :day=>4, :term=>2007..9999, :name=>"みどりの日"}
{:date=>#, :month=>5, :day=>5, :term=>0..9999, :name=>"こどもの日"}
{:date=>#, :month=>7, :day=>"monday 3", :term=>2003..9999, :name=>"海の日"}
{:date=>#, :month=>9, :day=>"monday 3", :term=>2003..9999, :name=>"敬老の日"}
{:date=>#, :month=>9, :day=>"autumn_day", :term=>1900..2099, :name=>"秋分の日"}
{:date=>#, :month=>10, :day=>"monday 2", :term=>2000..9999, :name=>"体育の日"}
{:date=>#, :month=>11, :day=>3, :term=>0..9999, :name=>"文化の日"}
{:date=>#, :month=>11, :day=>23, :term=>0..9999, :name=>"勤労感謝の日"}
{:date=>#, :month=>12, :day=>23, :term=>1989..9999, :name=>"天皇誕生日"}
refineの警告を消したい
  • 但し、refineはRuby 2.0において実験的な機能らしいので、警告が表示されてしまう...。
    • Ruby 2.1では正式な機能となり、警告されなくなるようだ。
  • では、Ruby 2.0でこの警告を消す方法はないのだろうか?
  • 調べてみると、rubyコマンドの-W0オプションによって警告を非表示にできるようだ。
$ ruby -W0 jcal_test.rb
  • これで警告されなくなった!
  • コマンドとして実行する時は、ファイルの先頭にも-W0を追記しておくのだ。
#!/usr/bin/ruby -W0

振替休日の判定

  • ここまでの祝日情報に、さらに振替休日を追加する必要がある。
  • 祝日が日曜に当たる場合は、次の祝日でない平日が振替休日となる。
  • その処理を追記して、以下のようなコードとなった。
...中略...

y = 2014

# 有効な祝日を取り出し、日付を追加する
enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
  case h[:day]
  when Fixnum
    {date: Date.new(y, h[:month], h[:day])}.merge(h)
  when String
    # {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
    method, argument = *h[:day].split
    eval("{date:Date.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
  end
end

enable_dates = enable_holidays.map {|h| h[:date]}

# 振替休日を判定
enable_dates.each do |date|
  if date.wday == 0
    while enable_dates.include?(date)
      date += 1
    end
    enable_holidays << {date:date, name:'振替休日'}
  end
end

enable_holidays.each {|i| p i}
  • 実行してみると、振替休日が2日追加された。
$ ruby -W0 jcal_test.rb 
{:date=>#, :month=>1, :day=>1, :term=>0..9999, :name=>"元旦"}
{:date=>#, :month=>1, :day=>"monday 2", :term=>2000..9999, :name=>"成人の日"}
{:date=>#, :month=>2, :day=>11, :term=>1967..9999, :name=>"建国記念日"}
{:date=>#, :month=>3, :day=>"spring_day", :term=>1900..2099, :name=>"春分の日"}
{:date=>#, :month=>4, :day=>29, :term=>2007..9999, :name=>"昭和の日"}
{:date=>#, :month=>5, :day=>3, :term=>0..9999, :name=>"憲法記念日"}
{:date=>#, :month=>5, :day=>4, :term=>2007..9999, :name=>"みどりの日"}
{:date=>#, :month=>5, :day=>5, :term=>0..9999, :name=>"こどもの日"}
{:date=>#, :month=>7, :day=>"monday 3", :term=>2003..9999, :name=>"海の日"}
{:date=>#, :month=>9, :day=>"monday 3", :term=>2003..9999, :name=>"敬老の日"}
{:date=>#, :month=>9, :day=>"autumn_day", :term=>1900..2099, :name=>"秋分の日"}
{:date=>#, :month=>10, :day=>"monday 2", :term=>2000..9999, :name=>"体育の日"}
{:date=>#, :month=>11, :day=>3, :term=>0..9999, :name=>"文化の日"}
{:date=>#, :month=>11, :day=>23, :term=>0..9999, :name=>"勤労感謝の日"}
{:date=>#, :month=>12, :day=>23, :term=>1989..9999, :name=>"天皇誕生日"}
{:date=>#, :name=>"振替休日"}
{:date=>#, :name=>"振替休日"}

国民の休日の判定

  • さらに、祝日と祝日の間の平日は、休日となる。
  • その処理を追記して、以下のようなコードとなった。
    • 2014年には国民の休日が存在しないので、y = 2015 にした。
...中略...

y = 2015

# 有効な祝日を取り出し、日付を追加する
enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
  case h[:day]
  when Fixnum
    {date: Date.new(y, h[:month], h[:day])}.merge(h)
  when String
    # {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
    method, argument = *h[:day].split
    eval("{date:Date.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
  end
end

enable_dates = enable_holidays.map {|h| h[:date]}

# 振替休日を判定
enable_dates.each do |date|
  if date.wday == 0
    while enable_dates.include?(date)
      date += 1
    end
    enable_holidays << {date:date, name:'振替休日'}
  end
end

# 国民の休日を判定
enable_dates.each_cons(2) do |a, b|
  if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
    enable_holidays << {date:a + 1, name:'国民の休日'}
  end
end

enable_holidays.each {|i| p i}
  • 実行してみると、振替休日と国民の休日がそれぞれ追加された。
$ ruby -W0 jcal_test.rb 
{:date=>#, :month=>1, :day=>1, :term=>0..9999, :name=>"元旦"}
{:date=>#, :month=>1, :day=>"monday 2", :term=>2000..9999, :name=>"成人の日"}
{:date=>#, :month=>2, :day=>11, :term=>1967..9999, :name=>"建国記念日"}
{:date=>#, :month=>3, :day=>"spring_day", :term=>1900..2099, :name=>"春分の日"}
{:date=>#, :month=>4, :day=>29, :term=>2007..9999, :name=>"昭和の日"}
{:date=>#, :month=>5, :day=>3, :term=>0..9999, :name=>"憲法記念日"}
{:date=>#, :month=>5, :day=>4, :term=>2007..9999, :name=>"みどりの日"}
{:date=>#, :month=>5, :day=>5, :term=>0..9999, :name=>"こどもの日"}
{:date=>#, :month=>7, :day=>"monday 3", :term=>2003..9999, :name=>"海の日"}
{:date=>#, :month=>9, :day=>"monday 3", :term=>2003..9999, :name=>"敬老の日"}
{:date=>#, :month=>9, :day=>"autumn_day", :term=>1900..2099, :name=>"秋分の日"}
{:date=>#, :month=>10, :day=>"monday 2", :term=>2000..9999, :name=>"体育の日"}
{:date=>#, :month=>11, :day=>3, :term=>0..9999, :name=>"文化の日"}
{:date=>#, :month=>11, :day=>23, :term=>0..9999, :name=>"勤労感謝の日"}
{:date=>#, :month=>12, :day=>23, :term=>1989..9999, :name=>"天皇誕生日"}
{:date=>#, :name=>"振替休日"}
{:date=>#, :name=>"国民の休日"}

祝日データベースを作る

  • 最後に、祝日の日付と名前をペアにした配列を作って、これを祝日のデータベースとしておくのだ。
...中略...

@holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort

@holidays_database.each {|i| p i}
  • 実行してみると、以下のような配列が得られるのだ。
$ ruby -W0 jcal_test.rb 
[#, "元旦"]
[#, "成人の日"]
[#, "建国記念日"]
[#, "春分の日"]
[#, "昭和の日"]
[#, "憲法記念日"]
[#, "みどりの日"]
[#, "こどもの日"]
[#, "振替休日"]
[#, "海の日"]
[#, "敬老の日"]
[#, "国民の休日"]
[#, "秋分の日"]
[#, "体育の日"]
[#, "文化の日"]
[#, "勤労感謝の日"]
[#, "天皇誕生日"]

JPHolidayクラスにまとめる

  • 以上の仕組みをJPHolidayクラスとしてまとめてみる。
...中略...

class JPHoliday
  HOLIDAYS = [
    {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
    {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
    {month:11, day:12,          term:1990..1990, name:'即位の礼'},
    {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
    {month:1,  day:1,           term:   0..9999, name:'元旦'},
    {month:1,  day:15,          term:   0..1999, name:'成人の日'},
    {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
    {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
    {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
    {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
    {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
    {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
    {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
    {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
    {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
    {month:7,  day:20,          term:1996..2002, name:'海の日'},
    {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
    {month:8,  day:11,          term:2016..9999, name:'山の日'},
    {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
    {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
    {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
    {month:10, day:10,          term:1966..1999, name:'体育の日'},
    {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
    {month:11, day:3,           term:   0..9999, name:'文化の日'},
    {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
    {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
  ]

  def initialize(y)
    # 有効な祝日を取り出し、日付を追加する
    enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
      case h[:day]
      when Fixnum
        {date: Date.new(y, h[:month], h[:day])}.merge(h)
      when String
        # {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
        method, argument = *h[:day].split
        eval("{date:Date.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
      end
    end

    enable_dates = enable_holidays.map {|h| h[:date]}

    # 振替休日を判定
    enable_dates.each do |date|
      if date.wday == 0
        while enable_dates.include?(date)
          date += 1
        end
        enable_holidays << {date:date, name:'振替休日'}
      end
    end

    # 国民の休日を判定
    enable_dates.each_cons(2) do |a, b|
      if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
        enable_holidays << {date:a + 1, name:'国民の休日'}
      end
    end

    @holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
  end
  
  def lookup(*args)
    case args.first
    when Fixnum
      @holidays_database.assoc(Date.new(*args))
    when Date
      @holidays_database.assoc(*args)
    when String
      @holidays_database.assoc(Date.parse(*args))
    end
  end
end # class JPHoliday

holiday = JPHoliday.new(2015)
p holiday
puts
p holiday.lookup(2015, 1, 1)
p holiday.lookup(Date.new(2015, 1, 1))
p holiday.lookup('2015-1-1')
  • lookupメソッドも追加して、日付から祝日情報を取得できるようにした。
  • (2015, 1, 1)やDateオブジェクト、("2015-1-1")など、いろいろな日付指定ができる。
$ ruby -W0 jcal_test.rb 
#, "元旦"], [#, "成人の日"], [#, "建国記念日"], [#, "春分の日"], [#, "昭和の日"], [#, "憲法記念日"], [#, "みどりの日"], [#, "こどもの日"], [#, "振替休日"], [#, "海の日"], [#, "敬老の日"], [#, "国民の休日"], [#, "秋分の日"], [#, "体育の日"], [#, "文化の日"], [#, "勤労感謝の日"], [#, "天皇誕生日"]]>

[#, "元旦"]
[#, "元旦"]
[#, "元旦"]

すべてをmoduleにまとめる

  • さらに、依存するDateクラスの拡張もまとめて、すべてをJPCalendarモジュールとしてまとめたくなった。
#!/usr/bin/ruby -W0

module JPCalendar
  require 'date'

  module DateEx
    refine Date do
      def monday(w)
        self + 7 * w.to_i - ((self - 1).wday + 6) % 7 - 1
      end

      def spring_day
        dy = self.year - 1900
        Date.new(self.year, 3, (21.4471 + 0.242377*dy - dy/4).to_i)
      end

      def autumn_day
        dy = self.year - 1900
        Date.new(self.year, 9, (23.8896 + 0.242032*dy - dy/4).to_i)
      end
    end
  end
  using DateEx

  class JPHoliday
    HOLIDAYS = [
      {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
      {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
      {month:11, day:12,          term:1990..1990, name:'即位の礼'},
      {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
      {month:1,  day:1,           term:   0..9999, name:'元旦'},
      {month:1,  day:15,          term:   0..1999, name:'成人の日'},
      {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
      {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
      {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
      {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
      {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
      {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
      {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
      {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
      {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
      {month:7,  day:20,          term:1996..2002, name:'海の日'},
      {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
      {month:8,  day:11,          term:2016..9999, name:'山の日'},
      {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
      {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
      {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
      {month:10, day:10,          term:1966..1999, name:'体育の日'},
      {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
      {month:11, day:3,           term:   0..9999, name:'文化の日'},
      {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
      {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
    ]

    def initialize(y)
      # 有効な祝日を取り出し、日付を追加する
      enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
        case h[:day]
        when Fixnum
          {date: Date.new(y, h[:month], h[:day])}.merge(h)
        when String
          # {date: Date.new(y, h[:month]).send(*h[:day].split)}.merge(h)
          method, argument = *h[:day].split
          eval("{date:Date.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
        end
      end

      enable_dates = enable_holidays.map {|h| h[:date]}

      # 振替休日を判定
      enable_dates.each do |date|
        if date.wday == 0
          while enable_dates.include?(date)
            date += 1
          end
          enable_holidays << {date:date, name:'振替休日'}
        end
      end

      # 国民の休日を判定
      enable_dates.each_cons(2) do |a, b|
        if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
          enable_holidays << {date:a + 1, name:'国民の休日'}
        end
      end

      @holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
    end
  
    def lookup(*args)
      case args.first
      when Fixnum
        @holidays_database.assoc(Date.new(*args))
      when Date
        @holidays_database.assoc(*args)
      when String
        @holidays_database.assoc(Date.parse(*args))
      end
    end
  end # class JPHoliday
end

include JPCalendar

holiday = JPHoliday.new(2015)
p holiday
puts
p holiday.lookup(2015, 1, 1)
p holiday.lookup(Date.new(2015, 1, 1))
p holiday.lookup('2015-1-1')
  • しかし、エラーが発生...。
$ ruby -W0 jcal_test.rb 
jcal_test.rb:23:in `': undefined method `using' for JPCalendar:Module (NoMethodError)
	from jcal_test.rb:3:in `
'
  • どうやらRuby 2.0において、refineモジュールとusingは、module内では使えないようだ。
  • トップレベルのコードとして書いておく必要がある。
  • 試しに、refineモジュールとusingをJPCalendarモジュールの外(トップレベル)に出すと、正常に動いた。
  • どうするべきか暫し悩んだが、この場合、refineを無理に使う必要はないことに気付いた。
  • Dateクラスを継承したJPDateクラスを定義して、Dateの代わりにJPDateを使えばいいのだ!
  • 新機能を使いたいが為に、本来からある便利な書き方を忘れていた。さっそく書き直してみる。
#!/usr/bin/ruby -W0

module JPCalendar
  require 'date'

  class JPDate < Date
    def monday(w)
      self + 7 * w.to_i - ((self - 1).wday + 6) % 7 - 1
    end

    def spring_day
      dy = self.year - 1900
      Date.new(self.year, 3, (21.4471 + 0.242377*dy - dy/4).to_i)
    end

    def autumn_day
      dy = self.year - 1900
      Date.new(self.year, 9, (23.8896 + 0.242032*dy - dy/4).to_i)
    end
  end

  class JPHoliday
    HOLIDAYS = [
      {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
      {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
      {month:11, day:12,          term:1990..1990, name:'即位の礼'},
      {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
      {month:1,  day:1,           term:   0..9999, name:'元旦'},
      {month:1,  day:15,          term:   0..1999, name:'成人の日'},
      {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
      {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
      {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
      {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
      {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
      {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
      {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
      {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
      {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
      {month:7,  day:20,          term:1996..2002, name:'海の日'},
      {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
      {month:8,  day:11,          term:2016..9999, name:'山の日'},
      {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
      {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
      {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
      {month:10, day:10,          term:1966..1999, name:'体育の日'},
      {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
      {month:11, day:3,           term:   0..9999, name:'文化の日'},
      {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
      {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
    ]

    def initialize(y)
      # 有効な祝日を取り出し、日付を追加する
      enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
        case h[:day]
        when Fixnum
          {date: Date.new(y, h[:month], h[:day])}.merge(h)
        when String
          {date: JPDate.new(y, h[:month]).send(*h[:day].split)}.merge(h)
          # method, argument = *h[:day].split
          # eval("{date:JPDate.new(y, h[:month]).#{method}(#{argument})}.merge(h)")
        end
      end

      enable_dates = enable_holidays.map {|h| h[:date]}

      # 振替休日を判定
      enable_dates.each do |date|
        if date.wday == 0
          while enable_dates.include?(date)
            date += 1
          end
          enable_holidays << {date:date, name:'振替休日'}
        end
      end

      # 国民の休日を判定
      enable_dates.each_cons(2) do |a, b|
        if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
          enable_holidays << {date:a + 1, name:'国民の休日'}
        end
      end

      @holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
    end
  
    def lookup(*args)
      case args.first
      when Fixnum
        @holidays_database.assoc(Date.new(*args))
      when Date
        @holidays_database.assoc(*args)
      when String
        @holidays_database.assoc(Date.parse(*args))
      end
    end
  end # class JPHoliday
end

include JPCalendar

holiday = JPHoliday.new(2015)
p holiday
puts
p holiday.lookup(2015, 1, 1)
p holiday.lookup(Date.new(2015, 1, 1))
p holiday.lookup('2015-1-1')
  • クラス継承するなら、eval使ってた部分も、sendで大丈夫。
    • 以前refineで拡張しても、sendに対しては効果ないので、evalで書き直した部分。
{date: JPDate.new(y, h[:month]).send(*h[:day].split)}.merge(h)
# method, argument = *h[:day].split
# eval("{date:JPDate.new(y, h[:month]).#{method}(#{argument})}.merge(h)")

これで日本の祝日を素早く検索できるようになった!

カレンダー出力

  • あとは好みのカレンダー書式を作って、JPCalendarモジュールを利用して祝日情報を取得すればいいのだ。
...中略...
include JPCalendar

def matrix_cal(y, m)
  start_date = Date.new(y, m) - Date.new(y, m).wday
  end_date   = Date.new(y, m, -1) + (6 - Date.new(y, m, -1).wday)
  date_list = start_date..end_date
  holiday = JPHoliday.new(y)
  
  date_list.each_slice(7) do |week|
    week.each do |date|
      print  holiday.lookup(date).last.rjust(14) rescue print ' ' * 14
      printf "%2d", date.day
    end
    puts
  end
end

matrix_cal(2014, 9)
  • しかし、なぜか書式が崩れる...。


  • その原因は、rjust(14)が全角文字も1文字と判定してるためであった。
    • 文字列.rjust(14)は、文字列を14文字幅で右寄せするメソッド。
  • この場合、全角文字は2文字と解釈して欲しい。
  • ならば、Stringクラスを拡張して、rjust_jaメソッドを追加してみた。
    • いよいよ、refineの出番である。
    • ついでに、ljust_ja、center_jaも追加しておいた。
...中略...
include JPCalendar

module StringEx
  refine String do
    def length_ja
      half_lenght = count(" -~")
      full_length = (length - half_lenght) * 2
      half_lenght + full_length
    end
  
    def ljust_ja(width, padstr=' ')
      n = [0, width - length_ja].max
      self + padstr * n
    end
  
    def rjust_ja(width, padstr=' ')
      n = [0, width - length_ja].max
      padstr * n + self
    end
  
    def center_ja(width, padstr=' ')
      n = [0, width - length_ja].max
      padstr * (n/2) + self
    end
  end
end
using StringEx

def matrix_cal(y, m)
  start_date = Date.new(y, m) - Date.new(y, m).wday
  end_date   = Date.new(y, m, -1) + (6 - Date.new(y, m, -1).wday)
  date_list = start_date..end_date
  holiday = JPHoliday.new(y)
  
  date_list.each_slice(7) do |week|
    week.each do |date|
      print  holiday.lookup(date).last.rjust_ja(14) rescue print ' ' * 14
      printf "%2d", date.day
    end
    puts
  end
end

matrix_cal(2014, 9)
  • 今度は、ずれることなく表示された!


色を付ける

  • さらに、カレンダーらしくカラー表示したい。イメージとしては...
  • 土曜は水色、日曜祝日は赤、今日の日付は反転表示、前月翌月は灰色。
  • ターミナルでは、色の制御コードと共に出力することで、カラー表示できる。
$ ruby -e 'puts "\e[31mRed"'
Red
  • つまり、"\e[31m"が色の制御コード(テキスト属性)。
  • 数値の31が赤を意味する。
    • 制御コード以降の文字が指定された色で表示される。
    • "\e[0m"で、すべての制御コードを解除する(リセット)
    • セミコロンで区切って複数の数値を指定することもできる。
    • 例:Red(赤)とBold(太字)
$ ruby -e 'puts "\e[31;1mRed"'
Red
  • 制御コードの数値の一覧は...
Text attributes Foreground colors Background colors
0 attributes off(リセット) 30 Black 40 Black
1 Bold(太字) 31 Red 41 Red
2 Low intensity(減光) 32 Green 42 Green
3 33 Yellow 43 Yellow
4 Underscore(下線) 34 Blue 44 Blue
5 Blink(点滅) 35 Magenta 45 Magenta
6 36 Cyan 46 Cyan
7 Reverse(反転) 37 White 47 White
8 Invisible text(非表示)
  • 以上の仕組みを利用して、カラー表示してみた。
...中略...
def matrix_cal(y, m)
  start_date = Date.new(y, m) - Date.new(y, m).wday
  end_date   = Date.new(y, m, -1) + (6 - Date.new(y, m, -1).wday)
  date_list = start_date..end_date
  holiday = JPHoliday.new(y)
  
  date_list.each_slice(7) do |week|
    week.each do |date|
      today_marker = date == Date.today ? "\e[7m" : ''
      holiday_name = holiday.lookup(date).last.rjust_ja(14) rescue ' ' * 14
      case
      when date.month != m
        printf "\e[37m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when holiday.lookup(date)
        printf "\e[31m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when date.wday == 0
        printf "\e[31m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when date.wday == 6
        printf "\e[36m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      else
        printf       "%s%s%2d\e[0m", holiday_name, today_marker, date.day
      end
    end
    puts
  end
  puts
end

matrix_cal(2014, 9)

  • 年月のタイトルと曜日のヘッダーを付けてみた。
...中略...
def matrix_cal(y, m)
  start_date = Date.new(y, m) - Date.new(y, m).wday
  end_date   = Date.new(y, m, -1) + (6 - Date.new(y, m, -1).wday)
  date_list = start_date..end_date
  holiday = JPHoliday.new(y)

  puts sprintf("%4d年 %2d月", y, m).center_ja(16 * 7)
  header = %w(日 月 火 水 木 金 土).map {|s| s.rjust_ja(16)}
  header[0] = "\e[31m" + header[0] + "\e[0m"
  header[6] = "\e[36m" + header[6] + "\e[0m"
  print header.join, "\n"
  
  date_list.each_slice(7) do |week|
    week.each do |date|
      today_marker = date == Date.today ? "\e[7m" : ''
      holiday_name = holiday.lookup(date).last.rjust_ja(14) rescue ' ' * 14
      case
      when date.month != m
        printf "\e[37m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when holiday.lookup(date)
        printf "\e[31m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when date.wday == 0
        printf "\e[31m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      when date.wday == 6
        printf "\e[36m%s%s%2d\e[0m", holiday_name, today_marker, date.day
      else
        printf       "%s%s%2d\e[0m", holiday_name, today_marker, date.day
      end
    end
    puts
  end
  puts
end

matrix_cal(2014, 9)

  • 日本の祝日も表示するカレンダーは出力できるようになったけど、まだコマンドになっていない...。
    • 長くなり過ぎたので、続きは後日。