3

Need to remove apostrophes from files. I have tried several approaches, also from Stackexchange.

I am on a Synology NAS, so I don't have Python, or Perl and furthermore I have to exclude certain directories (find -prune).

The following find command seems to generally work during testing (using echo):

find . -depth \( -type d -name "@*" -o -name "*#recycle*" -o -name "*SynoResource*" -prune \) -o \( -name "*\'*" -execdir bash -c 'for f; do echo mv ${f/\\\'/\\\\'} `echo $f | sed 's/\\\'/X/g'`; done' _ {} + \)

For a file like "911's.zip", replacing the apostrophe ' with an X generates the following result (using the command above):

mv ./911's.zip ./911Xs.zip

When I remove the "echo" in order to execute the command, I get the following error:

mv: target '911Xs.zip' is not a directory

In an interactive shell I can rename the file with this command:

mv 911\'s.zip 911Xs.zip

Of course, I have tried to escape the ' (even with several \, like in ${f/\'/X}), but it doesn't work.

Any idea?

Thanks a lot,

Gary

  • unrelated to issue, but mv "${f}" should be quoted. echo $f | sed might render file names unintentionally (seq whitespaces will shrink to one) and echo could exploited with file names – alecxs Apr 06 '22 at 17:53

1 Answers1

3

Several problems:

  • You can't combine -prune with -depth as -depth processes leaves first, so -prune has no effect as it comes too late, after the whole contents of the directory has already been processed.
  • Your -prune is misplaced. Where you put it, it would only apply to the files that match -name "*SynoResource*". Same for -type d which only applies to -name "@*".
  • you have missing double quotes around your parameter expansions and command substitutions which causes them to undergo split+glob.
  • echo generally can't be used to output arbitrary data.
  • You can't have a ' inside single quoted strings. If you need to pass a ' literally to some command, you need to quote it using another shell quoting operator ("..." or backslash) and outside of single quotes.
  • ' is not special to mv, sed nor find, no need to escape it for them. It's only special to the shell where it's a strong quoting operator.
  • You should use $(...) instead of that deprecated `...` (which also adds even more complication when backslash are involved).

So, here:

LC_ALL=C Q="'" find . -depth \
  ! -path '*/@*' \
  ! -path '*#recycle*' \
  ! -path '*SynoResource*' \
  -name "*'*" -execdir bash -c '
    for file do
      mv -i -- "$file" "${file//$Q/X}"
    done' bash {} +

Here, since -prune can't be used with -depth, we're using -path (aka -wholename in GNU find) to filter out the files in those excluded directories based on their full path. That means however that find will descend into those directories which is not ideal from a performance point of view.

Using -execdir means you're renaming only the basename which is good, but that also means you end up running at least one bash per directory that contains files with 's. Alternatively, you could do:

LC_ALL=C Q="'" find . -depth \
  ! -path '*/@*' \
  ! -path '*#recycle*' \
  ! -path '*SynoResource*' \
  -name "*'*" -exec bash -c '
    for file do
      dir=${file%/*} base=${file##*/}
      mv -i -- "$file" "$dir/${base//$Q/X}"
    done' bash {} +

That makes it more efficient though less safe in the face of somebody renaming files and directories whilst the script is running.

For a dry-run, you can replace the mv command with:

(PS4="Would run"; set -x; : mv -- "$file" "$dir/${base//$Q/X}")

That's better than using echo as bash will use quotes where necessary in its tracing output so as to make it unambiguous. The tracing output will look as if it was valid shell code that could be used to run the same command.

There, using -exec instead of -execdir also makes the tracing clear about what files would be renamed.

To use -prune but still process the directory depth first, an alternative would be to use GNU tac -s '' if available (along with GNU xargs) on the output of find -print0 to reverse it:

LC_ALL=C find . -depth \
  '(' -name '@*' -o \
      -name '*#recycle*' -o \
      -name '*SynoResource*' \
  ')' -type d -prune -o \
  -name "*'*" -print0 |
  tac -s '' |
  Q="'" xargs -r0 bash -c '
    for file do
      dir=${file%/*} base=${file##*/}
      mv -i -- "$file" "$dir/${base//$Q/X}"
    done' bash {} +

Without GNU tac, xargs but if your bash is of version 4.4 or above, you could also do:

LC_ALL=C find . -depth \
  '(' -name '@*' -o \
      -name '*#recycle*' -o \
      -name '*SynoResource*' \
  ')' -type d -prune -o \
  -name "*'*" -print0 |
  Q="'" bash -c '
    readarray -td "" files &&
      for (( i = ${#files[@]} - 1; i >= 0; i-- )); do
        file=${files[i]}
        dir=${file%/*} base=${file##*/}
        mv -i -- "$file" "$dir/${base//$Q/X}"
      done' bash {} +

(beware I've not tested any of this).