#!/usr/bin/perl # vim:fdm=marker:commentstring=#\ %s: # TODO file name length check and other file system specific checks? # TODO somehow allow directory arguments on command line # TODO dir-mode: ask for confirmation only for each directory # TODO option to skip asking for confirmations # TODO option: on errors repeat edit cycle (print errors messages as `comments' in the file (comment character: //)) # TODO not so soon: option: --move (between directories) # TODO detect whether the underlying file system handles time stamps correct - if necessary save & restore # TODO timestamps of directories might be handled differently, pay special attention on those # TODO add a mode which allows to change link targets? # TODO mvedit seems not to work under darwin # TODO what about detecting charset of the filesystem? # TODO support subversion file trees # TODO add a warning for --dirs (and in general: exclude options) that triggers when the new name exists but wasn't part of the shown listing # {{{ Modules, variables, option processing, some initializations use Fcntl; use File::Spec; use File::Find; use File::Temp qw(tempfile); use Getopt::Long; use Pod::Usage; use Fatal qw(rename); # Stop on rename error (e.g., missing permissions) my %options; GetOptions(\%options, qw( case-sensitivity=s delete dirs help|h|? editor=s maxdepth=i v|verbose )) or pod2usage(2); pod2usage(1) if $options{help}; pod2usage("?Extra argument(s) `@ARGV' given on command line") unless @ARGV==0; pod2usage("?Argument to --maxdepth must be positive.") if exists $options{maxdepth} and $options{maxdepth} < 1; pod2usage("?Option --dirs and --delete currently conflict (no directory deletion possible).") if $options{dirs} and $options{delete}; $options{'case-sensitivity'} ||= 'auto'; pod2usage("?Argument to --case-sensitivity must be `on', `off', or `auto'.") if $options{'case-sensitivity'} !~ m/^(|on|off|auto)$/; if( $options{'case-sensitivity'} eq 'auto' ) { # (this test is not fool proof...) my ($fh, $tmp) = tempfile( 'CaSETeSTXXXXX', DIR => File::Spec->curdir ); $options{'case-sensitivity'} = !-e lc $tmp; warn "!Case-" . ($options{'case-sensitivity'}?'':'in') . "sensitive file system detected.\n" if $options{v}; close $fh; unlink $tmp; } else { $options{'case-sensitivity'} = $options{'case-sensitivity'} eq 'on'; warn "!Case-" . ($options{'case-sensitivity'}?'':'in') . "sensitive file system forced.\n" if $options{v}; } my $editor = $options{editor} || $ENV{EDITOR} || 'vim'; my @DIRS, %DIR; my ($fh, $tmp) = tempfile( 'mveditXXXXX', DIR => File::Spec->tmpdir ); my ($idx,$query,$choice); # }}} # {{{ do the equivalent of `ls -a1R > $tmp' my $depth = 0; find( { preprocess => sub { $depth++; my @a = grep { !m/\n/ or warn "!ignoring file `$_' containing newline(s).\n" and 0 } @_; @a = grep -d $_, @a if $options{dirs}; push @DIRS, [$depth,$_,$File::Find::dir]; # record dir names in order of occurrence $DIR{$File::Find::dir} = [sort @a]; # store dir contents @a; }, wanted => sub { $File::Find::prune = 1 if exists $options{maxdepth} and $depth>=$options{maxdepth} }, postprocess => sub { $depth-- }, }, File::Spec->curdir ); # automatically add newlines after and between records of print $\ = $, = "\n"; for (@DIRS) { print $fh '' unless $_->[2] eq File::Spec->curdir; print $fh "$_->[2]:"; print $fh @{$DIR{$_->[2]}}; } # }}} # {{{ let the user rename the stuff using his editor my ($at,$mt) = ((stat($tmp))[8],(stat($tmp))[9]); # save access and modification times system( $editor, $tmp )==0 or unlink $tmp and die "?Executing `$editor $tmp' failed: $? ($!)\n"; unlink $tmp and die "Access and modification time of `$tmp' unchanged, nothing to do.\n" if $at==(stat($tmp))[8] and $mt== (stat($tmp))[9]; # }}} # {{{ first pass: check whether the user damaged the structure of the listing; do simple clobber check seek $fh, 0, SEEK_SET; @NEW = <$fh>; chomp @NEW; $idx = 0; for (@DIRS) { $dir = $_->[2]; if( $dir ne File::Spec->curdir and $NEW[$idx++] ne '' ) { die "?Line $idx in file `$tmp' should be a blank line, aborting.\n"; } if( $NEW[$idx++] ne "$dir:" ) { die "?Line $idx in file `$tmp' should not have been changed, aborting.\n"; } my $numdel = 0; for (@{$DIR{$dir}}) { die "?Too few lines in file `$tmp', aborting.\n" if $idx > $#NEW; $idx++; # TODO check the line numbers that are output! die "?Line $idx in file `$tmp' should not have been changed, aborting.\n" if m/^\.\.?$/ and $_ ne $NEW[$idx-1]; if( $NEW[$idx-1] eq '' ) { die "?Line $idx in file `$tmp' should not be empty, aborting (use --delete to enable file deletion).\n" if !$options{delete}; die "?Line $idx in file `$tmp' should not be empty, directory deletion is not (yet) supported.\n" if -d "$dir/$_"; $numdel++; } die "?Cannot rename file to `.' or `..' (line $idx in file `$tmp'), aborting.\n" if !m/^\.\.?$/ and $NEW[$idx-1] =~ m/^\.\.?$/; die "?Names may not contain slashes (line $idx in file `$tmp'), aborting.\n" if $NEW[$idx-1] =~ m:/:; } # essential: check that the target names are pairwise distinct (under --case-sensitivity) # $idx is the last line number of the directory block in the $tmp file my %C; if( $options{'case-sensitivity'} ) { @C{@NEW[($idx-@{$DIR{$dir}})..($idx-1)]} = 1; } else { # for case insensitive file systems map all names to lower-case (sufficient?) @C{map lc $_, @NEW[($idx-@{$DIR{$dir}})..($idx-1)]} = 1; } delete $C{''}; # deleted files don't count... if( @{$DIR{$dir}} != $numdel + keys %C ) { die "?New names in directory `$dir' are not " . ($options{'case-sensitivity'}?'':'(case-) ') . "distinct, cf. line(s) " . ($idx-@{$DIR{$dir}}+1) . " to " . ($idx) . " in file `$tmp'."; } } die "?There are extra lines in file `$tmp', aborting\n" if $idx != @NEW; # }}} # {{{ second pass: do the renames my @paths; $idx = 0; $query = 1; while ($_ = shift @DIRS) { my ($d,$name,$oldpath) = @$_; $paths[$d-1] = $name; splice @paths, $d; # cut any additional elements my $path = join '/', @paths; $idx++ if $oldpath ne File::Spec->curdir; # skip the blank $idx++; # skip the directory name my $printeddir = 0; my %curname; # track the current name of a file # (before a file gets renamed regularly, it might moved out of the way for a preceding rename) for (@{$DIR{$oldpath}}) { my ($from,$to) = ($_,$NEW[$idx]); if( $from ne $to ) { # 1. print something and possibly ask for confirmation print "In directory `$path'". ($oldpath ne $path && " (former directory `$oldpath')") . ":" unless $printeddir; $printeddir = 1; # (printf to avoid newline) printf '%s', 'mvedit: '.($to?'rename ':'delete ').(-d "$path/".($curname{$from}||$from)&& 'directory ') if $query; printf '%s', "`$from'" . (exists $curname{$_} && " (currently `$curname{$from}')") . ($to?" -> `$to'":''); if( $query ) { printf ' (y/n/a)? '; # (printf to avoid newline) $choice = ; # (newline by user) } else { $choice = 'y'; # force answer print ''; # output newline now } if( $choice =~ /^n/i ) { # user says to continue next; } elsif( !$choice =~ /^[ya]/i ) { # unknown choice, continue next; } elsif( $choice =~ /^a/i ) { # always, turn off queries $query = 0; } # 2. handle deletion (easier than renames) if( $to eq '' ) { # delete the file (we checked earlier that it is not a directory) die "?Couldn't delete `$path/".($curname{$from}||$from) . "'\n" if unlink("$path/".($curname{$from}||$from))!=1; delete $curname{$from}; next; # go on } # 3. if necessary, move something out of the way # (note that we do not need special handling with respect to case sensitivity here) if( -e "$path/$to" ) { if( length($to) > 1 && !-e "$path/".substr($to,0,-1).'~' ) { # quick one: use filename by replacing the last char with ~ (tilde) # (do not change length of the file name to avoid problems with # length limitations) $curname{$to} = substr($to,0,-1).'~'; } else { # else make use of File::Temp (TODO guarantee nicer names?) my ($fh, $tmp) = tempfile( 'mvedittmpXXXXX', DIR => $path ); $curname{$to} = substr($tmp,length($path)-1); # as tempfile() includes the path, must clip it... } rename "$path/$to", "$path/$curname{$to}"; warn "!Moved out of the way: `$to' -> `$curname{$to}'\n" if $options{v}; # $to was a directory, the @DIRS datastructure should be updated # however, directories are always included in the edited file names # and the @DIRS data structure will be updated when they are renamed } # 4. renamed directories must be updated in the @DIRS data structure! if( -d "$path/".($curname{$from}||$from) && (!exists $options{maxdepth} or $options{maxdepth} > $d) ) { my $flag = 0; for (0..$#DIRS) { if( $DIRS[$_]->[0]==$d+1 and $DIRS[$_]->[1] eq $from ) { $DIRS[$_] = [$d+1,$to,$DIRS[$_]->[2]]; $flag = 1; last; } } die "?Internal error: could not find the \@DIRS entry. (You may want to keep the file `$tmp')\n" unless $flag; } # 5. rename # (probably can be simplified wrt to $curname{$from} if( !$options{'case-sensitivity'} and (lc ($curname{$from}||$from) eq lc $to) ) { # if names only differ in cases, play it slower but safer (-> FAT) # (not sure if lc() is good enough for the intended purpose) if( length($curname{$from}||$from) > 1 && !-e "$path/".substr($curname{$from}||$from,0,-1) ) { # quick one: use intermediate filename constructed by removing the last char rename "$path/".($curname{$from}||$from), "$path/".substr($curname{$from}||$from,0,-1); rename "$path/".substr($curname{$from}||$from,0,-1), "$path/$to"; } else { # else make use of File::Temp my ($fh, $tmp) = tempfile( 'mvedittmpXXXXX', DIR => $path ); rename "$path/".($curname{$from}||$from), "$path/$tmp"; rename "$path/$tmp", "$path/$to"; } } else { rename "$path/".($curname{$from}||$from), "$path/$to"; } delete $curname{$from}; } } continue { $idx++ }; # check whether something remains in %curname (this should not happen currently!) for (keys %curname) { print "!Moved the following file out of the way: `$_' -> `$curname${$_}'"; } } # }}} # {{{ clean up close $fh; unlink $tmp; # }}} __END__ =head1 NAME mvedit - edit a recursive directory listing to rename (and delete) files =head1 SYNOPSIS B B<--help> B [B<--case-sensitivity> I] [B<--delete>] [B<--dirs>] [B<--editor> I] [B<--maxdepth> I] [B<--verbose>] =head1 DESCRIPTION B lets you edit the output of recursive directory listing (same as with C) and thus rename a great number of files in a convenient fashion. Just start the script in the directory where you want to rename files. Your editor will start up, showing the file list. Directories are introduced with their names and a trailing colon. These lines, blank lines, and the lines for the current and the parent directory must I be changed. All other lines may be edited. If the I<--delete> switch is specified, file may be deleted by emptying out their line. If you save the file and quit your editor, the script will start asking you for confirmations of the renames and deletions before performing them. If you answer with `a' (for always) at the confirmation prompt, all remaining renames and deletions will be performed unconditionally and automatically. If the scripts detects problems with your new names (e.g., if the new names are not unique) it will abort, reporting a reason, a location of the error, and a file name. You may then try to correct your error and start over by running B again and reading in the old file's contents in your editor. =head1 OPTIONS =over 4 =item B<--case-sensitivity> I Define whether the underlying file system is considered as case sensitive or not. The I argument can be `on' for case sensitive file systems, `off' for case insensitive file systems, and `auto' to make the script try to detect case sensitivity itself (this is the default). For case insensitive file systems, B's overwrite checks are different and renames only differing in case are performed using intermediate files (note that with file systems like FAT two files that only differ in case may not exist, yet the case of each single file name is stored). There is only a single setting of case sensitivity for each run of B regardless of whether different file systems are crossed. For the automatic check, the case sensitivity of the file system of the current directory is tested against. =item B<--delete> Enable file deletion by emptying - not deleting! - lines. Directory deletion is not yet supported. =item B<--dirs> Only rename directories. =item B<--help> Print help on options. =item B<--editor> I Use I to edit the file listings. If this option is not given, the editor defaults to the one specified with environment variable I or B. =item B<--maxdepth> I Descend at most I (a positive integer) levels of directories below the current working directory. For I equal to 1, only rename files / directories in the current working directory. =item B<--verbose>, B<-v> More verbose output. =back =head1 BUGS AND CAVEATS While B does allow to swap file names (e.g., renaming I to I and vice versa) it will perform such renames in multiple steps, moving a file out of the way whenever it would be overwritten. Thus, if B aborts while renaming - for example because of missing permissions - the directory tree may be in an `inconsistent' state where certain files are already renamed but not to their intended target name. Typically, this will be the original name with the last character replaced by a tilde. Also, if you rename something to an existing target name the target will be renamed implicitly even if you have specified no renaming (or, under the --dirs option, have not even seen that the target exists). The positive effect is that the script should not overwrite something silently. On the other hand it does not yet warn if this situation occurs. The script is not very file-system aware (cf. I<--case-sensitivity> above) and will not guard against illegal character or too long file names. Directory deletion is currently not supported. =head1 COPYRIGHT (C) 2002-2006 Mark Hillebrand . This code is released under the BSD License. Before using this software, visit L for the full license text.