17

So, say, I have a directory with a bunch of files like g.txt, where g.txt was last modified on, say, June 20, 2012.

How would I batch-rename all of the files (like g.txt) with the last modified date of June 20, 2012 appended on the end?

InquilineKea
  • 6,262

5 Answers5

25

here's a version of goldschrafe's one-liner that:

  • doesn't use stat

  • works with earlier versions of GNU date

  • correctly copes with any spaces in the filenames

  • also copes with filenames beginning with a dash

    for f in *; do mv -- "$f" "$f-$(date -r "$f" +%Y%m%d)"; done


Update 2021-03-20

My answer to this question has been bothering me for years (well, only when I remember it - on days like today when it gets another upvote) so I'm finally updating it. My original answer above still works, but the updated answer below is better.

Like any batch file renaming operation, this should be done with the perl rename utility, not with some klunky shell for loop.

The perl rename utility is effectively a specialised scripting language that allows you to use ANY perl code to rename files, from simple s/search/replace/ regular expression operations (which suffices for most renaming tasks) to complex multi-line scripts.

e.g.

rename -n 'BEGIN {use Date::Format};
           die $! unless -f $_;
           next if (m/-\d{8}$/);
           my $ts=(stat($_))[9];
           my $dt=time2str("%Y%m%d",$ts);
           s/$/-$dt/;' *.txt

This requires only perl and the Date::Format module (a module that is so useful that it should be installed on any system with perl. IMO it, along with the author Graham Barr's Date::Parse module, should be part of perl's core module library but it isn't so you'll have to install it with cpan or a distro package like Debian's libtimedate-perl package).

BTW, this script skips any file that looks like it already has a date (i.e. 8 digits) at the end of the filename.

or, for a fancier version that puts the date before the file's suffix (if any):

rename -n 'BEGIN {use Date::Format; use File::Basename};
           die $! unless -f $_;
           my ($filename,$dirs,$suffix) = fileparse($_,qr/\.[^.]*/);
           next if (m/-\d{8}${suffix}$/);
           my $ts=(stat($_))[9];
           my $dt=time2str("%Y%m%d",$ts);
           s/${suffix}$/-${dt}${suffix}/;' *.txt

This version has no extra requirements because the File::Basename module has been included as a standard core module with perl for as long as I can remember (a decade at least, probably longer).

Note: both rename scripts above use rename's -n (aka --nono) "dry-run" option so that the results can be tested/simulated before being applied. Remove the -n (or replace it with -v for verbose output) when you're sure that it does what you want.

Also Note: as with any other perl rename script, this can rename filenames supplied on the command line and/or from standard input. e.g. to rename all .txt files in the current directory and all sub-directories:

find . -type f -iname '*.txt' -print0 | 
   rename -0 --nofullpath -n '..........'

BTW, I have used rename's --nofullpath (aka -d, --filenmame, --nopath) option here to ensure that it renames only the filename portion of any filepaths found. It's not needed in this particular case (because the example rename scripts only change the end of the filename) but is generally a good idea when you don't want to rename the path as well as the filename (e.g. a rename script like 's/ //g' to strip spaces from filenames would try to remove any spaces in the path as well as the filename without --nofullpath, probably causing it to fail with an error).

Finally: do not confuse the perl rename script (aka File::Rename, or sometimes called prename on Fedora & RedHat, or perl-rename) with any other program called rename. Only this perl-based rename utility can rename files using arbitrary perl code as shown above, any other rename utility will have different capabilities and different & incompatible command-line options.

You can check if you have the right rename installed:

$ rename -V
/usr/bin/rename using File::Rename version 1.13, File::Rename::Options version 1.10

The executable might be called prename or perl-rename or file-rename on your system, so try -V with those, and adjust the examples above to use the correct executable name.

cas
  • 78,579
13

Quick-and-dirty Bash one-liner to rename all (globbed) files in the current directory from filename.txt to filename.txt-20120620:

for f in *; do mv -- "$f" "$f-$(stat -c %Y "$f" | date +%Y%m%d)"; done

An enterprising Bash nerd will find some edge case to break it, I'm sure. :)

Obviously, this doesn't do desirable things like checking whether a file already has something that looks like a date at the end.

  • 2
    +1, looks good. you can make it a little more robust by using '--' as the first arg to mv. guards against filenaes begining with '-' – cas Jul 18 '12 at 03:28
  • 6
    What kind of date implementation is that? My GNU date doesn't seem to handle input. – manatwork Jul 19 '12 at 07:33
  • coreutils 8.4 on RHEL 6. – jgoldschrafe Jul 19 '12 at 12:37
  • 2
    @jgoldschrafe, are you sure that code properly works for you? http://pastebin.com/nNYubf3A – manatwork Jul 19 '12 at 15:00
  • 5
    good catch manatwork. my GNU date 8.13 doesn't support reading from stdin either. It does support using the mod time of a file with "-r" - e.g. "date +%Y%m%d -r $f", so stat isn't even needed in this particular case. – cas Jul 20 '12 at 00:45
  • The code absolutely works fine for me, but Craig's definitely got the better solution with date -r. If this is uncommon, it must be some weird Red Hat customization. – jgoldschrafe Jul 20 '12 at 03:59
  • That can't work. If a date implementation behaved like that (read from its stdin when it's not asked to), it would be severely broken. – Stéphane Chazelas Dec 07 '12 at 17:14
  • In case anyone would prefer the file extension to remain at the end of the file name, there's this: for f in *; do echo "${f%.*}-$(stat -f %SB -t "%Y%m%d" $f).${f##*.}"; done So this will go from filename.txt to filename-20120620.txt – bergie3000 Dec 04 '14 at 06:54
  • @StéphaneChazelas: programs can easily find out if they're being piped to by checking where stdin is a tty or not, so it's not unheard of for programs to behave differently if data is being piped into them. I can't recall ever seeing a version of date that did that though. – cas Sep 01 '15 at 09:22
  • 1
    @cas, if date started to read its stdin as soon as it's not a tty, that would break all scripts using date that are not run in a terminal or with their stdin redirected to something. Programs that do read their stdin, like interactive ones (think shells or rm -i) may change their behaviour when stdin is not a tty, but those that are not meant to read from stdin are not going to start doing so when they're not run in a terminal. – Stéphane Chazelas Sep 01 '15 at 09:39
4

Obligatory zsh one-liner (not counting the one-time loading of optional components):

zmodload zsh/stat
autoload -U zmv
zmv -n '(*)' '$1-$(stat -F %Y%m%d +mtime -- $1)'

We use the stat builtin from the zsh/stat module, and the zmv function to rename files. And here's an extra which places the date before the extension, if any.

zmv -n '(*)' '$1:r-$(stat -F %Y%m%d +mtime -- $1)${${1:e}:+.$1:e}'
1

As I understood we don't know beforehand what is the modification date. So we need to get it from each file, format the output and rename each file in a way so that it includes the modification date in the filenames.

You can save this script as something like "modif_date.sh" and make it executable. We invoke it with the target directory as the argument:

modif_date.sh txt_collection

Where "txt_collection" is the name of the directory where we have all the files that we want to rename.

#!/bin/sh

# Override any locale setting to get known month names
export LC_ALL=c
# First we check for the argument
if [ -z "$1" ]; then
    echo "Usage: $0 directory"
    exit 1
fi

# Here we check if the argument is an absolute or relative path. It works both ways
case "${1}" in
  /*) work_dir=${1};;
  *) work_dir=${PWD}/${1};;
esac

# We need a for loop to treat file by file inside our target directory
for i in *; do
    # If the modification date is in the same year, "ls -l" shows us the timestamp.
    # So in this case we use our current year. 
    test_year=`ls -Ggl "${work_dir}/${i}" | awk '{ print $6 }'`
    case ${test_year} in *:*) 
        modif_year=`date '+%Y'`
        ;;
    *)
        modif_year=${test_year}
        ;;
    esac
    # The month output from "ls -l" is in short names. We convert it to numbers.
    name_month=`ls -Ggl "${work_dir}/${i}" | awk '{ print $4 }'`
    case ${name_month} in
            Jan) num_month=01 ;;
            Feb) num_month=02 ;;
        Mar) num_month=03 ;;
        Apr) num_month=04 ;;
        May) num_month=05 ;;
        Jun) num_month=06 ;;
        Jul) num_month=07 ;;
        Aug) num_month=08 ;;
        Sep) num_month=09 ;;
        Oct) num_month=10 ;;
        Nov) num_month=11 ;;
        Dec) num_month=12 ;;
        *) echo "ERROR!"; exit 1 ;;
    esac
    # Here is the date we will use for each file
    modif_date=`ls -Ggl "${work_dir}/${i}" | awk '{ print $5 }'`${num_month}${modif_year}
    # And finally, here we actually rename each file to include
    # the last modification date as part of the filename.
    mv "${work_dir}/${i}" "${work_dir}/${i}-${modif_date}"
done
Aline
  • 54
  • 1
    read the man page for stat(1) to find out how you can shrink your script by about 3/4...most of the above is re-inventing the wheel. in particular, 'stat -c %Y' will give you the modification time of a file in seconds since the epoch (1970-01-01 00:00:00). This can then be used as input to date(1) to format the timestamp as required. see jgoldschrafe's answer above for an example. – cas Jul 18 '12 at 05:56
  • @CraigSanders The upside is that this script works under operating systems other than Linux, which have a different stat utility or none at all. – Gilles 'SO- stop being evil' Jul 19 '12 at 00:42
  • Note that parsing the output of ls is not reliable. On some unix variants, user and group names can contain spaces, which will throw off the alignment of the date columns. – Gilles 'SO- stop being evil' Jul 19 '12 at 00:48
  • @giles: true, but anyone sensible :-) would install GNU coreutils (and all of the other GNU tools) on any non-linux system. It's the easiest way to get not only a good set of userland tools, but also a consistent set regardless of the underlying kernel. – cas Jul 19 '12 at 04:54
  • The emperor has no clothes!  …  I mean, this fails, badly.  (1) On my systems, LC_ALL=c gives an error message.  It would probably be better for this to be LC_ALL=C (capitalized).  (2) The script checks whether $1 is null (zero-length), but not whether it is a directory.  (3) Big fail: for i in * iterates over filenames in the current directory, but the rest of the script expects $i to be the name of a file in $work_dir.  … (Cont’d) – G-Man Says 'Reinstate Monica' Mar 27 '17 at 19:39
  • (Cont’d) …  (4) While the question is imprecise regarding the desired format, DDMMYYYY is a non-obvious choice. A 51-line script should document the format it uses, rather than forcing the user to burrow through somewhat cryptic code. (4b) The code would be clearer if it set num_day, *and then,* on a separate line, set modif_date from the concatenation of $num_day, $num_month, and $modif_year. (And, IMHO, it might be slightly clearer if it didn’t use so many unnecessary braces, but, yeah, I know that’s pretty much a style issue.) … (Cont’d) – G-Man Says 'Reinstate Monica' Mar 27 '17 at 19:40
  • 1
    (Cont’d) …  (5) Another bug: it assumes that any date/time displayed by ls as a date and a time (rather than a date and a year) must be in the current year.  This is not true.  It is in the past six months; that could be in the prior year (e.g., 27-Sept-2016 through 31-Dec-2016 are within the past six months).  (6) At the risk of parroting Greg’s Wiki (which @Gilles already cited), filenames that contain newline(s) and spaces can cause this to fail. … (Cont’d) – G-Man Says 'Reinstate Monica' Mar 27 '17 at 19:41
  • (Cont’d) …  A filename of z(newline)a b c d e f could cause $name_month to be set to something like Apr(newline)d and $test_year to 2011(newline)f.  (This could be fixed by the simple kludge of saying awk 'NR==1 { print $4 }'.) (7) Similarly, this blows up if subdirectories are present, because the ls commands don’t include the -d option.  (For want of a nail, …) (8) Opportunity for optimization: ls each file only once. (9) Why do we need to convert relative path names to absolute? – G-Man Says 'Reinstate Monica' Mar 27 '17 at 19:41
  • @Gilles:  I’m pinging you, because you said that the script works, and Aline seems not to be very active on the site any more. – G-Man Says 'Reinstate Monica' Mar 27 '17 at 19:42
0

Here is version of cas's one-liner, (based on goldschrafe's oneliner) extended with idempotence,

i.e. extended to prefix files with date time and to do this only for those not having date time prefix yet .

Use-case: helpful if you add new files into directory and want to add date time prefix to those not having one yet.

for f in * ; do
  if [[ "$f" != ????-??-??' '??:??:??' - '* ]]; then
    mv -v -- "$f" "$(date -r "$f" +%Y-%m-%d' '%H:%M:%S) - $f" ;
  fi;
done
  • If your files timestamps are screwed and are jpegs, you may want firs to fix them with EXIF data: for f in *.jpg ; do jhead -ft "$f" ; done source: https://unix.stackexchange.com/a/290755/9689 – Grzegorz Wierzowiecki Jun 30 '18 at 14:37