Man, it is been awhile. So the topic of bash shell scripting. Consider my problem:
I always download stuff to read later on. I am sort of a hoarder in that regard.(I hoard text files and pdfs). Anyways I have always used Nautils(Nautils is the GUI that gives the windows and that "Microsofty" look to Linux systems) to copy my downloaded files to the location I want them to go to. I desperately wanted to use the command line to automate that process. I recently contributed to a zsh theme called Geometry which I use on my FreeBSD machine.(BTW, I think it is a fantastic beginner's project to contribute to, but I digress).
So the problem stated more formally is:
Automate the copying of recently downloaded files to user-defined directories. The user should have the option of specifing the number of files that they want to copy and if they want the copied file to have the same name as the original.
Whew!! That was a mouthful. Onto the task of coding up this badboy!!
Now what is the way this function would be called? I like the way ls
operates. U can either call ls -l
(ls long-format), ls -r
(list in reverse format) and ls -l -r
(ls long-format in reverse). These are called options. So this command, I am calling it cpRecent , should have options for the number of files and an edit option. Ideally we want this new function to behave similarly to cp
. So the format could be
cpRecent -d {directories} -n {number of files} -i{interactive or edit mode} -h{help}
The last option -h is sort of mandatory as it makes the command in line with most of commands in linux and we want it that a new user can use the function upon reading the help menu, accessed through the -h option. Also a "sort of" standard is to put your user-defined functions in the bash_aliases
file. Now, lets see the help menu(encapsulated by our __usage()
function):
__usage(){
cat <<End-Of-Message
_______________________________________________________________________________
Copies/Moves the n most recent file(s) in a directory to another directory where
n is user specified.
<cpRecent/mvRecent> [-d "D1,D2"] [-n NUM] [-ih]
-d "D1,D2" The directory that is copied/moved from is D1 while
the directory that is copied/moved to is D2
The directories would need to be in relative paths
-n This specifies an integer num of files
-i This allows the user to edit the name of the file
to be copied or moved
-h Shows this entire wall of text
An example of how to use the command:
cpRecent -d "Documents,." -n 3
(This means that copy the three(3) most recent files in Documents to
the folder I am currently in)
_______________________________________________________________________________
End-Of-Message
}
The above is called a here document. It allows for self-documenting code and we can use it to store our help menu. Important caveats:
The string on the last line must not have any spaces in front of it.
Dont use
wall
.Wall
sends the message to all the users logged into the system. Ideally you want the message to go to the current user executing the script.
If you noticed, the usage function has preceding double underscores. That is "sort of" a convention. How do we process these options -d, -n, -i, -h? We use a shell command called getopts
. Another caveat: getopts
only processes short options( just one dash infront of the character). Long options are more descriptive like --number instead of -n in our description. There is a way to do that but I am too lazy. We also are going to put all our option processing into another function.
__processoptions(){
OPTIND=1
while getopts ":d:n:ih" opt; do
case $opt in
d ) IFS=',' read -r -a directories <<< "$OPTARG";;
n ) numfiles=$OPTARG;;
i ) interactive=1;;
h ) __usage; return 1;;
\? ) echo "$invalid_option -$OPTARG" >&2 ; return 1;;
: ) echo "$no_args"; __usage >&2 ; return 1;;
* ) __usage >&2; return 1;;
esac
done
shift "$((OPTIND-1))"
# Check for errors
(( ${#directories[@]} != 2 )) && echo "$invalid_option Number of directories must be 2" && return 2
__returnfullpath "${directories[0]}"
directories[0]="$fullpath"
__returnfullpath "${directories[1]}"
directories[1]="$fullpath"
if [[ -z ${directories[0]} || -z ${directories[1]} ]]; then
echo $no_directory
return 3
fi
[[ numfiles != *[!0-9]* ]] && echo "$invalid_option Number of files cannot be a string" && return 4
(( $numfiles == 0 )) && echo "$invalid_option Number of files cannot be zero" && return 4
return 0
}
The (( ))
indicate mathematical evaluation while [[ ]]
is for test
(conditional expressions). A fun way to check all the conditional expressions that is there is to do this on your command line man test
.>&2
is a way to print to sterr
instead of stout
. We use return
instead of exit
because we are going to put this code in bash_aliases
file. An exit
commad would cause our current shell to terminated prematurely. The line IFS=',' read -r -a directories <<< "$OPTARG";;
is to split the string from OPTARG
into two substrings at any ,
. IFS
is kinda of global and we do not want to set it so we put it on the same line as the read
command . This part read -r -a directories <<< "$OPTARG"
is just redirecting the string into an array called directories.
So we get an invocation of the command like: cpRecent -d "Downloads,." -n 2
. We need to check that the user has inputted directories that are on the system. (I mean, you can't copy from thin air into thin air). That is the function of the __returnfullpath
function as shown below:
# Advise that you use relative paths
__returnfullpath(){
local npath
if [[ -d $1 ]]; then
cd "$(dirname $1)"
npath="$PWD/$(basename $1)"
npath="$npath/" # Add a slash
npath="${npath%.*}" # Delete .
fi
fullpath=${npath:=""}
}
local variable
basically makes variable
to have function-scope, that is, variable
only lives in a function. dirname
basically gets the parent directory of our directory and basename
returns a filename from a path.
The other variables that we are going to be using is shown below:
#Error codes
no_args="You need to pass in an argument"
invalid_option="Invaild option:"
no_directory="No directory found"
# Return values
fullpath=
directories=
numfiles=
interactive=
typeset -a files
typeset -A filelist
The typeset
is a way to declare a variable as an array. typeset -A
initialises an associative array, kinda like a dictionary and typeset -a
initialises an normal array.
Continuing with our eariler example, we have to get the 2 most recent files from the Downloads directory. We do that with another function:
__getrecentfiles(){
local num="-"$numfiles""
# Get the requested files in directory(skips directories)
if [[ -n "$(ls -t ${directories[0]} | head $num)" ]]; then
# For some reason using local -a or declare -a does not seem to split the string into two
local tempfiles=($(ls -t ${directories[0]} | head $num))
for index in "${!tempfiles[@]}"; do
echo $index ${tempfiles[index]}
[[ -f "${directories[0]}${tempfiles[index]}" ]] && files+=("${tempfiles[index]}")
done
fi
return 0
}
Basically, we make use of ls -t
(ls with timestamps arranged from most recent to least recent) and head
to get the list of files. Then we check if the files themselves are directories with the -f
test. If they are, we add them to files array. Okay, we need to do one last thing, we need to get the full path of the files so that cp
would not complain!! We use the __processlines function to do that:
__processlines(){
local name
local answer
if [[ -n $interactive ]]; then
for index in "${!files[@]}"; do
name=${files[index]}
read -n 1 -p "Old name: $name. Do you wish to change the name(y/n)?" answer
# Need to leave a space in between the variables
[[ "$answer" == "y" ]] && read -p "Enter new name:" name
local dirFrom="${directories[0]}${files[i]}"
local dirTo="${directories[1]}$name"
filelist+=(["$dirFrom"]="$dirTo")
done
else
for index in "${!files[@]}"; do
local dirFrom="${directories[0]}${files[index]}"
local dirTo="${directories[1]}${files[index]}"
filelist+=(["$dirFrom"]="$dirTo")
done
fi
return 0
}
We check for the interactive option (-i). If the user specified interactive, we ask if they want to keep the names or change them using these lines:
read -n 1 -p "Old name: $name. Do you wish to change the name(y/n)?" answer
This line prompts the user with the old name of the file and asks the user if he/she wants to change it. The read
command is a way to read user input and in this case, it only reads one character, either a "y" or a "n".-n
means "number of characters" and -p
is for prompt. The value is stored in answer:
[[ "$answer" == "y" ]] && read -p "Enter new name:" name
This line checks if the answer is "y". If it is, it asks for the new name. We then associate the old name of the file with the new name of the file: filelist+=(["$dirFrom"]="$dirTo")
. Now I had run all this in one script file , but because I did not want to pollute my bash_aliases
file, I decided to put all the above code into a helper library: helperlib.sh(Imaginative, huh?) So where do we use this helperlib.sh? We get to use it in the main function, cpRecent. Another reason for using the library was to see if I could also use the code to do a mv
(move command) style command. Turns out I could. Okay, enough stalling. Here is the main bash_aliases
file:
The first line in the cpRecent command is to source the helperlib.sh. If you have used Java with its import
statement or C++ with its include
statement, it bascially does the same thing. Another thing with return
and exit
,it normally returns the status of the last command. The status of the last command is in the variable $?
. If the status is 0
, then we know that the command was successful. Any other number indicates a failure and that is what this (( $? == 0 ))
is checking. unset
is normally used with arrays to deference(delete) them. So the helperlib.sh is shown below:
Okay, I learnt a lot of patience while coding up this solution. Also bash -x
is a powerful way of looking at how your bash program executes, basically debugging. Use it liberally!!!!
One caveats:
Presently the directories should be relative paths. I have not found a way to get the path of any file with using some third party library.
To see it in action,