1

I need to walk a directory tree with multiple starting points e.g. find ./User1 ./User2 ./User3 but only process specific directory names that exist in any of those starting points, like this: ./User1/Documents ./User1/Pictures /.User2/Documents etc. I would like to skip and prune any other subfolders not in a given list from all of the starting points.

All of my attempts at multiple combinations of -not and prune with \( & \) have failed.

The starting points will be determined in a script at runtime. As a last resort, I could build up a list of starting points by looping over the list of User? and generating an even bigger list of starting points with the desired directory names already appended.

Only ./User?/NotInList should be excluded but ./User?/InList/NotInList is OK.

[The big picture: I'm doing data recovery and want to enumerate 'important' files in the Microsoft Windows Users folder. I need to skip the Public and Default folders (so I build up a string of the remaining folders as starting points for the find command), and also only select/descend into the Pictures Documents etc. folders of each user, but not the AppData or Contacts etc. folders that are rarely important.]

  • Why not just find {User1,User2,...}/{Documents,Pictures,...}? – Stéphane Chazelas Aug 12 '18 at 19:31
  • @StéphaneChazelas User1,User2 will be generated at runtime and I believe variable expansion doesn't work inside braces. Also, what if the names have spaces in them (I could use \ in the braces, but the dynamic usernames will still make trouble. – afuna Aug 12 '18 at 19:59
3

With zsh:

users=(User1 User2)
# or users=($(<userlist.txt))
# if userlist.txt contains a list of users as separate words

dirs=(Documents Pictures ...)

find ./$^users/$^dirs

Same with fish:

set users User1 User2 ...
set dirs Documents Pictures
find ./$users/$dirs

In fish or zsh -o rcexpandparam, arrays expand in a brace-expansion fashion. In zsh, the $^array syntax turns on rcexpandparam for that single array expansion.

The reference to rc is a bit misleading here. While $array^string in rc/es (where array is (1 2)) would expand like {1,2}string (and that's the reason why zsh chose ^ for its rc-like type of expansion in $^array), that doesn't apply for joining arrays together. In rc, $array1^$arrat2 (same as $array1$array2) only works for arrays of the same size and joins the element one by one ((1 2)^(a b) becomes 1a 2b, not 1a 1b 2a 2b).

Note that those are not globbing, User1/Documents will be passed to find regardless of whether that file exists or not. To pass the list of those files or directories that actually exist, in zsh, you could do

find ./$^users/$^dirs(N)

which passes adds the (N) glob qualifier to all the element resulting of that array multiplication, which has two effects:

  • makes them globs which means they will expand to matching files
  • if the glob doesn't match any file (they can only match either 0 or 1 as there's no wildcard in there), the resulting glob expands to nothing instead.

Alternatively, you could go glob all the way:

find (User1|User2)/(Documents|Pictures)

Or based on your example:

set -o extendedglob # best in ~/.zshrc
find ./^(#i)(Default|Public)/(Documents|Pictures)

Or generate that glob based on the arrays with

find (${(j:|:)~${(b)users}})/${(j:|:)~${(b)dirs}}

Where:

  • (b) quotes the wildcard operators if any in the elements of the array
  • (j:|:) joins them with |
  • ~ turns on globbing upon the expansion

Those work with arbitrary file names (except those starting with - as a limitation of find).

To do it with find alone would be very tricky, especially if you want to allow arbitrary file names. But for your relatively tamed ones like those Default, Public...), you could try:

LC_ALL=C find . -name . -o -path './*/*' \( ! -path './*/*/*' \
  ! -name Documents ! -name Pictures -prune -o -print \) -o   \
  ! -name Default ! -name Public -o -prune

(This kind of thing gives me headaches).

That's using standard find syntax, if you want a case insensitive match, you can use -name '[pP][uU][bB][lL][iI][cC]', or if using GNU find (which you seem to) or compatible, use -iname Public.

2

Judging by the answer you posted yourself, you want to pick the directories ./x/y, where x is any of the directories in the current directory, except Public or Default, and y is any of Documents, Pictures, or My Documents.

In Bash, you can do that with a glob when extglob is enabled. (In ksh, it's the default when the pattern is directly on the command line; in Zsh, you need to use setopt kshglob.)

Given:

$ mkdir -p {Someuser,"other user",Public,Default}/{Pictures,"My Documents",Uselessdir}

the glob here should do what you want:

$ shopt -s extglob
$ printf "%s\n" ./!(Public|Default)/@(Documents|My Documents|Pictures)
./other user/My Documents
./other user/Pictures
./Someuser/My Documents
./Someuser/Pictures

So, you should be able to just run

shopt -s extglob
find ./!(Public|Default)/@(Documents|My Documents|Pictures) ...

The glob will not expand to directories that don't exist, so you don't get errors from find if say ./Someuser/Pictures doesn't exist.

Add shopt -s nocaseglob if you want a case-insensitive match.

  • Thanks! Is extglob case insensitive? – afuna Aug 13 '18 at 12:00
  • 1
    To answer my own comment, use shopt -s nocaseglob to ignore case when globbing – afuna Aug 13 '18 at 12:23
0

Based on @StephaneChazelas comment using brace expansion, here's the solution I went with for bash that works with spaces in filenames and handles edge cases like only one user.

The string for brace expansion will be stored in a variable, so we need to use eval to actually expand it.

# escape spaces with backslash
directories=Documents,Pictures,My\ Documents

# get usernames to use with expansion
# and escape any spaces using sed
user_folders=$(find . -maxdepth 1 -mindepth 1 -not -iname Public -a -not -iname Default -type d -printf "%f," | \
sed 's| |\\ |g' )   

# remove trailing comma from last user's name
user_folders=${user_folders::-1}    

# only use brace expansion if there is more than one user; if there's only
# one value it won't expand and we'll be left with braces
[[ $user_folders = *,* ]] && user_folders="{$user_folders}"

find_string="find $user_folders/{$directories}"
eval "$find_string"
  • brace expansion not working with variables (and your need for eval here) is a limitation of bash. Other shells with brace expansion support like zsh or ksh93 don't have that limitation. Do you really have to use bash here? – Stéphane Chazelas Aug 12 '18 at 21:28
  • @StéphaneChazelas yes. it's part of a larger script with many bashisms... – afuna Aug 13 '18 at 6:52
  • Actually, that code above is zsh syntax. In bash, you'd need eval "$find_string". – Stéphane Chazelas Aug 13 '18 at 7:03
  • @StéphaneChazelas ... but (on Ubuntu) it works in bash too? – afuna Aug 13 '18 at 7:05
  • In bash eval $find_string invokes the split+glob operator so it would break on values that contain characters of $IFS or wildcard operators, see for instance Security implications of forgetting to quote a variable in bash/POSIX shells – Stéphane Chazelas Aug 13 '18 at 7:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.