#!/usr/local/bin/ruby

=begin
RubyRED
The Ruby Remote Editor

Author:  Hal Fulton
         hal9000@hypermetrics.com

License: Ruby's
Version: 1.0 alpha
         16 May 2002

It's not truly an editor; it invokes the local editor you specify. It allows
the editing of remote files by (more or less transparently) downloading and
uploading files via FTP. 

It tries very hard to safeguard against overwriting "good" files with "bad"
ones, even to the point of experimentally determining the clock difference
between the two machines (including any time zone difference).

It does not handle binary files. As for text files, since FTP is used, the
"newline problem" between Windows and UNIX systems is handled automatically.

Right now, it has the clumsiest text interface imaginable. I plan to write a
Tk version soon. (This will hopefully be easier because I have encapsulated
the user interface methods in a module.) I do plan other GUI versions later,
perhaps as a way of comparing their look and feel.

A known problem on Windows: If you use Notepad as an editor, and the file is
too big (thus prompting you to invoke WordPad), the "system" call will NOT
block if you use WordPad. (Presumably Notepad is in effect "forking" the
WordPad app.) The fix: Why are you using Notepad anyway?

See also the rubyred.html file which is the only documentation.

=end

require 'net/ftp'
require 'singleton'

module UI_Tk

  require "tk"

  def ui_init
    @root = TkRoot.new { title "RubyRED" }

    getOptions unless readConfig

    start_session

    @fr1 = TkFrame.new(@root)
    getOptionsProc = proc { getOptions }
    quitProc = proc { end_session; exit }
    getOptProc = proc { getOptions }
    @btnOptions = TkButton.new(@fr1) do
      text "Options"
      command getOptProc
      pack("side"=>"left")
    end
    @btnQuit = TkButton.new(@fr1) do
      text "Quit"
      command quitProc
      pack("side"=>"left")
    end
    @fr2 = TkFrame.new(@root)
    newFilesProc = proc { fnameInit; newFiles }
    newLocalProc = proc { fnameInit; newLocal }
    newRemoteProc = proc { fnameInit; newRemote }
    existingFilesProc = proc { fnameInit; existingFiles }
    @btnEditNewFiles = TkButton.new(@fr2) do
      text "Edit: New Files      "
      font "Courier"
      command newFilesProc
      pack
    end
    @btnEditNewLocal = TkButton.new(@fr2) do
      text "Edit: New LOCAL File "
      font "Courier"
      command newLocalProc
      pack
    end
    @btnEditNewRemote = TkButton.new(@fr2) do
      text "Edit: New REMOTE File"
      font "Courier"
      command newRemoteProc
      pack
    end
    @btnEditExisting = TkButton.new(@fr2) do
      text "Edit: Existing Files "
      font "Courier"
      command existingFilesProc
      pack
    end
    @fr1.pack
    @fr2.pack
  end

  def fnameInit
    @fnameFrame = TkToplevel.new(@root)
    locvar = TkVariable.new
    remvar = TkVariable.new
    killFrame = proc { @fnameFrame.destroy }
    TkLabel.new(@fnameFrame) do
      text "Local file"
      pack("anchor"=>"w")
    end
    TkEntry.new(@fnameFrame) do
      width 20
      textvariable locvar
      bind "Return", killFrame
      pack("anchor"=>"w")
    end
    TkLabel.new(@fnameFrame) do
      text "Remote file"
      pack("anchor"=>"w")
    end
    TkEntry.new(@fnameFrame) do
      width 20
      textvariable remvar
      bind "Return", killFrame
      pack("anchor"=>"w")
    end
    @fnameFrame.wait_destroy
    @local = locvar.value
    @remote = remvar.value
  end

  def getOptions
    @optWin = TkToplevel.new(@root)
    fr1 = TkFrame.new(@optWin)
    fr2 = TkFrame.new(@optWin)
    fr3 = TkFrame.new(@optWin)
    fr4 = TkFrame.new(@optWin)
    fr5 = TkFrame.new(@optWin)
    fr6 = TkFrame.new(@optWin)
    fr7 = TkFrame.new(@optWin)
    # domain, user, pass, local, remote, editor
    domain = TkVariable.new
    user = TkVariable.new
    pass = TkVariable.new
    local = TkVariable.new
    remote = TkVariable.new
    edpath = TkVariable.new
    domain.value = @domain
    user.value = @user
    pass.value = @passwd
    local.value = @localDir
    remote.value = @remoteDir
    edpath.value = @editor
    killWin = proc { @optWin.destroy }
    TkLabel.new(fr1) { text "FTP site"; pack("anchor"=>"w") }
    TkEntry.new(fr1) do
      width 20
      textvariable domain
      pack("anchor"=>"w")
    end
    TkLabel.new(fr2) { text "User"; pack("anchor"=>"w") }
    TkEntry.new(fr2) do
      width 20
      textvariable user
      pack("anchor"=>"w")
    end
    TkLabel.new(fr3) { text "Password"; pack("anchor"=>"w") }
    TkEntry.new(fr3) do
      width 20
      textvariable pass
      pack("anchor"=>"w")
    end
    TkLabel.new(fr4) { text "Local dir"; pack("anchor"=>"w") }
    TkEntry.new(fr4) do
      width 20
      textvariable local
      pack("anchor"=>"w")
    end
    TkLabel.new(fr5) { text "Remote dir"; pack("anchor"=>"w") }
    TkEntry.new(fr5) do
      width 20
      textvariable remote
      pack("anchor"=>"w")
    end
    TkLabel.new(fr6) { text "Editor path"; pack("anchor"=>"w") }
    TkEntry.new(fr6) do
      width 20
      textvariable edpath
      pack("anchor"=>"w")
    end
    TkButton.new(fr7) do
      text "OK"
      command killWin
      bind "Return", killWin
      pack
    end
    fr1.pack
    fr2.pack
    fr3.pack
    fr4.pack
    fr5.pack
    fr6.pack
    fr7.pack

    @optWin.wait_destroy
    @domain = domain.value
    @user = user.value
    @passwd = pass.value
    @localDir = local.value
    @remoteDir = remote.value
    @editor = edpath.value
  end

  def ui_eventloop
    Tk.mainloop
  end

  def ui_notify_local_unchanged
  end

  def ui_notify_remote_newer
  end

  def ui_notify_uploading
  end

end

module UI_text

  def ui_init
    getOptions unless readConfig
    @cmd = nil
    mainMenu until @cmd =~ /Q/i
  end

  def ui_eventloop
  end

  def mainMenu
    puts
    puts "Main Menu"
    puts "  O  Options"
    puts "  E  Edit"
    puts "  Q  Quit"
    until @cmd =~ /^[OEQ]$/i
      print "Command = "
      @cmd = gets.chomp
    end
    case @cmd
      when /O/i
        optMenu until @cmd =~ /Q/i
        @cmd = nil
      when /E/i
        edMenu until @cmd =~ /Q/i
        @cmd = nil
    end
  end

  def optMenu
    puts
    puts "Options Menu"
    puts "  1  Set domain/user/passwd"
    puts "  2  Set default local dir"
    puts "  3  Set default remote dir"
    puts "  4  Set default editor path"
    puts "  S  Save options and quit"
    puts "  Q  Quit without saving options"
    until @cmd =~ /^[1234SQ]$/i
      print "Command = "
      @cmd = gets.chomp
    end
    case @cmd
      when "1"
        @domain = getfield("FTP site",@domain)
        @user = getfield("User",@user)
        if @user == "anonymous"
          @user = nil
        else
          @passwd = getfield("Password",@passwd)
        end
      when "2"
        @localDir = getfield("Local dir",@localDir)
      when "3"
        @remoteDir = getfield("Remote dir",@remoteDir)
      when "4"
        @editor = getfield("Editor path",@editor)
      when /S/i
        writeConfig
        @cmd = "Q"
      when /Q/i
        # Do nothing...
    end
    @cmd = nil unless @cmd =~ /Q/i
  end

  def edMenu
    puts
    puts "Edit Menu"
    puts "  1  New files"
    puts "  2  New LOCAL file"
    puts "  3  New REMOTE file"
    puts "  4  Existing files"
    puts "  Q  Quit"
    until @cmd =~ /^[1234Q]$/i
      print "Command = "
      @cmd = gets.chomp
    end
    if @cmd !~ /Q/i     # We ARE editing...
      @local = getfield("Local file")
      @remote = getfield("Remote file",@local)
    end
    begin
      start_session
      case @cmd
        when "1"
          newFiles
        when "2"
          newLocal
        when "3"
          newRemote
        when "4"
          existingFiles
        when /Q/i
          # Do nothing...
      end
    rescue Existence => errmsg
      puts
      puts errmsg
      puts
    ensure
      end_session
      @cmd = nil unless @cmd =~ /Q/i
    end
  end

  def getfield(prompt,default=nil)
    defstr = if default then "(Enter=#{default})" else "" end
    print "#{prompt} #{defstr}: "
    str = gets.chomp
    if default and str==""
      default
    else
      str
    end
  end

  def getOptions
    @domain = getfield("FTP site")
    @user = getfield("User","anonymous")
    if @user == "anonymous"
      @user = nil
    else
      @passwd = getfield("Password")
    end
    @localDir = getfield("Local dir",@localDir)
    @remoteDir = getfield("Remote dir",@remoteDir)
    @editor = getfield("Editor path",@editor)
    writeConfig
  end

  def ui_notify_local_unchanged
    puts "\n  Local file was unchanged!"
  end

  def ui_notify_remote_newer
    puts "\n  Remote file is newer... downloading."
  end

  def ui_notify_uploading
    puts "Uploading..."
  end

end

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

class Existence < Exception

end

class RubyRED

  include Singleton

  include UI_text
  # include UI_Tk

  def unix?
  end

  def initialize
    @domain = nil
    @user = "anonymous"
    @passwd = "nopass"
    @localDir = "."
    @remoteDir = "."
    @editor = "notepad.exe"
    @local = nil
    @locMod = nil   # Local file modification time
    @remote = nil
    ui_init
    ui_eventloop
  end

  def readConfig
    begin
      cfg = File.open("rubyred.cfg")
      @domain = cfg.gets.chomp
      @user = cfg.gets.chomp
      @passwd = cfg.gets.chomp
      @localDir = cfg.gets.chomp
      @remoteDir = cfg.gets.chomp
      @editor = cfg.gets.chomp
      cfg.close
    rescue
      return false
    end
    true
  end

  def writeConfig
    File.open("rubyred.cfg","w") do |cfg|
      cfg.puts @domain
      cfg.puts @user
      cfg.puts @passwd
      cfg.puts @localDir
      cfg.puts @remoteDir
      cfg.puts @editor
    end
  end

  def start_ftp
    args = [@domain]
    args << @user << @passwd unless @user == nil
    @ftp = Net::FTP.new(*args)
    @ftp.chdir(@remoteDir)   # check error later
  end

  def start_session
    start_ftp
    @saveDir = Dir.pwd
    Dir.chdir(@localDir)
  end

  def end_session
    @ftp.close
    Dir.chdir(@saveDir)
  end

  def upload
    ui_notify_uploading
    begin
      @ftp.puttextfile(@local, @remote)
    rescue
      if !tried
        tried = true
        start_ftp
        retry
      end
    end
  end

  def download
    tried = false
    begin
      @ftp.gettextfile(@remote, @local)
    rescue
      if !tried
        tried = true
        start_ftp
        retry
      end
    end
  end

  def edit
    @locMod = File.mtime(@local) if File.exist?(@local)
    system("#@editor #@local")
  end

  def unchanged   # Is the local file unchanged after editing?
    didntExist = (@locSize==nil)
    doesntExist = !File.exist?(@local)
    sizeSame = (File.size(@local)==@locSize) unless doesntExist
    timeSame = (File.mtime(@local)==@locMod) unless doesntExist
    flag = ((didntExist and doesntExist) or (sizeSame and timeSame))
    ui_notify_local_unchanged if flag
    flag
  end

  def mdtm2time(str)
    Time.local(str[0..3].to_i,    # year
               str[4..5].to_i,    # month
               str[6..7].to_i,    # day
               str[8..9].to_i,    # hour
               str[10..11].to_i,  # minute
               str[12..13].to_i,  # second
               0)                 # microsec
  end

  def remote_time
    if @ftp.dir(@remote)==[]
      result = nil
    else
      str = @ftp.mdtm(@remote)
      result = mdtm2time(str)
    end
    result
  end

  def clockCheck
    tempfile = "clk-skew"
    File.open(tempfile,"w") {|f| f.puts "xyz" } # Open-close
    @ftp.puttextfile(tempfile, tempfile)
    rmod = @ftp.mdtm(tempfile)
    lmod = File.mtime(tempfile)
    rmod = mdtm2time(rmod)
    skew = lmod - rmod
    # puts "skew = #{skew} seconds"
    @ftp.delete(tempfile)   # Remote
    File.delete(tempfile)  # Local
    skew
  end

  def check_existence(loc,rem)
    # Don't forget to save info for "unchanged"
    # And allow for a "still nonexistent" local file
    locExists = File.exist?(@local)
    @remTime = remote_time
    remExists = (@remTime != nil)
    @locSize = nil
    @locMod = nil
    # locCRC later...
    if locExists
      @locSize = File.size(@local)
      @locMod = File.mtime(@local)
    end
    # While we're here, check for "clock skew"
    @clock_offset = clockCheck   # client minus server
    @remAdjTime = nil
    if remExists
      @remAdjTime = @remTime + @clock_offset
    end
    err = ""
    case [loc,rem]
      when [:noLocal, :noRemote]
        err << "Local file #@local already exists.\n" if locExists
        err << "Remote file #@remote already exists.\n" if remExists
      when [:noLocal, :remote]
        err << "Local file #@local already exists.\n" if locExists
        err << "Remote file #@remote does not exist.\n" if !remExists
      when [:local, :noRemote]
        err << "Local file #@local does not exist.\n" if !locExists
        err << "Remote file #@remote already exists.\n" if remExists
      when [:local, :remote]
        err << "Local file #@local does not exist.\n" if !locExists
        err << "Remote file #@remote does not exist.\n" if !remExists
    end
    raise Existence.new(err) if err != ""
  end

  def newFiles
    check_existence(:noLocal, :noRemote)
    edit
    upload unless unchanged
  end

  def newLocal
    check_existence(:noLocal, :remote)
    download
    edit
    upload unless unchanged
  end

  def newRemote
    check_existence(:local, :noRemote)
    edit
    upload  # whether it's changed or not
  end

  def syncFiles
    if @remAdjTime > @locMod
      ui_notify_remote_newer
      download
    end
  end

  def existingFiles
    check_existence(:local, :remote)
    syncFiles
    edit
    upload unless unchanged
  end

end

RubyRED.instance