#! /usr/bin/perl
#
# synctree - A directory tree synchronization tool.
#
# Copyright (C) 2004 Russ W. Knize
# 
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# e-mail: rknize@yahoo.com
#
# 10/07/2004 - rknize: Major cleanup, Debianize.
# 09/25/2004 - rknize: Support for Sorune
# 12/01/2003 - rknize: Rules file support
# 11/20/2003 - rknize: Added -n and -R options
# 11/14/2003 - rknize: Added -f option
# 11/05/2003 - rknize: Created
#
################################################################################

# Required by makemaker
my # newline required
    $VERSION = '0.1';

use POSIX;

use File::Spec;
require "synctree/std.pm";

$| = 1; # Set Autoflush


################################################################################
# Configuration
################################################################################
my $toolSorune = "sorune";
my $toolOggInfo = "ogginfo";
my $toolOggPeel = "";
my $toolOggEnc = "oggenc --quiet";
my $toolOggDec = "oggdec --quiet";
my $toolOggTag = "vorbiscomment";


################################################################################
# Globals and Defaults
################################################################################
my $scriptname = "synctree";
my $home = getHomePath();
my $configFile = glob("${home}/.${scriptname}rc");
my %rules;


################################################################################
# printUsage
################################################################################
sub printUsage
{
    print <<END_PRINT_USAGE
usage: $scriptname [options] [lpath [rpath]]

options: -d       : dry run; don't actually do anything; just print the command
         -D       : do not create directories if they are not present on 'rpath'
         -f       : compare the file contents as well
         -F       : force overwrite to 'rpath', even if they are the same
         -h       : display this message
         -n       : take the newest file instead of the file in 'lpath'
         -r=rules : follow the rules in file 'rules'
         -R       : do not recurse through directories
         -v       : enable verbose output         

Compare the directory tree 'lpath' to the tree 'rpath'.  By default, any files
that are missing from 'rpath' are copied over from 'lpath'.  Unless the '-D'
option is specified, any directories in 'lpath' that are missing in 'rpath' are
created.

If the '-f' option is specified, the files contents are also compared and any
in 'rpath' that are different from 'lpath' are overwritten with the files in 
'lpath'.  If the '-n' option is specified, the newest file is taken from one
side and copied to the other.

If a rules file is specified, the command line options will override any 
options set in the rules.  The 'lpath' and/or 'rpath' may also be specified
there.

END_PRINT_USAGE
}


################################################################################
# loadRules(file, config, ) - load a synchro rules file
#
#   file - path to the rules file
#   config - reference to rules hash
#
# The file format requires one key/value pair per line, seperated by an equal
# sign (=) or colon (:).  All leading and trailing white space is stripped
# unless they are enclosed by double quotes (").  The quotes themselves will be
# removed.  Each key/value pair is added to the hash.
#
# The special key "list" indicates a list of values to follow, one per line.
# A reference to the list is added to the hash, whose key is given the value
# provided after the "list" key.  The list is terminated by another "list" key
# or "list: end" if no lists are to follow.  For example:
#
#   list: paths
#   path1
#   path2
#   list: end
#
# The key "paths" will be added to the hash whose value will be a reference to a
# list containing "path1" and "path2".
#
# Returns: 1 on success, 0 on failure.
################################################################################
sub loadRules
{
    my $file = shift(@_);
    my $hash = shift(@_);
    my $line = "";
    my $key = "";
    my $keyend = "";
    my $value = "";
    my $listname = "";
    my @list;

    open(RULES, "$file") or return 0;

    $line = <RULES>;
    until ($line eq "")
    {
        chomp($line);
        next if ($line =~ /^#/);

        # Try to interpret a key
        ($key, $value) = split(/\s*=\s*|\s*:\s*/, $line, 2);
        if ($key)
        {
            $value =~ s/^\s|\s$|\n//g;
            $value =~ s/"//g;
            
            # Need to handle list key processing carefully
            if ($key eq "list")
            {
                # If we are processing a list, terminate it and start a new one
                if ($listname)
                {
                    printDbg(2, "$listname->@list\n");
                    $hash->{$listname} = [@list];
                    
                    # Not starting a new list, just terminate.
                    if ($value eq "end")
                    {
                        printDbg(3, "Ending List: $listname\n");
                        $listname = "";
                        @list = ();
                    }
                    else
                    {
                        printDbg(3, "Ending List: $listname\n");
                        $listname = $value;
                        @list = ();
                        printDbg(3, "Starting List: $listname\n");
                    }
                }
                # Start a list.  Each subsequent line is a list item.
                else
                {
                    $listname = $value;
                    printDbg(3, "Starting List: $listname\n");
                }
            }

            # We are processing a list, so add the value.
            elsif ($listname)
            {
                push(@list, $line) if ($line);
            }

            # Just a regular key
            else
            {
                # Booleanize
                $value = 0 if (lc($value) eq 'no' || lc($value) eq 'off' || lc($value) eq 'false');
                $value = 1 if (lc($value) eq 'yes' || lc($value) eq 'on' || lc($value) eq 'true');

                printDbg(2, "$key->$value\n");
                $hash->{$key} = $value;
            }
        }
        
        # No key, add it to the list if one is in progress
        elsif ($listname)
        {
            push(@list, $line) if ($line);
        }
    }
    continue
    {
        $line = <RULES>;
    }

    @l = keys(%rules);
    printDbg(3, "@l\n");
    close(RULES);
    return 1;
}


################################################################################
# validatePath() - strip/fix any characters that are illegal in picky file systems
################################################################################
sub validatePath
{
    my $path = shift;
    
    # Strip useless Windoze shell nasties
    $path =~ s/\?|\$|\*//g;
    
    # Tweak other Windoze shell nasties
    $path =~ s/"/'/g;
    $path =~ s/:/;/g;
    
    return $path;
}


################################################################################
# validateFile() - strip/fix any characters that are illegal in picky file systems
################################################################################
sub validateFile
{
    my $path = shift;
    
    # Strip useless Windoze shell nasties
    $path =~ s/\?|\$|\*//g;
    
    # Tweak other Windoze shell nasties
    $path =~ s/\\|\//-/g;
    $path =~ s/"/'/g;
    $path =~ s/:/;/g;
    
    return $path;
}



################################################################################
# copyFile() - copy a file
################################################################################
sub copyFile
{
    my $src = shift;
    my $dst = shift;
    my $status;

    if ($dryrun)
    {
        print "cp \"$fileSrc\" \"$fileDst\"\n";
    }
    else
    {
        $status = system("cp \"$src\" \"$dst\"") / 256;
    }
    return 1 if ($status == 0);
    
    print "Aborting...\n";
    unlink($dst);
    return 0;
}


################################################################################
# diffFile() - check if two files are different and indicate which is newer.
################################################################################
sub diffFile
{
    my $lt = shift;
    my $rt = shift;
    my $status;

    #print "diff -q \"$lt\" \"$rt\"\n";
    $status = system("diff -q \"$lt\" \"$rt\" > /dev/null") / 256;
    return 0 if ($status == 0);
    
    $lt = (stat($lt))[9];
    $rt = (stat($rt))[9];
    return $lt - $rt;
}


################################################################################
# oggGetBitrate() - get the bitrate of an OGG Vorbis file
################################################################################
sub oggGetBitrate
{
    my $file = shift;
    my $bitrate;

    open(PEEL, "$toolOggInfo \"$file\" | grep \"Nominal bitrate\"|");
    $bitrate = <PEEL>;
    close(PEEL);
    $bitrate =~ /:\s+(\S+)/;
    $bitrate = int($1);
    return $bitrate;
}


################################################################################
# oggPeel() - peel an OGG Vorbis file down to a target bitrate, if needed
################################################################################
sub oggPeel
{
    my $src = shift;
    my $dst = shift;
    my $bitrate = shift;
    my $status;

    # No good peelers yet, just transcode
    if ($dryrun)
    {
        print "$toolOggDec --output - \"$src\" | $toolOggEnc --bitrate $bitrate --output \"$dst\" -\n";
    }
    else
    {
        $status = system("$toolOggDec --output - \"$src\" | $toolOggEnc --bitrate $bitrate --output \"$dst\" -") / 256 if (!$dryrun);
    }
    if ($status == 0)
    {
        # Copy the Vorbis comments
        if ($dryrun)
        {
            print "$toolOggTag -l \"$src\" | $toolOggTag -w \"$dst\"\n";
        }
        else
        {
            $status = system("$toolOggTag -l \"$src\" | $toolOggTag -w \"$dst\"") / 256 if (!$dryrun);
        }
        return 1 if ($status == 0);
    }
    
    print "Aborting...\n";
    unlink($dst);
    return 0;
}


################################################################################
# syncPath() - create a subdirectory path
################################################################################
sub syncPath
{
    my $pathSrc = shift;
    my $pathDst = $rpath . substr($pathSrc, length($lpath));;
    my $status;

    # Create the target if it doesn't exist.
    if (!-e $pathDst)
    {
        printMsg("Creating: $pathSrc\n");
        if ($dryrun)
        {
            print "mkdir -p \"$dir\"\n";
        }
        else
        {
            $status = system("mkdir -p \"$pathDst\"") / 256;
        }
        return ($status == 0);
    }
    return 1;
}


################################################################################
# syncFile() - add a file to the target
################################################################################
sub syncFile
{
    my $fileSrc = shift @_;
    my $fileDst = $rpath . substr($fileSrc, length($lpath));;
    my $status;
    my $copy = 1;
    
    $fileDst = validatePath($fileDst);
    printMsg("File: $fileSrc\n");

    # See if the target is there.
    if (-e "$fileDst" && !$force)
    {
        # If we care about the contents, check them.
        if ($diff)
        {
            $copy = diffFile($fileSrc, $fileDst);
            
            # Don't use the newer one if requested.
            if ($copy && !$new)
            {
                $copy = 1;
            }
        }
        # The file is there, so don't copy
        else
        {
            $copy = 0;
        }
    }

    if ($copy > 0)
    {
        printMsg("  Copying ->\n");
        $status = copyFile($fileSrc, $fileDst);
        printMsg("\n");
        return $status;
    }
    elsif ($copy < 0)
    {
        printMsg("  Copying <-\n");
        $status = copyFile($fileDst, $fileSrc);
        printMsg("\n");
        return $status;
    }

    return 1;
}


################################################################################
# syncSorunePath() - add a directory to the Neuros for sorune
################################################################################
sub syncSorunePath
{
    my $pathSrc = shift;
    my $pathDst = $rpath . substr($pathSrc, length($lpath));;
#    my $pathDst = $rpath . $pathSrc;
    my $status;
    
    # Sorune uses the same complete path as the source.  See if the source is there.
    if (-e "$pathSrc")
    {
        # Create the target if it doesn't exist.
        if (!-e $pathDst)
        {
            printMsg("Creating: $pathSrc\n");
            if ($dryrun)
            {
                print "mkdir -p \"$pathDst\"\n";
            }
            else
            {
                $status = system("mkdir -p \"$pathDst\"") / 256;
            }
            return ($status == 0);
        }
        return 1;
    }
    return 0;
}


################################################################################
# syncSoruneFile() - add a file to Neuros and sorune
################################################################################
sub syncSoruneFile
{
    my $fileSrc = shift @_;
    my $fileDst = $rpath . substr($fileSrc, length($lpath));;
#    my $fileDst = $rpath . $fileSrc;
    my $bitrateLimit = $rules{'peelat'};
    my $bitratePeel = $rules{'peelto'};
    my $bitrate;
    my $status;
    my $copy = 0;
    my $add = 0;
    
    # Sorune uses the same complete path as the source, but will shave off
    # whatever is set as the "musichome" in the .sorunerc file.
    
    $fileDst = validatePath($fileDst);
    printMsg("File: $fileSrc\n");

    # See if the target is not there.
    if (!-e "$fileDst")
    {
        # Copy the file if it does not get peeled.
        $copy = 1;
        
        # If we have a bitrate limit and the source exceeds it, peel and copy
        if ($bitrateLimit)
        {
            if ($fileSrc =~ /ogg$/)
            {
                $bitrate = oggGetBitrate($fileSrc);
                if ($bitrate > $bitrateLimit || $force)
                {
                    printMsg("  Peeling to $bitratePeel kbps ($bitrate kbps > $bitrateLimit kbps) and copying...\n");
                    if (!oggPeel($fileSrc, $fileDst, $bitratePeel))
                    {
                        return 0;
                    }
                    $copy = 0;
                    $add = 1;
                }
            }
        }
    }
    elsif ($diff)
    {
        # If we have a bitrate limit and the target exceeds it, peel and recopy
        if ($bitrateLimit)
        {
            if ($fileSrc =~ /ogg$/)
            {
                $bitrate = oggGetBitrate($fileDst);
                if ($bitrate > $bitrateLimit || $force)
                {
                    printMsg("  Peeling to $bitratePeel kbps ($bitrate kbps > $bitrateLimit kbps) and overwriting...\n");
                    if (!oggPeel($fileSrc, $fileDst, $bitratePeel))
                    {
                        return 0;
                    }
                    $add = 1;
                }
            }
        }
    }

    if ($copy)
    {
        printMsg("  Copying...\n");
        $add = copyFile($fileSrc, $fileDst);
    }
    
    if ($add)
    {
        if ($dryrun)
        {
            print "$toolSorune --add \"$fileDst\"\n";
        }
        else
        {
            $status = system("$toolSorune --add \"$fileSrc\"") / 256;
        }
        printMsg("\n");
        return ($status == 0);
    }
    
    return 1;
}


################################################################################
# syncSorune() - sync the Neuros DB with sorune
################################################################################
sub syncSorune
{
    my $status;
    
    #print "$toolSorune --sync\n";
    printMsg("Synchronizing Neuros database...\n");
    $status = system("$toolSorune --sync") / 256 if (!$dryrun);
    return ($status == 0);
}


################################################################################
# scanDir() - scan a directory
################################################################################
sub scanDir
{
    my $target = shift @_;
    my $dirHandler = shift @_;
    my $fileHandler = shift @_;
    my $entry = "";
    my @dir;
    
    printDbg(1, "Scanning $target\n");
    opendir(DIR, $target) or exitError("failed to open directory ($target)");
    @dir = readdir(DIR);
    closedir(DIR);
    
    $entry = shift @dir;
    while ($entry ne "")
    {
        if ($entry eq "." or $entry eq "..") { next; }
        if (-d "$target/$entry")
		{
			if ($recurse)
            {
                if ($dirHandler)
                {
                    if (!&$dirHandler("$target/$entry"))
                    {
                        exitError("failed while processing directory $target/$entry");
                    }
                }
                scanDir("$target/$entry", $dirHandler, $fileHandler);
            }
		}
        else
        {
            if ($fileHandler)
            {
                if (!&$fileHandler("$target/$entry"))
                {
                    exitError("failed while processing file $target/$entry");
                }
            }
        }
    }
    continue
    {
        $entry = shift @dir;
    }
}


################################################################################
# Main routine.
################################################################################

$all = "";
$dirs = 1;
$dryrun = 0;
$diff = 0;
$force = 0;
$new = 0;
$rulesFile = "";
$recurse = 1;
$verbose = 0;
while ($ARGV[0] =~ /^-[a-zA-Z].*/)
{
    ($opt, $arg) = split /=|\n/, $ARGV[0], 2;
    if ($opt =~ /a/) { $all = $arg; }
    if ($opt =~ /d/) { $dryrun = 1; }
    if ($opt =~ /D/) { $dirs = 0; }
    if ($opt =~ /f/) { $diff = 1; }
    if ($opt =~ /F/) { $force = 1; }
    if ($opt =~ /n/) { $new = 1; }
    if ($opt =~ /h/) { printUsage(); exit -1; }
    if ($opt =~ /r/) { $rulesFile = $arg }
    if ($opt =~ /R/) { $recurse = 0; }
    if ($opt =~ /v/) { $verbose = 1; }
    shift @ARGV;
}

loadRules($rulesFile, \%rules) if ($rulesFile);

# Mangle options with rules
$lpath = $rules{'lpath'};
$lpath = $rules{'source'} if (!$lpath);
$lpath = shift @ARGV if (!$lpath);

$rpath = $rules{'rpath'};
$rpath = $rules{'target'} if (!$rpath);
$rpath = shift @ARGV if (!$rpath);

$dirs = $rules{'createdirs'} if (defined($rules{'createdirs'}) && $dirs == 1);
$diff = $rules{'comparefiles'} if (defined($rules{'comparefiles'}) && $diff == 0);
$force = $rules{'overwrite'} if (defined($rules{'overwrite'}) && $force == 0);
$new = $rules{'newest'} if (defined($rules{'newest'}) && $new == 0);
$recurse = $rules{'recurse'} if (defined($rules{'recurse'}) && $recurse == 1);
$verbose = $rules{'verbose'} if (defined($rules{'verbose'}) && $verbose == 0);

# Rules preprocessing before user confirmation
if ($rulesFile)
{
    # Handle the Neuros sorune ruleset
    if ($rules{'type'} eq "sorune")
    {
        $home = getHomePath();
        $configFile = glob("${home}/.sorunerc");
        open(SORUNERC, "grep musichome \"$configFile\" |");
        $musichome = <SORUNERC>;
        close(SORUNERC);
        $musichome = (split(/\s*=\s*/, $musichome, 2))[1];
        $musichome =~ s/^\s|\s$|\n//g;
        printDbg(1, "sorune musichome=$musichome, lpath=$lpath\n");
        if (!$lpath)
        {
            if ($musichome)
            {
                printMsg("Using 'musichome' from .sorunerc as 'lpath'\n");
                $lpath = $musichome;
            }
            else
            {
                exitError("cannot set lpath: unable to find sorune 'musichome' setting in .sorunerc");
            }
        }
        elsif ($musichome && $musichome ne $lpath)
        {
            print "WARNING: lpath does not match sorune musichome.  Check your .sorunerc file.\n";
        }
    }
}

# If we don't have an lpath and rpath by now, bail.
if (!$lpath || !$rpath)
{
    printUsage();
    exit -1;
}

# Spew config and confirm if in verbose mode.
printMsg("Type: $rules{'type'}\n");
printMsg("Source: $lpath\n");
printMsg("Target: $rpath\n");
printMsg("Options:\n");
$dryrun ? printMsg("  No changes will be made to the target (dry run).\n") : printMsg("  All changes will be made to the target.\n");
$dirs ? printMsg("  Directories on the target will be created.\n") : printMsg("  Directories on the target will NOT be created.\n");
$recurse ? printMsg("  Directories on the source will be recursively scanned.\n") : printMsg("  Directories on the source will NOT be recursively scanned.\n");
$diff ? printMsg("  File contents will be compared.\n") : printMsg("  File contents will NOT be compared.\n");
$force ? printMsg("  All files on target will be overwritten by the source.\n") : printMsg("  Files will only be overwritten as needed.\n");
$new ? printMsg("  The newest file on the source or target will be used.\n") : printMsg("  Files from the source will be written to the target.\n");
if ($verbose)
{
    print "Proceed (y/N): ";
    $resp = <STDIN>;
    exit 0 if ($resp !~ /^y/i);
    print "\n";
}

# Now process the rules, if any.
# The meaning of lpath and rpath are a little different when using a rules file.
if ($rulesFile)
{
    # Handle a normal sync
    if (!$rules{'type'} || $rules{'type'} eq "normal")
    {
        if ($rules{'path'})
        {
            $paths = $rules{'path'};
            $path = shift(@$paths);
            while ($path)
            {
                syncPath("$lpath/$path");
                scanDir("$lpath/$path", \&syncPath, \&syncFile);
                $path = shift(@$paths);
            }
        }

        if ($rules{'file'})
        {
            $paths = $rules{'file'};
            $path = shift(@$paths);
            while ($path)
            {
                syncFile($path);
                $path = shift(@$paths);
            }
        }
    }

    # Handle the Neuros sorune ruleset
    elsif ($rules{'type'} eq "sorune")
    {
        if ($rules{'path'})
        {
            $paths = $rules{'path'};
            $path = shift(@$paths);
            while ($path)
            {
                syncSorunePath("$lpath/$path");
                scanDir("$lpath/$path", \&syncSorunePath, \&syncSoruneFile);
                $path = shift(@$paths);
            }
        }

        if ($rules{'file'})
        {
            $paths = $rules{'file'};
            $path = shift(@$paths);
            while ($path)
            {
                syncSoruneFile("$path");
                $path = shift(@$paths);
            }
        }

        syncSorune();
    }

    else
    {
        exitError("unrecognized rules file type: $rules{'type'}");
    }
}

# No rules file
else
{
    scanDir($lpath, \&syncPath, \&syncFile);
}

exit 0;
