#!/usr/bin/env ruby
#= RsyncBackup
#
# Developers:: Morikawa Yasuhiro
# Version::    2007-09-28 21:18:31
#
#== Overview
#
#ローカルのファイルを rsync で他のサーバ (ローカルでも良い) へ転送する
#ためのクラス。初期化 (RsyncBackup.new)の 際に転送元と転送先の「基」と
#なるディレクトリとホストを指定する。そのまま RsyncBackup.run メソッド
#を実行すればそのディレクトリをそのまま rsync するが、RsyncBackup.partly
#の引数 *dir にディレクトリ名を渡すことで、その部分だけを rsync すること
#が可能である。
#
#その他、様々なメソッドを利用することで、rsync に有効なオプションの
#指定が可能である。
#
#ソース一番下の Main Routine の部分にサンプルが用意してある。
#
#== Usage
#
#     % rsync_backup.rb [options] [dir [dir ...]]
#
#=== Options
#
#   --n                : for test (not exec "rsync")
#   --check            : for test (exec "rsync" with "-n" option)
#   --delete           : add "--delete" option
#   --nodelete         : remove "--delete" option
#   --logauto          : log to appropriate logfile
#   --log <logfile>    : output rsync command messages to <logfile>
#
#== Error Handling
#
#* RsyncBackup.new を参照のこと。
#
#== Known Bugs
#
#* 今のところ、特に無し
#
#== Future Plans
#
#* 日本語ファイル名を rsync のパスとして用いる場合には未対応
#
#* パスワードは毎回入力するようになっている。RsyncBackup.partly
#  で複数のディレクトリ
#  を指定する場合には面倒かもしれない。
#
#== Note
#
#サンプルとして実行部分もあるが、基本的にはライブラリとしての利用を
#想定している。
#
#== History
#
#* 2008/08/27 23:19:11
#  * デフォルトのオプションに P (--progress (転送の進捗状況を表示), 
#    --partial (リジューム可能)) を付加. 
#
#* 2005/06/14 20:04:41
#  * ユーザ名指定用のメソッド RsyncBackup.username を作成.
#  * pathname.rb さえ持ってこれば ruby1.6 でも動くよう,
#    String.new を "" に変更する.
#
#* 2005/06/14 15:39:56
#  * インスタンス変数のいくつかにコメントを追加
#
#* 2005/03/21 18:17:29
#  * rdoc 用にコメントの形式を改訂
#
#* 2005/03/21 02:11:26
#  * オブジェクトが String で、且つ空白でない事を調べるための
#    RsyncBackup.str_and_notspace? メソッド作成
#  * オブジェクトが Array で、且つゼロ配列でない事を調べるための
#    RsyncBackup.array_and_notzero? メソッド作成
#  * RsyncBackup.new メソッドで、srcdir または dstdir が String でないか、
#    空文字の場合のエラーの種類を ArgumentError にするようにした。
#  * RsyncBackup.new メソッドで、srchost と dsthost の両方が有効で
#    ある場合にはエラーを返すようにした。(rsync ではリモートから
#    リモートへの転送をサポートしていないので)
#  * RsyncBackup.excludeopt メソッドを上記の 
#    RsyncBackup.str_and_notspace? メソッドと
#    RsyncBackup.array_and_notzero? を利用して簡単に記載。
#  * RsyncBackup.partly メソッドを改良し、リモート→ローカル も 
#    ローカル→リモートの際も両方ともまっとうに動作するようにした。
#
#* 2005/03/21 01:23:23
#  * RsyncBackup.excludeopt メソッドをバグフィックスし、まともに
#    動くようにした。(カレントディレクトリは関係なく、rsync の src 
#    が関係あったので)。 その際、操作の大半を partly メソッドに移行。
#
#* 2005/03/20 00:27:10
#  * RsyncBackup.excludeopt メソッドを改良し、カレントディレクトリから判定し、
#    rsync にとって有効な文字列に整形するようにした。
#
#  * RsyncBackup.partly に文字列を与えた際、スラッシュが2重に付くバグを修正。
#
#* 2005/03/13 03:23:56
#  * 空白が含まれたパスでも rsync が正しく解釈できるよう、
#    RsyncBackup.merge_dir_host_user メソッドを改良。
#
#* 2005/03/12 12:24:35
#  * メソッド RsyncBackup.excludeopt を作成し、exclude オプションを有効にした。
#
#* 2005/03/07 03:56:35
#  * 一度構造を見直して作り変えた
#
#* 2005/03/06  Morikawa    Create
#
##################################################

require "pathname"        # for パス名クラス
require "getoptlong"      # for option_parse

############################################################################

#
#== RsyncBackup
#
# Rsync によるバックアップを行うためのクラス。
#
#
class RsyncBackup

  # 転送元のディレクトリ. RsyncBackup.new で指定 (必須)
  attr_reader :srcdir

  # 転送元のホスト. RsyncBackup.new で指定 (指定しない場合, ローカルとなる)
  attr_reader :srchost

  # 転送先のディレクトリ. RsyncBackup.new で指定 (必須)
  attr_reader :dstdir

  # 転送先のホスト. RsyncBackup.new で指定 (指定しない場合, ローカルとなる)
  attr_reader :dsthost
  #
  # srcdir と srchost に転送元のディレクトリとホストを指定する。
  # dstdir と dsthost に転送先のディレクトリとホストを指定する。
  # srchost, dsthost は、指定されない場合はローカルだと仮定する。
  # srcdir と dstdir を指定しない場合にはエラーを発生させる。
  #
  def initialize(srcdir=nil, srchost=nil, dstdir=nil, dsthost=nil)
    @srcdir   = srcdir
    @srchost  = srchost
    @dstdir   = dstdir
    @dsthost  = dsthost
    @optsbyuser = ""
    @rsync_com_name = "rsync"

    # @srcdir, @dstdir が文字変数でない or 空白の場合はエラー
    if !str_and_notspace?(@srcdir) then
      raise ArgumentError, "\"srcdir\" is not specified.\n"
    end
    if !str_and_notspace?(@dstdir) then
      raise ArgumentError, "\"dstdir\" is not specified.\n"
    end

    # @srchost と @dsthost の両方が有効な場合にはエラー
    if str_and_notspace?(@srchost) && str_and_notspace?(@dsthost) then
      raise ArgumentError, "rsync is not supported to transfer from remote src to remote destination.\n"
    end

    debug(@srcdir, @srchost, @dstdir, @dsthost)

    self.noexec(nil)
    self.logfile(nil, true)
    self.rsh
    self.checkopt(nil)
    self.deleteopt(nil)
    self.excludeopt(nil, true)
    self.username(nil)
    self.partly
  end

  # デバッグ出力用メソッド。組み込み関数 $DEBUG が真の場合 (つまり、
  # プログラムを $ ruby -d ./xxxxxx.rb として呼び出した場合) に
  # debug メソッドに代入された変数を出力する。
  #
  def debug(*args)
    p [caller.first, *args] if $DEBUG
  end
  private :debug

  attr_reader :optsbyuser
  #
  # 利用者による追加オプション. 動作チェックは行わない
  #
  def optsbyuser(opts)
    @optsbyuser = opts
  end

  attr_reader :rsync_com_name
  #
  # 利用者による rsync コマンド名の変更. 動作チェックは行わない
  #
  def rsync_com_name(com)
    return if !com
    return if com.empty?
    @rsync_com_name = com
  end

  attr_reader :noexec
  #
  # コマンドとして渡す文字列を出力するに留め、実際には rsync を動作させなく
  # するためのメソッド。引数に nil を渡せば、rsync を動作するようにできる。
  #
  def noexec(noexec=true)
    @noexec = noexec

    debug(@noexec)
  end

  attr_reader :logfile_output
  #
  # ログをファイルに書き出すためのメソッド、引数 logfile に nil を渡すと
  # ログは標準出力に書き出される。文字列を与えると、そのファイルに対して
  # ログを書き出すようになる。文字列ではなく、且つ true を渡すと、
  # 自動的に /tmp/以下に実行ファイルとプロセス ID を組み合わせた
  # ファイルに書き出す。
  #
  # デフォルトではログは以前のファイルを上書きするが、overwrite に nil を
  # 与えると、以前のものに追記するようになる。
  #
  def logfile(logfile=true, overwrite=true)
    redirect = (overwrite) ? ">" : ">>"

    if logfile.instance_of?(String) && /\w+/ =~ logfile.chomp then
      @logfile_output = redirect.strip + " " + logfile.chomp.strip
    elsif logfile then
      @logfile_output =  redirect.strip + " "
      @logfile_output << "/tmp/" + File.basename($0.to_s) + "-" + $$.to_s
    else
      @logfile_output = " "
    end

    debug(@logfile_output)
  end

  attr_reader :rsync_opt_rsh
  #
  # rsync の転送方法を指定するためのメソッド。何も指定しない場合は
  # "-e ssh" が用いられる。もしも RsyncBackup.new にて設定した
  # @srchost と @dsthost が両方とも無効 (文字列で無い、または
  # 空文字) である場合には効果を発揮しない。
  #
  def rsh(rsync_rsh=nil)
    if rsync_rsh then
      @rsync_opt_rsh = rsync_rsh
    elsif !@srchost && !@dsthost
      @rsync_opt_rsh = " "
    else
      @rsync_opt_rsh = "-e ssh"
    end

    debug(@rsync_opt_rsh)
  end

  #
  # rsync の "-n" オプションをつけるためのフラグ.
  # RsyncBackup.checkopt メソッドで指定できる.
  #
  attr_reader :rsync_opt_check
  #
  # rsync に "-n" オプションをつけて動作させるためのメソッド。
  # これにより、転送するファイルやディレクトリの一覧を
  # 表示するだけで、実際にはデータの転送を行わないようにする。
  #
  # check に nil を与えると "-n" オプションをはずす。
  #
  def checkopt(check=true)
    @rsync_opt_check = (check) ? "-n" : " "

    debug(@rsync_opt_check)
  end

  attr_reader :rsync_opt_delete
  #
  # rsync に "--delete" オプションをつけて動作させるためのメソッド。
  # このオプションにより、転送元に無くて転送先に存在するファイルや
  # ディレクトリを消去するように動作する。
  #
  # delete に nil を与えると "--delete" オプションをはずす。
  #
  def deleteopt(delete=true)
    @rsync_opt_delete = (delete) ? "--delete" : " "

    debug(@rsync_opt_delete)
  end

  # rsync の --exclude オプションの対象となるリスト.
  # RsyncBackup.excludeopt で指定できる.
  #
  attr_reader :exclude_list
  #
  # rsync に "--exclude" オプションをつけて動作させるためのメソッド。
  # このオプションにより、一部のファイルを転送対象からはずすことができる。
  # 複数回呼ぶことが可能である。第１引数には転送対象からはずしたい
  # ファイル名を書く。第２引数に true を与えると今まで与えたファイル名
  # がリストからクリアされる。
  #
  # ただし、有効にするには、このメソッドを呼んだ後、必ず
  # RsyncBackup.partly メソッドを呼ぶ必要がある (引数は nil でも良い)
  # ので注意すること。
  #
  def excludeopt(exclude=false, clear=false)
    if clear then
      @exclude_list = Array.new
    end

    if str_and_notspace?(exclude) then
      @exclude_list.push(exclude)
    end

    debug(@exclude_list)
  end


  # ユーザ名. デフォルトは nil で, その場合には rsync コマンドに
  # ユーザを指定しない.
  #
  attr_reader :user
  #
  # rsync にユーザ名を指定するためのメソッド. user に文字型で且つ空白
  # のみでない値を与えた場合のみ, 有効になる.
  #
  def username(user=false)
    if str_and_notspace?(user) then
      @user = user.chomp.strip
    else
      @user = nil
    end
    debug(@user)
  end

  attr_reader :src_dst_hash, :src_exclude_hash
  #
  # 一部だけを転送するためのメソッド。
  #
  # dirs にディレクトリ名を渡すと、RsyncBackup.new で srcdir に指定した
  # ディレクトリをそのまま転送するのではなく、その一部分のみを
  # 転送するようにする。重複するものは無視される。
  # srcdir 以下のファイルやディレクトリの数が多くなってきた場合に、
  # dirs にディレクトリを指定して個別にバックアップを行うことを想定
  # している。
  # 現在の仕様では、複数回 partly を用いた場合、最後の一回のみが
  # 有効になるようになっている。
  #
  def partly(*dirs)
    dirs.flatten!  # 配列の平滑化 (1次元配列化)
    dirs.delete_if{|dir| !dir.instance_of?(String)} # 文字列でないものは削除
    dirs.collect!{|dir| dir = dir.strip} # 前後の空白を除く
    dirs.collect!{|dir| dir = File.expand_path(dir)} # フルパスに変更
    dirs.uniq!                     # 重複を無くす

    @src_dst_hash     = Hash.new
    @src_exclude_hash = Hash.new
    if (dirs.size < 1) then
      src = merge_dir_host_user(@srcdir, @srchost, @user)
      dst = merge_dir_host_user(@dstdir, @dsthost, @user)
      @src_dst_hash.store(src, dst)

      base_dir = (!str_and_notspace?(@srchost)) ? @srcdir : @dstdir
      rsync_opt_exclude = gen_rsync_opt_exclude(base_dir, @exclude_list)
      @src_exclude_hash.store(src, rsync_opt_exclude)

      debug(@src_dst_hash)
      debug(@src_exclude_hash)
    else
      dirs.each{|directory|
        debug(directory)
        # directory が @srcdir と同じであれば上記設定をして next
        if File.expand_path(@srcdir) == File.expand_path(directory) then
          src = merge_dir_host_user(@srcdir, @srchost, @user)
          dst = merge_dir_host_user(@dstdir, @dsthost, @user)
          @src_dst_hash.store(src, dst)

          base_dir = (!str_and_notspace?(@srchost)) ? @srcdir : @dstdir
          rsync_opt_exclude = gen_rsync_opt_exclude(base_dir, @exclude_list)
          @src_exclude_hash.store(src, rsync_opt_exclude)

          debug(@src_dst_hash)
          debug(@src_exclude_hash)
          next
        end

        # directory が @srcdir を異なる際の動作
        splited = split_base_dir(@srcdir, directory)
        unless splited then
          print "\"#{directory}\" is not included \"#{@srcdir}\".\n"
          next
        end

        src = merge_dir_host_user(File.join(@srcdir, splited), @srchost, @user)
        dst = merge_dir_host_user(File.join(@dstdir, splited), @dsthost, @user)

        @src_dst_hash.store(src, dst)

        rsync_opt_exclude = gen_rsync_opt_exclude(directory, @exclude_list)
        @src_exclude_hash.store(src, rsync_opt_exclude)
      }
      debug(@src_dst_hash)
      debug(@src_exclude_hash)
    end
  end

  #
  # @src_dst_hash に対して rsync コマンドを実行するメソッド。
  # 最終的に呼ばれるべきメソッドである。
  #
  def run(cmd=nil)
    cmdstr_base = (cmd) ? cmd : "/usr/bin/env #{@rsync_com_name} "
    cmdstr_base << "#{@rsync_opt_check} "
    cmdstr_base << "#{@rsync_opt_delete} "
    cmdstr_base << "-avP "
    cmdstr_base << "#{@rsync_opt_rsh} "
    cmdstr_base << " #{@optsbyuser} "

    @src_dst_hash.each{|src, dst|
      cmdstr =  ""              # for ruby1.6
#      cmdstr =  String.new     # for ruby1.8
      cmdstr << cmdstr_base
      cmdstr << " #{@src_exclude_hash[src]}"
      cmdstr << " #{src} #{dst}"
      cmdstr << " #{logfile_output}"

      debug(cmdstr)

      print "#{cmdstr}\n"
      system "#{cmdstr}" unless @noexec
    }
  end

  #
  # ディレクトリ名 dir, ホスト名 host, ユーザ名 user を
  # "user@host:dir/" という rsync で用いる形式にマージする。
  # ディレクトリ名が無ければ nil を返すが、host や user が
  # 無い場合にはそれぞれの部分が無いパスを返す。
  # なお、ディレクトリの最後にスラッシュ "/" を付け加えていることに注意
  # されたし。これは、rsync の src に指定する際に、スラッシュの有無が
  # 動作を大きく変えるためである。詳しくは rsync(1) を参照せよ。
  #
  # ファイル名やディレクトリ名に空白が入っている場合、
  # そのディレクトリ名を rsync が正しく解釈できるよう整形する。
  #
  def merge_dir_host_user(dir=nil, host=nil, user=nil)
    debug(dir, host, user)
    return nil unless dir.instance_of?(String)

    unless host.instance_of?(String) then
      if /\w+\s+\w+/ =~ dir.strip then
        formed_dir = "\"" + File.expand_path(dir) + "/\""
      else
        formed_dir = dir.strip + "/"
      end

      debug(formed_dir)
      return formed_dir
    end

    if host.instance_of?(String) then
      if /\w+\s+\w+/ =~ dir.strip then
        formed_dir = "\"" + dir.strip.gsub(/\s/, '\ ') + "/\""
      else
        formed_dir = dir.strip + "/"
      end
    end

    url = host.strip + ":" + formed_dir
    debug(url)
    return url unless user
    url = user.strip + "@" + url
    debug(url)
    return url
  end
  private :merge_dir_host_user

  #
  # dir から base 部分を除いた部分を返す。dir がカレントパスで
  # 記述されていたり、"~" 記法を用いていても自動的に展開する。
  # もしも dir が base 以下に存在しないディレクトリの場合は nil を返す。
  #
  def split_base_dir(base=nil, dir=nil)
    debug("base=#{base}, dir=#{dir}", dir)

    return nil unless dir
    fulldir  = File.expand_path(dir)
    debug("fulldir=#{fulldir}")

    return fulldir unless base
    fullbase = File.expand_path(base)
    debug("fullbase=#{fullbase}")

    if /^#{fullbase}\/(.*)$/ =~ fulldir
      splited = $1
      debug("splited=#{splited}")
      return splited

    elsif /^#{fullbase}$/ =~ fulldir
      debug("splited=")
      return ""

    else
      debug("splited=", nil)
      return nil
    end
  end
  private :split_base_dir

  #
  # 2つ目の引数 (Array オブジェクト) の各パスを1つ目の引数 
  # (String オブジェクト) に合わせた文字列に整形し、最終的に
  # rsync のオプションとして渡す文字列を整形する。
  #
  def gen_rsync_opt_exclude(srcdir=nil, exclude_list=nil)
    debug("srcdir=#{srcdir}, exclude_list=", exclude_list)

    rsync_opt_exclude = ""

    # exclude_list が配列で無かったり、サイズが 0 だったら空白文字を返す
    if !array_and_notzero?(exclude_list) then
      return rsync_opt_exclude
    end

    if !str_and_notspace?(srcdir) then
      # srcdir が無効な場合の処理
      exclude_list.each{|exclude|
        rsync_opt_exclude << " --exclude #{exclude}"
      }
    else
      # srcdir が有効な場合
      exclude_list.each{|exclude|
        formed_exclude_path = Pathname.new(File.expand_path(exclude))
        expand_srcdir = Pathname.new(File.expand_path(srcdir))

        formed_exclude =
              formed_exclude_path.relative_path_from(expand_srcdir).to_str

        rsync_opt_exclude << " --exclude #{formed_exclude}"
      }
    end

    debug(rsync_opt_exclude)
    return rsync_opt_exclude

  end
  private :gen_rsync_opt_exclude


  #
  # 代入された変数が、文字列で、且つ空白文字のみではないことを
  # 調べるメソッド
  #
  def str_and_notspace?(obj)
    debug(obj)

    if obj.instance_of?(String) && /\w+/ =~ obj.chomp.strip then
      return true
    else
      return false
    end
  end
  private :str_and_notspace?


  #
  # 代入された変数が、配列で、且つゼロ配列ではないことを
  # 調べるメソッド
  #
  def array_and_notzero?(obj)
    debug(obj)

    if obj.instance_of?(Array) && obj.size > 0 then
      return true
    else
      return false
    end

  end
  private :array_and_notzero?


end

############################################################################


##################################################
## +++             Main Routine             +++ ##

## parse options
parser = GetoptLong.new
parser.set_options(
                   ###    global option   ###
                   ['--n',     GetoptLong::NO_ARGUMENT], # for test  (not exec "rsync")
                   ['--check', GetoptLong::NO_ARGUMENT], # for test (exec "rsync" with "-n" option)
                   ['--delete',GetoptLong::NO_ARGUMENT], # add "--delete" options
                   ['--nodelete',GetoptLong::NO_ARGUMENT], # remove "--delete" options
                   ['--logauto',GetoptLong::NO_ARGUMENT], # log to appropriate logfile
                   ['--log',   GetoptLong::REQUIRED_ARGUMENT] # log to <logfile>
                   )
begin
  parser.each_option do |name, arg|
    eval "$OPT_#{name.sub(/^--/, '').gsub(/-/, '_')} = '#{arg}'"  # strage option value to $OPT_val
  end
rescue
  exit(1)
end


if $0 == __FILE__

  rsyncitem = RsyncBackup.new("~/Windows", nil,
                              "~/work/tmp/Windows",
                              "dennou-h.gfd-dennou.org")
  rsyncitem.noexec($OPT_n)         if $OPT_n
  rsyncitem.logfile($OPT_logauto)  if $OPT_logauto
  rsyncitem.logfile($OPT_log)      if $OPT_log
  rsyncitem.rsh
  rsyncitem.checkopt($OPT_check)   if $OPT_check
  rsyncitem.deleteopt($OPT_delete) if $OPT_delete
  rsyncitem.deleteopt(!$OPT_delete) if $OPT_nodelete
  rsyncitem.partly(ARGV)           if ARGV.size > 0
  rsyncitem.run

end
