#!/bin/bash

#-----------------------------------------------------------------------
# Copyright (C) 2001 by Daniel Käps.
#
# 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.
#
# If you do not have a copy of the GNU General Public License write to
# the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
# Boston, MA 02111-1307, USA.
#-----------------------------------------------------------------------

# Changes:
# - 2001-09-09: added code for options and rewrite of option parsing
# - 2001-10-09: 
#   - improved error handling
#   - fixed bug with escaping of the "'" char (only needed for the -S 
#     option)
# - 2001-10-10:
#   - changed script interpreter command from /bin/sh to /bin/bash 
#     (because none of the other tested shells seem to have support 
#     for the ${PIPESTATUS[]} variable)
# - 2002-01-13:
#   - added option "-p" to create parent directories
#   - added posibility to answer "a/all" when confirming renaming
#   - added option "-n" for a "dry" run
# - 2002-01-20:
#   - added option -q/--quiet to suppress printing names of files to
#     be renamed
#   - split up help into short (--help) and long variant (--manual)
# - 2002-10-02:
#   - added option '--version' to show version identificator
#
# Tested:
# - 2001-10-09: - Debian GNU/Linux 2.2; GNU bash version 2.03.0(1)-release
# - 2001-10-09: - SuSE 6.4 Linux; GNU bash version 2.03.0(1)-release
# - 2001-10-10: - Win95; Cygwin b20.1; GNU bash version 2.02.1(2)-release
#
# Todo:
# - implement variant without using an intermediate file for case 
#   insensitve file systems

# terminate program in case of any unhandled error:
set -e

IsActionInMultipleArguments=false
IsDryRun=false
IsInteractive=false
IsCreateParentDirs=false
IsPrintRenameInfo=true
IsVerbose=false
IsApplyToDir=false

IsDebugPrint=false

# setting this variable to 1 is necessary for Cygwin, but possibly also
# in case a media with non case sensistive file names is mounted
HaveCaseInsensitiveFileNames=0

ProgramName=`basename "$0" .sh`
ProgramVersionString="1.0.2b-01 (2002-10-06)"
TempFileNameSuffix=".~temp"

CommonHelpString()
{
cat <<.
USAGE
    $0 OPTIONS ACTION FILES
    $0 -S OPTIONS ACTIONARGS -- FILES

DESCRIPTION
    Rename(move) files or directories according to a renaming function 
    specified by ACTION.

OPTIONS
    -c, --case-insensitive  assume a file system with case insensitive file 
                            names
    -D, --apply-to-dir      apply renaming function ACTION to the complete
                            filename (including path/directories) (default:
                            apply only to the basename of a filename)
    --help                  display short help text
    -i, --interactive       confirm before renaming
    --man, --manual         display complete help with examples etc.
    -n, --dry-run           don't actually rename files
    -p, --parent-dirs       create all parent directories for new file names
                            if needed
    -q, --quiet             don't print names of files to rename
    -S                      use syntax ACTIONARGS -- FILES rather than put 
                            the whole ACTION construct into a single argument
    -v, --verbose           be a bit verbose
    -V, --version           output version information and exit

.
}

ShortHelpString()
{
CommonHelpString

cat <<.
To display examples, notes and copying conditions, use the "--manual" option.
.
}

LongHelpString()
{
CommonHelpString

cat <<.
NOTES
    1 ACTION: input must come from stdin, output must go to stdout.
    2 ACTION can be a piping construct.
    3 If the ACTIONARGS syntax is used:
      - arguments not prefixed with "@" are put into quotes
      - arguments prefixed with "@" are not put into quotes, and the "@" is
        removed from the argument.
    4 If ACTION fails, no changes will be made.
    5 If ACTION yields an empty string, the file won't be renamed.
    6 If ACTION yields the original filename, the file won't be renamed.
    7 Already existing files won't be overwritten.

EXAMPLES
    Modify the file name extension of files named *.cc to *.cpp:
        $ProgramName "sed 's/\\\\.cc\\\$/.cpp/ig'" *.cc

    Rename files by changing all spaces (" ") to underscores ("_") and all 
    upper case letters to lower case letters:
        $ProgramName "sed 's/ /_/g' | tr [:upper:] [:lower:]" *

    Same as above but using the -S option:
        $ProgramName -S sed 's/ /_/g' "@|" tr [:upper:] [:lower:] -- *

    Move files matching "LHS--RHS.txt" into subdirectories "LHS"
    (renaming them to "RHS.txt"). Create needed directories if they 
    do not yet exist. Don't really execute the commands:
        $ProgramName --verbose --parent-dirs --dry-run \\
            "sed 's,\\(.*\\)--\\(.*\\.txt\\),\\1/\\2,'" *

BUGS
    - only tested with bash
    - it is quite slow
    - (more: see lines in script file marked with BUG)

SEE ALSO
    man/perldoc rename

AUTHOR
    Daniel Käps (kaeps AT informatik.uni-leipzig.de)

COPYRIGHT
    This is free software; see the source for copying conditions. 
    There is NO warranty; not even for MERCHANTABILITY or FITNESS 
    FOR A PARTICULAR PURPOSE.

.
}

verbose_echo()
{
    if $IsVerbose ; then
        echo "$ProgramName: $@"
    fi
}

error_echo()
{
    echo "$ProgramName: $@" >&2
}

debug_echo ()
{
    if $IsDebugPrint ; then
        echo "$ProgramName: $@"
    fi
}

detect_pipe_error_helper()
{
    while [ "$#" != 0 ] ; do
        # there was an error in at least one program of the pipe
        if [ "$1" != 0 ] ; then return 1 ; fi
        shift 1
    done
    return 0
}

detect_pipe_error()
{
    detect_pipe_error_helper "${PIPESTATUS[@]}"
    return $?
}

my_mkdir()
{
    if $IsDebugPrint ; then
        echo "my_mkdir $@"
    fi
    mkdir -p "$@"
}

my_mv()
{
    if $IsDebugPrint ; then
        echo "my_mv $@"
    fi
    # to avoid accidental overwrites, use the "-i" option for 'mv':
    mv -i "$@"
}

while [ "$#" != 0 ] ; do
    case "$1" in
        -c|--case-insensitive)
            HaveCaseInsensitiveFileNames=1 ;;
        -D|--apply-to-dir)
            IsApplyToDir=true ;;
        -i|--interactive)
            IsInteractive=true ;;
        -n|--dry-run)
            IsDryRun=true ;;
        -p|--parent-dirs)
            IsCreateParentDirs=true ;;
        -q|--quiet)
            IsPrintRenameInfo=false ;;
        -S)
            IsActionInMultipleArguments=true ;;
        -v|--verbose)
            IsVerbose=true ;;
        --help)
            ShortHelpString
            exit 1 ;;
        --man|--manual)
            LongHelpString
            exit 1 ;;
        --version|-V)
            echo -e "$ProgramVersionString\n"
            exit 1 ;;
        --)
            shift 1
            break ;;
        -*)
            error_echo "Unrecognized option: \"$1\""
            ShortHelpString >&2
            exit 1 ;;
        *)
            break ;;
    esac
    shift 1
done

if $IsActionInMultipleArguments ; then
    while [ "$1" != "--" -a "$#" != 0 ] ; do
        case "$1" in
            @*)
                # don't put arguments prefixed by a "@" into quotes, just
                # remove the "@"
                Argument="${1/@/}"
                ;;
            *)
                # put other arguments into "'" quotation marks and "escape" 
                # "'" characters that are in the argument string
                # was incorrect: Argument="'${1//\'/\'\\\'\'}'"
                COM="'"
                Argument="'${1//$COM/$COM\\$COM$COM}'"
                ;;
          esac
          PipingConstruct="$PipingConstruct$Argument "
        shift 1
    done
    shift 1
else
    PipingConstruct="$1"
    shift 1
fi

verbose_echo "renaming function is: \"$PipingConstruct\""

PipingConstruct="$PipingConstruct ; detect_pipe_error"

while [ "$#" != 0 ]
do
    OldFileName="$1"
    debug_echo "current file is: \"$OldFileName\""

    IsRenameCurrentFile=false
    if $IsApplyToDir ; then
        set +e
        NewFileName=`echo "$OldFileName" | eval ${PipingConstruct} ; detect_pipe_error`
        if [ $? != 0 ] ; then
            set -e
            error_echo "renaming function failed, no renaming done."
            shift 1
            continue
        fi
        set -e

        if [ -z "$NewFileName" ] ; then
            verbose_echo "ignored: destination file name is a null-string for \"$OldFileName\""
        elif [ "$OldFileName" = "$NewFileName" ] ; then
            verbose_echo "new file name is same as old file name for \"$OldFileName\""
        else
            IsRenameCurrentFile=true
        fi

    else
        OldBaseName=`basename "$OldFileName"`
        OldDirName=`dirname "$OldFileName"`
        OldFileName="$OldDirName"/"$OldBaseName"
        set +e
        NewBaseName=`echo "$OldBaseName" | eval ${PipingConstruct} ; detect_pipe_error`
        if [ $? != 0 ] ; then
            set -e
            error_echo "renaming function failed, no renaming done."
            shift 1
            continue
        fi
        set -e

        if [ -z "$NewBaseName" ] ; then
            verbose_echo "ignored: destination base name is a null-string for \"$OldFileName\""
        elif [ "$OldBaseName" = "$NewBaseName" ] ; then
            verbose_echo "new file name is same as old file name for \"$OldFileName\""
        else
            IsRenameCurrentFile=true
        fi

        NewFileName="$OldDirName"/"$NewBaseName"
    fi
    debug_echo "new file name is: \"$NewFileName\""

    if $IsRenameCurrentFile ; then

        if $IsDryRun ; then
            if $IsPrintRenameInfo ; then
                echo "\"$OldFileName\" -> \"$NewFileName\""
            fi

            shift 1
            continue
        fi

        if $IsInteractive ; then
            read -p "$ProgramName: Rename \"$OldFileName\" -> \"$NewFileName\" ([y]es/[a]ll/[]no)? " TempInput
            case "$TempInput" in 
               [yY][eE][sS]|[yY])
                   IsRenameCurrentFile=true ;;
               [aA][lL][lL]|[aA])
                   IsRenameCurrentFile=true 
                   # assume yes/non-interactive for following files:
                   IsInteractive=false
                   ;;
               *)
                   IsRenameCurrentFile=false ;;
            esac
        else
            IsRenameCurrentFile=true
        fi

        if $IsRenameCurrentFile ; then
            if $IsPrintRenameInfo ; then
                echo "\"$OldFileName\" -> \"$NewFileName\""
            fi

            IntermediateFileName="$OldFileName"

            if $IsCreateParentDirs ; then
                NewDirName=`dirname "$NewFileName"`
                my_mkdir "$NewDirName"
            fi

            if [ "$HaveCaseInsensitiveFileNames" -eq "1" ] ; then
                # for case insensitive file systems:
                # the mv to an intermediate file is needed in case we want to
                # change only case of letters (without the intermediate 
                # filename, it would not be possible to use the 
                # file-exists-check for $NewFileName)
   
                # use a readable file name in the same directory as 
                # intermediate file (instead of using f.ex. 'tempfile') to:
                # 1) make sure to stay on the same file system (avoids file 
                #    copying)
                # 2) make the file name human readable in case there are 
                #    errors and the user has to rename the files back manually
                IntermediateFileName="$NewFileName""$TempFileNameSuffix"
   
                if [ ! -e "$IntermediateFileName" ] ; then
                    my_mv "$OldFileName" "$IntermediateFileName"
                else
                    error_echo "intermediate file $IntermediateFileName already exists, skipping"
                    break
                fi
            fi
            if [ -e "$NewFileName" ] ; then
                error_echo "destination $NewFileName already exists, skipped."
                # in case $NewFileName already exists, "undo" the mv to the 
                # intermediate filename
                if [ "$HaveCaseInsensitiveFileNames" -eq "1" ] ; then
                    my_mv "$IntermediateFileName" "$OldFileName"
                fi
            else
                my_mv "$IntermediateFileName" "$NewFileName"
            fi
        fi
    fi
    shift 1
done
