#!/usr/bin/ruby

=begin

FTP Sync allows synchronizing files on multiple client machines via an 
FTP server.
U wrote this because my web host does not run CVS. Additionally, 
I usually sync large binary files, and I need only the latest version. 
FTP Sync saves file information (version and timestamp) in an XML file 
which is stored on the FTP server alognside the file with content. 
The program compares the version number in the remote XML file, and 
the time stamp on the local XML file, figuring out whether to upload 
the local version or download the remote one.

Usage:
    ftp_sync.rb  "C:\dir\subdir\file.ext"

Configuration:
Edit the following files:
    config.ini   - specifies server info, and local root dir
    control.xml  - keeps version/timestamp for all files


CONFIG.INI

A typical 'config.ini' file looks like this:
----------------------------------------
host              = ftp.server.com
username          = john
password          = john_is_genius
remote_root       = my_proj/
remote_control    = my_proj/control.xml

local_root        = C:\John\projects

control_filename  = C:\John\projects\control.xml
temp_filename     = C:\John\projects\control.tmp.xml
----------------------------------------

It is read into a hash:

c = {
    'host'              => "ftp.server.com",
    'username'          => "john",
    'password'          => "john_is_genius",
    ...
}

The hash can be accessed either as c['host'] or c.host 



CONTROL.XML

<opt>
  <file time="Sat May 01 21:20:16 2004" version="16">my_file.bin</file>
  <file time="Sat May 01 21:20:16 2004" version="11">subdir/file.bin</file>
</opt>

    "version" is incremented every time the new version of the file is
uploaded. 
    "time" is time on the local machine, and it is compared
against the file modification time. Once on the remote server, "time"
has no meaning, because the server and other clients do NOT need to
have synchronized clocks and timezones.

Using xml-simple library, this file is read into a Hash-like object
which can be accessed as:

    i = doc.file.find{|x| x.content == 'my_file.bin' }
    i.time    == "Sat May 01 21:20:16 2004"
    i.version == "16"
    i.content == "my_file.bin"

=end




# require_local() loads a ruby file (just like 'require' does)
# but the argument is relative to the current script. This
# is default in Java import and C/C++ include, but in Ruby
# this function is needed.
def require_local file

    # WARNING: in the required file, __FILE__ == "./xyz.rb"

    wdir= Dir.getwd
    Dir.chdir File.dirname(__FILE__)
    require File.join('.',file)
    Dir.chdir wdir
end


require 'net/ftp'
require 'rexml/document'
require 'xmlsimple'
require 'ParseDate'

require_local 'utils.rb'


include ParseDate

#
# This script looks up config file located in the same
# directory as this script. Initially, I tried finding out
# the script deroctory by extracting the path from __FILE__.
# __FILE__ == "./ftp_sync.rb", but after the working directory
# changes, the path points to the current directory instead of the 
# directory with this script. This constant evaluates when the 
# script is loaded/required, and the full path is expanded and saved.
#
PROGRAM_DIR = File.expand_path(File.dirname(__FILE__))






SYNC_CONFLICT_ERROR_MESSAGE = <<EOM

 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
 
                E   R   R   O   R
                
 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

    BOTH REMOTE AND LOCAL FILES HAVE CHANGED ! 
    
    Cannot sync the files.

EOM


def ftp_sync local_filename
    # INI file lists server address, username, password, file locations
    ini_file = File.join(PROGRAM_DIR,'config.ini')
    c = read_ini_file ini_file
    
    c.local_filename  = local_filename
    c.filename        = relative_path(local_filename, c.local_root)
    c.remote_filename = File.join( c.remote_root , c.filename )
    

    Net::FTP.open(c.host,c.username,c.password){|ftp|
        sync_file(ftp, c)
    }
end




#
# sync_file() determines which file is newer (local or remote one)
#             and appropriately uploads or downloads the file.
#
#       ftp = FTP connection object (or a symulation of a connection)
#             Must support methods getbinaryfile() and putbinaryfile()
#       c   = Hash with values of filenames
#       c.filename        = path of the content file, relative to root dir
#       c.local_filename  = path of the content file on the local machine
#       c.remote_filename = path of the content file on the remote machine
#       c.control_filename= local XML file with version/timestamp info
#       c.remote_control  = remote path for the control file
#       c.temp_filename   = filename where remote control file is downloaded
#

def sync_file( ftp, c )

    last_time = get_timestamp c.control_filename, c.filename
    curr_time = File.new(c.local_filename).mtime

    modified_local_file = ( last_time <  curr_time )
    
    
    ftp.getbinaryfile c.remote_control, c.temp_filename
    
    last_ver   = get_last_version c.temp_filename,    c.filename
    curr_ver   = get_last_version c.control_filename, c.filename
    
    message "File "+(modified_local_file ?"":"not ")+"modified."
    message "  current file modified at: #{curr_time}"
    message "  last time modified at:    #{last_time}"
    message "  local  file version:      #{curr_ver}"
    message "  remote file version:      #{last_ver}"

    if      last_ver <= curr_ver 
        # The remote file is not more recent.
        # The remote file is either older or equal to the local file.
    
        if (last_ver == curr_ver)  and  not modified_local_file
            message "File not modified. Nothing to do."
        else
            message "File modified. Sending to server."
            increment_version c.control_filename, c.filename, c.local_filename
            ftp.putbinaryfile(c.local_filename, c.remote_filename){
                print "#"; $stdout.flush
            }
	    print "\n"
            ftp.putbinaryfile c.control_filename, c.remote_control
            message "File sent successfully."
        end
    
    else  # last_ver >  curr_ver
        # The remote file is more recent.
    
        if modified_local_file
            # WARNING: modified BOTH local and remote file !!!
            message SYNC_CONFLICT_ERROR_MESSAGE
            pause
        else
            message "Remote file newer. Downloading newer version."
            ftp.getbinaryfile c.remote_filename, c.local_filename {
                print "#"; $stdout.flush
            }
	    print "\n"
            ftp.getbinaryfile c.remote_control,  c.control_filename
            update_local_time c.control_filename, c.filename, c.local_filename
            message "File downloaded successfully."
        end

    end
end





def relative_path filename, path, raise_exception=true
    
    filename = filename.gsub('\\','/')
    path     = path.gsub('\\','/')
    
    if not path.ends_with? '/'
        path += '/'
    end
    
    
    if filename.begins_with? path
        return filename[ path.length .. -1 ]
    else
        if raise_exception
            raise "File not in the subpath"
        else
            return nil
        end
    end
end




#
# Reads from the control file 
# the local time when the content file was last modified.
#
def get_timestamp( control_file, content_file )
    doc = XmlSimple.xml_in control_file
    i = doc.file.find{|x| x.content == content_file }
    return Time.local( * parsedate(i.time))
end


def get_last_version( control_file, content_file )
    doc = XmlSimple.xml_in control_file
    i = doc.file.find{|x| x.content == content_file }
    return i.version.to_i
end

#
# control_file == "control.xml"
# filename     == "my_file.bin"
# content_file == "C:\temp\myfile.bin"
#
def increment_version( control_file, filename, content_file )
    doc = XmlSimple.xml_in control_file
    i = doc.file.find{|x| x.content == filename }
    raise "Entry for file '#{filename}' not found in '#{control_file}'" if i.nil?
    
    version =  i.version.to_i
    version += 1
    i.version = version.to_s
    
    i.time = file_time_string(content_file)
    
    File.open( control_file, "w" ){|f|   
        f << XmlSimple.xml_out(doc)  
    }
end

def update_local_time( control_file, filename, content_file )
    debug "Updating local time"
    doc = XmlSimple.xml_in control_file
    i = doc.file.find{|x| x.content == filename }
    raise "Entry for file '#{filename}' not found in '#{control_file}'" if i.nil?

    # Update local time in the control file, because the clock on the  
    # local machine is different from the remote clocks.
    i.time = file_time_string(content_file)
    
    File.open( control_file, "w" ){|f|   
        f << XmlSimple.xml_out(doc)  
    }
end

def file_time_string filename
    File.new(filename).mtime.asctime
end



if __FILE__ == $0
ftp_sync ARGV[0]
end


