in-out, in-out (2)

そんなわけで、バックアップがてらコピペしてみる。

たぶん、Ruby 1.8 系列なら動くとは思うけど、微妙に Windows に依存しているというか環境変数名が Windows 用かも地味にバグフィックスするついでに Windows 以外でも対応しやすいようにしたつもりだけど厳密にやるのも面倒なので適当*1。コメントもないし変数名もほげほげであったりと微妙だけど、ま、そこそこ動けばいいやという気分。

特に需要もないだろうけど、一応、ライセンスは修正BSDみたいな感じ。

#!/usr/bin/env ruby
# Copyright (C) 2006 yyuu <peek824545+hatena@gmail.com>

require "csv"
require "parsedate"
require "shellwords"

class M2Entry
  def initialize(amount, time=Time.new)
    @amount  = amount
    @time    = time
    @tags    = Array.new
    @comment = ""
  end
  attr_accessor :amount
  attr_accessor :time
  attr_accessor :comment
  attr_reader   :tags

  def add_tag(tag)
    unless @tags.include?(tag)
      @tags.push(tag)
    else
      nil
    end
  end
  def add_tags(*tags)
    added = Array.new
    tags.each { |tag|
      if add_tag(tag)
        added.push(tag)
      end
    }
    added
  end

  def to_a()
    ary = Array.new
    ary[0] = time.strftime("%Y/%m/%d %X")
    ary[1] = amount
    ary[2] = @tags.join("/") unless @tags.empty?
    ary[3] = @comment
    ary
  end
  def to_s()
    sprintf("%s %-15s %6d \# %s",
              @time.strftime("%Y/%m/%d"),
              @tags.join("/"),
              @amount,
              @comment)
  end
end

class M2Store
  M2STORE_PATH = "."

  class M2StoreError < StandardError
  end
  class M2StoreDateError < M2StoreError
  end

  def initialize(time=nil)
    @store = Array.new
    @time  = time
    update
  end
# attr_accessor :time
  attr_reader :time
  def time=(time)
    @time = time
    update
    time
  end

  def bound?()
    @time && true
  end
  def bound_filename()
    if bound?
      File.join(M2STORE_PATH, @time.strftime("mm%Y%m.csv"))
    else
      nil
    end
  end

  def update()
    if bound?
      reload_db
    else
      true
    end
  end
  def reload()
    reload_db
  end
  def commit()
    write_db
  end

  def add_entry(entry)
    entry.is_a?(M2Entry) or raise("not my buisiness: #{e.class.to_s}")
    if @time and \
         (@time.year != entry.time.year or @time.mon != entry.time.mon)
      raise(M2DateError.new("month unmatch"))
    end
    @store.push(entry)
  end
  def to_a()
    @store.dup
  end
  def each(&block)
    @store.each(&block)
  end

  def |(other)
    @store | other.to_a
  end
  def &(other)
    @store & other.to_a
  end

  def match_tag(tag)
    matched = self.class.new
    @store.each { |entry|
      matched.add_entry(entry) if entry.tags.include?(tag)
    }
    matched
  end
  def match_tags(*tags)
#   matched = self.class.new
#   tags.each { |tag|
#     matched |= match_tag(tag)
#   }
#   matched
    car, *cdr = tags
    _store = match_tag(car)
    if cdr.empty?
      _store
    else
      _store.match_tags(cdr)
    end
  end
  def unmatch_tag(tag)
    unmatched = self.class.new
    @store.each { |entry|
      unmatched.add_entry(entry) unless entry.tags.include?(tag)
    }
    unmatched
  end
  def unmatch_tags(*tags)
    car, *cdr = tags
    _store = unmatch_tag(car)
    if cdr.empty?
      _store
    else
      _store.unmatch_tags(cdr)
    end
  end

  private
  def clear()
    @store.clear
  end
  def reload_db()
    clear
    fcount = 0
    begin
      CSV.open(bound_filename, "r") { |a|
        add_entry(a2e(a))
      }
    rescue Errno::ENOENT => e
      p(e)
      if fcount < 1
        fcount += 1
        File.open(bound_filename, "w").close
        retry
      end
    end
  end
  def write_db()
    fcount = 0
    begin
      CSV.open(bound_filename, "w") { |writer|
        @store.each { |entry|
          writer << e2a(entry)
        }
      }
    rescue Errno::ENOENT => e
      p(e)
      if fcount < 1
        fcount += 1
        File.open(bound_filename, "w").close
        retry
      end
    end
  end

  def a2e(ary)
    time    = Time.local(*ParseDate.parsedate(ary[0]))
    amount  = ary[1].to_i
    tags    = ary[2].split("/")
    comment = ary[3]

    entry = M2Entry.new(amount, time)
    entry.add_tags(*tags)
    entry.comment = comment

    entry
  end
  def e2a(entry)
    entry.to_a
  end
end

class M2
  COMMAND = Hash.new
  case RUBY_PLATFORM
  when /mswin32/
    USERNAME = ENV["USERNAME"].downcase
    HOSTNAME = ENV["COMPUTERNAME"].downcase
  when /linux/
    USERNAME = ENV["USERNAME"]
    HOSTNAME = ENV["HOSTNAME"]
  else
    USERNAME = "somebody"
    HOSTNAME = "somewhere"
  end

  def initialize()
    @m2store = M2Store.new(Time.new)
  end
  def start()
    pstr = sprintf("[%s@%s]$ ", USERNAME, HOSTNAME)
    loop {
      if line = prompt(pstr, true)
        if /(?!\\)\#(.*)$/ =~ line # line includes comments
          cmdline, comment = $`, $&
        else
          cmdline, comment = line, ""
        end
        sw = Shellwords.shellwords(cmdline)
        cmd, *args = sw

        if cmd and not(cmd.empty?)
          if has_command?(cmd)
            call_command(cmd, args)
          else
            STDERR.printf("Command not found: %s\n", cmd)
          end
        end

      end
    }
  end

  private
  begin
    require "readline"
    def prompt(pstr, history=true)
      return Readline.readline(pstr, history)
    end
  rescue LoadError => e
    def prompt(pstr, *rest)
      STDOUT.printf(pstr)
      STDOUT.flush
      return STDIN.gets
    end
  end
  def has_command?(cmd)
    get_command(cmd) && true
  end
  def call_command(cmd, args)
    get_command(cmd).call(args, @m2store)
  end
  def get_command(cmd)
    COMMAND[cmd]
  end
end

M2::COMMAND["exit"] = \
M2::COMMAND["quit"] = \
  Proc.new { |args, store|
    exit(0)
}
M2::COMMAND["cd"] = \
  Proc.new{ |args, store|
    unless args.empty?
      car, *cdr = args
      if /^((\d{4})\/)?(\d{1,2})$/ =~ car
          now  = Time.new
          year = ($1.nil?) ? now.year : $1.to_i
          mon  = $3.to_i
          if store.time.mon != mon
            store.time = Time.local(year, mon)
          end
      else
        STDERR.puts("No such directory: #{car}")
      end
    else
      time = Time.new
      if store.time.mon != time.mon
        store.time = time
      end
    end
}
M2::COMMAND["ls"] = \
M2::COMMAND["grep"] = \
  Proc.new { |args, store|
    _store = store

    unless args.empty?
      car, *cdr = args
      if /^((\d{4})\/)?(\d{1,2})$/ =~ car
        now  = Time.new
        year = ($1.nil?) ? now.year : $1.to_i
        mon  = $3.to_i
        if store.time.mon != mon
#         store.time = Time.local(year, mon)
          _store = M2Store.new(Time.local(year, mon))
        end
      else
        cdr.unshift(car)
      end

      tags_i = cdr.find_all { |e| e[0] == ?- } || [] # invert tag
      tags_n = cdr - tags_i # normal tag
      tags_i.collect! { |e| e[1..-1] }

      unless tags_n.empty?
        _store = _store.match_tags(*tags_n)
      end

      unless tags_i.empty?
        _store = store.unmatch_tags(*tags_i)
      end
    end

    _store.each { |entry|
      STDOUT.puts(entry)
    }
}
M2::COMMAND["calc"] = \
  Proc.new { |args, store|
    _store = store

    unless args.empty?
      car, *cdr = args
      if /^((\d{4})\/)?(\d{1,2})$/ =~ car
        now  = Time.new
        year = ($1.nil?) ? now.year : $1.to_i
        mon  = $3.to_i
        _store = M2Store.new(Time.local(year, mon))
      else
        cdr.unshift(car)
      end

      tags_i = cdr.find_all { |e| e[0] == ?- } || [] # invert tag
      tags_n = cdr - tags_i # normal tag
      tags_i.collect! { |e| e[1..-1] }

      unless tags_n.empty?
        _store = _store.match_tags(*tags_n)
      end

      unless tags_i.empty?
        _store = _store.unmatch_tags(*tags_i)
      end
    end

    sum = _store.to_a.inject(0) { |s, entry| s + entry.amount }
    STDOUT.puts(sum.to_s)
}
M2::COMMAND["add"] = \
M2::COMMAND["insert"] = \
  Proc.new { |args, store|

    amount  = nil
    comment = ""
    time    = Time.new
    tags    = Array.new

    while arg = args.shift
      case arg
      when /^--date$/, /^-d$/
        d_str = args.shift
        if /^((\d{1,2})\/)?(\d{1,2})$/ =~ d_str
          now  = Time.new
          mon = ($1.nil?) ? now.year : $1.to_i
          day  = $3.to_i
          time = Time.local(now.year, mon, day)
        else
          STDERR.puts("insert: invalid date format: #{d_str}")
        end
      when /^--tag$/,  /^-t$/
        t_str = args.shift
        tags |= t_str.split(/[,\/]/)
      when /^--comment$/, /^-c$/
        c_str = args.shift
        comment << c_str
      when /^\d+$/
        amount = arg.to_i
      else
        if amount.nil?
          tags |= [arg]
        else
          STDERR.puts("insert: unknown option -- #{arg}")
          break
        end
      end
    end

    unless amount.nil?
      entry = M2Entry.new(amount, time)
      entry.add_tags(*tags)
      entry.comment = comment

      begin
        store.add_entry(entry)
        store.commit # automatic commiting
      rescue M2DateError => e
        STDERR.puts("insert: out of directory")
      end
    else
      STDERR.puts("insert: amount not specified")
    end
}
M2::COMMAND["commit"] = \
  Proc.new { |args, store|
    store.commit
}
M2::COMMAND["du"] = \
M2::COMMAND["wc"] = \
  Proc.new { |args, store|
    table = Hash.new(0)
    store.each { |entry|
      entry.tags.each { |tag|
        sum = table[tag] || 0
        table[tag] += entry.amount
      }
    }
    table.sort { |a, b|
      b[1] <=> a[1]
    }.each { |e|
      STDOUT.printf("%10d %s\n", e[1], e[0])
    }
}
M2::COMMAND["hist"] = \
M2::COMMAND["histogram"] = \
  Proc.new { |args, store|
    table = Hash.new(0)
    store.each { |entry|
      entry.tags.each { |tag|
        sum = table[tag] || 0
        table[tag] += entry.amount
      }
    }
    table.sort { |a, b|
      b[1] <=> a[1]
    }.each { |e|
      STDOUT.printf("%10s %s\n", e[0], "#" * (e[1].to_f/500.0))
    }
}
M2::COMMAND["exec"] = \
  Proc.new { |args, store|
    system(args.join(" "))
}
M2::COMMAND["date"] = \
  Proc.new { |args, store|
    STDOUT.puts(store.time)
}


M2.new.start

*1:BSD 系は USERNAME じゃなくて LOGNAME なんだっけ?っつーか、シェルの問題か。