9

I want to have a script to automatically move all files into a directory structure that I have in place (it looks like ~/jpg, ~/png, ~/mp3, ~/zip, and so on).  So far, this is almost doing exactly what I want it to:

#!/bin/zsh
echo "Executing Script"
find . -iname '*.jpg' -exec gmv -i --target-directory=$HOME/jpg '{}' +
find . -iname '*.png' -exec gmv -i --target-directory=$HOME/png '{}' +

I have no experience with shell scripting, so this is just something I've managed to cobble together.

Is simply issuing consecutive commands on consecutive lines the proper way to script for the shell?  I initially tried this with mv and there was error handling involved and it just wasn't elegant.  I'm trying to expand on user EvilSoup's answer here.

Besides what I've already mentioned, I included the mv -i flag so that I don't overwrite anything that already exists (this part is extremely important), but maybe the -n is better for that, I'm not exactly sure.

Regarding find, I want the mv operations to happen only in the current directory, and for some reason, the find seems to be somewhat recursive, although I don't exactly understand how to limit that.  I want to run my script in each directory and have it mv only the files that find finds in the current working directory.

+1 for any zsh-on-macOS-specific introductory material re: shell scripting.

Freiheit
  • 9,669
user69136
  • 113
  • I'm not sure if find on macos has this, but with GNU find instead of -name you can use -iname for case insensitive matching. – fuzzydrawrings Mar 21 '22 at 03:12
  • I just checked it and -iname works on macOS. That's a perfect solution to that aspect of this script. Now JPEG/JPG both with -iname is probably the only one I'll need to list two specific spellings for. Thanks – user69136 Mar 21 '22 at 03:23
  • I've done what you're doing - writing shell scripts on macOS, and only wanted to offer this: All of the mac OS tools (e.g. find) are based on the BSD-flavor of Unix. I've found that all of the native BSD-based tools are different than their Linux equivalents, and most of them are sadly out-of-date. Installing MacPorts will give you access to current and maintained tools with a far larger user-base, and access to far more support resources. – Seamus Mar 21 '22 at 03:48
  • Again, at least with GNU find, if you don't want to include subfolders, you can add the flag -maxdepth 1 and it will search only in the current folder, not subfolders. – frabjous Mar 21 '22 at 04:21
  • Thanks everybody. I have it working PERFECTLY now..

    find . -maxdepth 1 -iname '*.aif' -exec gmv -n --target-directory=$HOME/aiff '{}' + absolutely decimates a neglected downloads folder in no time.... especially if you can load up the script with enough extensions, it really works wonders.

    – user69136 Mar 21 '22 at 04:55
  • You can self-answer your question, but it's not clear from what you've said so far why this is any better than just mv -n *.png $HOME/png. – Michael Homer Mar 21 '22 at 04:59
  • I'll admit, your comment made me laugh at myself, with how over-engineered this is. The only difference I can think of right off is the case-insensitivity, rather than having to verbosely maintain (i.e. Jpeg, JPG, JPEG, jpg, Jpg) all the different spellings. – user69136 Mar 21 '22 at 05:54
  • You shouldn’t have been surprised that find was recursive, since the question that you linked to was specifically about specifically about searching a directory tree. – G-Man Says 'Reinstate Monica' Mar 21 '22 at 16:00

3 Answers3

11

You're not doing anything wrong. It's totally normal when you begin scripting to have your first script be nothing but a list of commands one after another.

But hopefully you will learn to go beyond that. I'm not saying your script is bad, but by contrast, I want to show you what I would have written instead of your script. I'm using conditionals and loops and safety checks, and I've put in a bunch of comments so you can see what the script is doing.

#!/usr/bin/env zsh

announce that the script has started

echo "Running sorting script."

the variable "$1" means the first argument to the script, i.e., if

the script is named "mysorter.zsh" if you ran mysorter.zsh ~/tmp

then $1 would be "~/tmp"

I'm assigning that to the variable "argument"

argument="$1"

if $argument is blank, I want the script to run in the present

directory; if not, I want to move to that directory first

[[ -n "$argument" ]] means "$argument is not blank" (not length 0)

for other conditions like -n, see

https://zsh.sourceforge.io/Doc/Release/Conditional-Expressions.html

if [[ -n "$argument" ]] ; then # [[ -d "$argument" ]] checks if "$argument" is a directory; if it's # not, the command is a mistake and the script should quit # the "!" means "not" if [[ ! -d "$argument" ]] ; then # echo prints text; the ">&2" means print to the standard error # stream, rather than regular output stream, because this is an # error message echo "$argument either does not exist or is not a directory" >&2 # exit quits the script; the number indicates the error code; if # you write "exit 0" that means no error; but this is an error # so I give it a non-zero code exit 1 fi # if we made it here, then "$argument" is indeed a folder, so I # move into it cd "$argument" fi

this is a loop that will be separately processed for all files in

the active directory

for file in * ; do # I indent inside the loop to indicate better where the loop starts # and stops

# first I check if "$file" is a folder/directory; if it is, 
# I don't want to do anything; "continue" means cease this cycle
# through the loop and move on to the next one
if [[ -d "$file" ]] ; then
    continue
fi

# what I want to do next depends on the extension of "$file";
# first I will determine what that is
# the notatation "${file##*.}" means cut off everything prior to
# the final . ; 
# see https://zsh.sourceforge.io/Doc/Release/Expansion.html#Parameter-Expansion
extension="${file##*.}"

# i want to treat extensions like JPG and jpg alike, so I will make
# the extension lowercase; see the same page above
extension="${(L)extension}"

# I want to move the file to a folder in $HOME named after the 
# extension, i.e., $HOME/$extension; first I check if it doesn't
# exist yet
if [[ ! -d "$HOME/$extension" ]] ; then
    # since it doesn't exist, I will create it
    mkdir "$HOME/$extension"
fi

# by default we want the move target to have the same name
# as the original file
targetname="$file"
# but I may need to add a number to it; see below
num=0
# now I want to check if there is already a file in there with
# that name already, and keep changing the target filename
# while there is
while [[ -e "$HOME/$extension/$targetname" ]] ; do
    # a file with that name already exists; but I still want
    # to put the file there without overwriting the other one;
    # I will keep checking numbers to add to the name until I
    # find one for a file that doesn't exist yet

    # increase by one
    let num++

    # the new targetname is filename with the extension cut off, 
    # plus "-$num" plus the extension
    # e.g. "originalfilename-1.zip"
    targetname="${file%.*}-${num}.${extension}"
done

# we have now found a safe file name to use for the target, 
# so we can move the file
echo "Moving file $file to $HOME/$extension/$targetname"
# here we try to move the file, but if it fails, we 
# print an error and quit the script with an error
if ! mv "$file" "$HOME/$extension/$targetname" ; then
    echo "Move command failed!" >&2
    exit 1
fi

# we're now done with this file and can move on to the next one

done

done indicates the end of the loop

if we got here everything was successful and quit with exit code 0

echo "All files successfully sorted." exit 0

This is just to give you a sense of what is possible.

Don't be worried about your scripts being perfect right away. You'll get better at it when you get more practice. If the scripts work for you, they work for you.

Obviously I put in some links to https://zsh.sourceforge.io which is a good resource for learning more about zsh.

I won't put in anything mac specific, because you should try to learn not to be tied into any one platform, especially if you might foresee yourself moving away from proprietary platforms towards those that are open source and truly embrace the UNIX philosophy. (I'm doing my best not to be preachy about it. Is it working?)

frabjous
  • 8,691
  • 1
    Thank you so much, this is like an entire tutorial. Thanks for all the great annotations, really appreciated. – user69136 Mar 21 '22 at 06:08
  • Nice tutorial! But I do have a little nitpick. In the argument check you give the "Directory doesn't exist" error-message, but that will ALSO happen if the argument refers to a file in stead of a directory. Either change the message to "$argument is not a directory!" (which matches the meaning of the [-d] test) or add an extra check to see if it was a file and give an appropriate message for that eventuality. – Tonny Mar 21 '22 at 13:37
  • A few notes: (1) in scripts, it's preferable to use cd -P (even maybe CDPATH= cd -P for cd to behave more like the chdir() of other languages. (2) in any case, you almost always want to check the exit status of cd (3) to test whether an argument is provided, you'd use (( $# >= 1 )) or use argument=${1?}, [[ -n $1 ]] only tests for its expansion yielding a non-empty string. – Stéphane Chazelas Mar 21 '22 at 13:41
  • (4) [[ ! -d "$argument" ]] could yield false if $argument doesn't exist but also if it exists but is not a directory (after symlink resolution) (5) for echo to output arbitrary data, you need to pass it a -E option (or use the more portable printf '%s\n' or print -r --) (6) You need -- to separate options from non-option arguments if you can't guarantee the latter won't start with -. – Stéphane Chazelas Mar 21 '22 at 13:45
  • (7) note that both ${file##*.} and ${file%.*} expand to foo if $file is foo (doesn't contain a dot), See also zsh's $file:e or $file:r in zsh (see also the difference for file=foo.d/bar). – Stéphane Chazelas Mar 21 '22 at 13:48
  • 1
    Thanks for the suggestions, everyone. I'm learning things myself. I'm not going to implement all the suggestions, because I want to keep the script easy enough for a beginner to understand the comments, but hopefully anyone who finds this post will read your comments as well! – frabjous Mar 21 '22 at 15:54
  • @frabjous last night your script actually became the impetus for switching my Android's termux shell over to zsh, it worked like a charm there, too. It's great that so many people chimed in here. – user69136 Mar 21 '22 at 17:19
  • When I learned shell scripting there were two things that helped me a lot. ShellCheck https://github.com/koalaman/shellcheck and the realization that when your script feels like it's getting too complex, you should turn to a more full featured programming language to save your own sanity. Good luck – aaaaaa Mar 21 '22 at 22:13
  • @user69136 That's funny. AFAIK there's only one line in it -- the one that lowercases the extension -- that wouldn't work in good ol' bash. And there's a bash substitute for that too. But yeah, zsh rocks. – frabjous Mar 21 '22 at 23:48
7

Nothing particularly wrong with your script (except maybe that if the first find fails, it will not be reported in the exit status of your script¹).

But since you're using zsh, there are possible ways to improve it or make it more zsh-like. For instance using zmv which is zsh's own batch-renaming tool (implemented as an autoloadable function):

#! /bin/zsh -
autoload zmv
zmodload zsh/files # for builtin mv to speed things up as zmv
                   # is going to run one mv per file below
zmv '**/(*).(#i)(jpg|png)(#qD.)' '$HOME/$2:l/$1'

zmv does do some sanity checks, to avoid losing data (overwriting files either because the targets already existed or because two files resolve to the same target) before starting doing any renaming.

**/ (for recursive globbing) matches any level of subdirectories. Just remove it if you only want to move files in the current directory.

The (#q...) introduces glob qualifiers with D being to not skip dotfiles and . to restrict the match to regular files (exclude directories, symlinks, fifos...). You could also add the oN qualifier to Not order the list of files as a slight performance optimisation.

(#i) enables case insensitive matching.

$2:l converts $2 to lowercase. That's using a (t)csh style modifier (though in tcsh, you'd need $2:al). In zsh, you can also use a parameter expansion flag (${(L)2}).

Instead of capturing (using (...)) the root and extension of the base names in $1 and $2, we could also use csh-style $f:t:r, (root of tail) $f:e (extension) on the whole file path (which zmv makes available in the $f variable):

zmv '**/*.(#i)(jpg|png)(#qD.)' '$HOME/$f:e:l/$f:t:r'

With find and gmv, you can also do it in one find invocation to avoid crawling the directory twice:

find . -type f '(' -name '*.[jJ][pP][gG]' -exec gmv -it ~/jpg {} + -o \
                   -name '*.[pP][nN][gG]' -exec gmv -it ~/jpg {} + ')'

(or use -iname if your find implementation supports that non-standard extension. Add ! -name . -prune before -type f to stop recursing; some find implementations also support -mindepth 1 -maxdepth 1 for that, the -mindepth 1 being not necessary here as . (at depth 0) does not match the patterns anyway).

Without GNU mv, you could use sh to rearrange the arguments for the target directory to be last:

find . -type f '(' -name '*.[jJ][pP][gG]' -exec sh -c '
                      exec mv -i "$@" ~/jpg/' sh {} + -o \
                   -name '*.[pP][nN][gG]' -exec sh -c '
                      exec mv -i "$@" ~/png/' sh {} + ')'

(the -type f being the equivalent of zsh's . glob qualifier above).

The only sanity check there is done with -i for interactive, it is not done in advance.

To build that command based on a list of extension + target-dir, you could do:

cmd=( find . -type f '(' ) or=()
for ext dir (
  jpg  ~/jpg
  jpeg ~/jpg
  png  ~/png
  aif  ~/aiff
) cmd+=($or -iname "*.$ext" -exec gmv -it $dir {} +) or=(-o)
cmd+=(')')
$cmd

With the zmv approach:

typeset -A map=(
  jpg  ~/jpg
  jpeg ~/jpg
  png  ~/png
  aif  ~/aiff
)
zmv "**/(*).(#i)(${(kj[|]map})(#qD.)" '$map[$2:l]/$1'

Using a $map associative array to map extensions to target directory. ${(kj[|])map} joins the keys of the associative array with |s. We switch to double quotes for that to be expanded into the pattern argument that is passed to zmv.


¹ another potential problem would arise if there were file of type directory that matched those patterns or worse things like dir.png/subdir.png/file.png, you'd want to skip those with ! -type d or even go further restrict to regular files only as I show here.

  • This is also really cool. I didn't know about zmv, I'm definitely going to look more into what you wrote, later -- did you really just show 6 different ways to do this, or am I not reading this right? That's so cool. Thanks. – user69136 Mar 21 '22 at 17:26
3

Since you asked about complexity and elegance in scripts, I’ll augment frabjous’s answer with something else you could do:

        ︙
echo "Executing Script"
for ext in jpg png
do
    find . -maxdepth 1 -iname "*.$ext" -exec gmv -n --target-directory="$HOME/$ext" {} +
done

This can cut down the duplication of code if, as you say, you are doing the same thing with multiple extensions (e.g., mp3, zip, bmp, gif, etc.).  I tell bash users

You should quote all shell variables ($something) in double quotes, as shown (although, as you have seen, things will sometimes work without the quotes). 

pxeger says

This isn't necessary in Zsh (most of the time), unless you set the -y option

but, specifically, you must not quote *.$ext in single quotes, because that would prevent the correct interpretation of the $ext shell variable.  And you must quote the *, with single quotes, double quotes, or by saying \*, in order to prevent the shell from expanding that glob; you need it to be passed to find as-is (not expanded). 

You don’t need to quote {} (but it doesn’t hurt).

This doesn’t directly support your multiple spellings (i.e., jpegjpg and aifaiff) issue — at least, not easily — but you can handle that outside the script (in the filesystem) with symbolic links:

cd                      # i.e., cd "$HOME"
mkdir jpg png mp3 zip aiff …
ln -s jpg jpeg
ln -s aiff aif

then do

for ext in jpg jpeg png aif aiff …
do
    find . -maxdepth 1 -iname "*.$ext" -exec gmv -n --target-directory="$HOME/$ext" {} +
done

Yes, the -iname test in find is nice.  But you can simulate it fairly easily:

gmv -n -- *.[Jj][Pp][Gg] *.[Jj][Pp][Ee][Gg] "$HOME"/jpg

But it’s not as easy to wrap a for loop around that — at least, not in bash.  Apparently it’s easy in zsh; see Stéphane Chazelas’s answer.  Stéphane reminds me that, in zsh, if a glob (wildcard) fails, the entire command is aborted, even if there are other globs that succeed.  So, for zsh, you need to split the above into

gmv -n -- *.[Jj][Pp][Gg]     "$HOME"/jpg
gmv -n -- *.[Jj][Pp][Ee][Gg] "$HOME"/jpg

I don’t know much about zsh, but I believe that everything I’ve showed you above will work in zsh.  (It will definitely work in bash.)

  • gmv -n *.[Jj][Pp][Gg] *.[Jj][Pp][Ee][Gg] "$HOME"/jpg wouldn't work if there were either no jpg files or no jpeg files (you also forgot the --). gmv -n -- *.[jJ][Pp]([eE]|)[Gg] ~/jpg/ or gmv -n -- *.(#i)jp(e|)g ~/jpg/ with extendedglob. – Stéphane Chazelas Mar 21 '22 at 07:55
  • 1
    "You should quote all shell variables" - This isn't necessary in Zsh (most of the time), unless you set the -y option – pxeger Mar 21 '22 at 17:32