2

I have been scratching my head for the last two days trying to put together a code that reads a folder structure and the strips off the base path of the listing.

What I have so far is this:

@ECHO OFF
CLS
SETLOCAL EnableExtensions EnableDelayedExpansion

SET "StartTime=%TIME: =0%"
SET "Folder=%~1"
IF "%Folder:~-1%"=="\" SET "Folder=%Folder:~0,-1%"
FOR %%G IN ("%Folder%") DO SET "Archive=%%~nxG"
ECHO Processing %Folder%...
ECHO.
PUSHD "%Folder%"
FOR /F "DELIMS=" %%G IN ('DIR /A:-D /B /O:N /S') DO (
   SET "FullPath=%%G"
   ECHO !FullPath:%Folder%\=! >> "%Archive%.$$$"
)
pause
endlocal
goto :eof

This works fine as long the directory does not have ( or ) (e.g. C:\Program Files (x86)\AMD), then throws me an error message and halts.

Cannot find any solution to bypass this limitation. Any input is greatly appreciated. Thanks!

aschipfl
  • 33,626
  • 12
  • 54
  • 99
Drakul
  • 119
  • 5
  • 1
    `^` is the escape character. See a cheat sheet about punctuation https://winsourcecode.blogspot.com/2019/12/command-prompt-cheat-sheet.html. Normal brackets are not redirection characters. Redirection characters are illegal in file names. –  Apr 26 '20 at 06:08
  • @Mark - Escaping does not easily (conveniently) help with poison characters inside already set variables. The escape would have to already be there, or else it would have to be in a string literal within the code, neither of which fits this situation. It could be done with an additional delayed expansion find/replace assignment, but as Mofi shows it is not needed. Or it could be done without using assignment using a string `FOR /F` and delayed expansion find/replace. Again, there are simpler ways. – dbenham Apr 26 '20 at 14:34

2 Answers2

3

Great explanations and solutions from Mofi

Here is a bit shorter and more direct way to do achieve a reliable solution that still relies on Mofi's fundamental technique of using the FOR /F tokens and delims to strip out the root path.

My code allows no specified path, in which case it defaults to the current directory. The code to normalize, validate, and extract the needed values from the root path is greatly simplified.

I also use a totally different method for counting the number of tokens in the root path. I substitute a newline for the \ character and then count the number of lines with FIND /C when I echo the value. It is a bit tricky, but it is very efficient and works without resorting to a GOTO loop.

I also preserve the error message if no files are found, and don't bother deleting the resultant empty file.

@echo off
setlocal disableDelayedExpansion

::==== Get normalized values for supplied path in %1
::==== Default to current directory if not specified
for %%F in ("%~f1.") do (  %=== The trailing dot allows paths with/without trailing \ to work %
  set "folder=%%~fF"   ==== The normalized path for the root folder
  set "archive=%%~nxF" ==== The base name of the output file
  set "att=-%%~aF"     ==== List of attributes for the root folder
)

::==== Validate path by checking for d in attributes
if %att:d=% == %att% (
  >&2 echo Supplied path is invalid or not a folder
  exit /b 1
)

::==== Special handling if drive root
if not defined archive (
  set "archive=drive %folder:~0,1% root"
  set cnt=1
  goto start
)

::==== Normal processing for all other paths
setlocal enableDelayedExpansion
for %%L in (^"^
%==== Puts quoted newline in FOR variable L %
^") do set "folder2=!folder:\=%%~L!"   ==== Substitutes newline for all \ in folder
::==== Count the number of tokens (lines) in folder2
setlocal disableDelayedExpansion
for /f %%N in ('"(cmd /v:on /c echo !folder2!)|find /c /v """') do set cnt=%%N

:start  ==== Finally get the sorted list of files without root path
>"%archive%.$$$" (
  for /f "delims=\ tokens=%cnt%*" %%A in ('dir "%folder%" /a:-d /b /o:n /s') do echo %%B
)

The code is quite terse without the comments and extra blank lines

@echo off
setlocal disableDelayedExpansion
for %%F in ("%~f1.") do (
  set "folder=%%~fF"
  set "archive=%%~nxF"
  set "att=-%%~aF"
)
if %att:d=% == %att% (
  >&2 echo Supplied path is invalid or not a folder
  exit /b 1
)
if not defined archive (
  set "archive=drive %folder:~0,1% root"
  set cnt=1
  goto start
)
setlocal enableDelayedExpansion
for %%L in (^"^

^") do set "folder2=!folder:\=%%~L!"
setlocal disableDelayedExpansion
for /f %%N in ('"(cmd /v:on /c echo !folder2!)|find /c /v """') do set cnt=%%N
:start
>"%archive%.$$$" (
  for /f "delims=\ tokens=%cnt%*" %%A in ('dir "%folder%" /a:-d /b /o:n /s') do echo %%B
)

Update in response to Mofi's comment

My original answer did not consider the possibility of wildcards in the input. Wildcards can be excluded by adding the following after the initial SETLOCAL

for /f "delims=*?<> tokens=2" %%A in (".%~1.") do (
  >&2 echo Wildcards * ? ^< and ^> not supported
  exit /b 1
)

Note that "<" and ">" are poorly documented wildcards (only when they are quoted within a path)!

But Mofi described an interesting behavior I had never seen before. At first I could not understand how C:\test\* was yielding C:\.

That is simply the result of %~f1 seeing the . directory entry as the first listed file/folder in the test folder, and then when my code appends its own dot, it becomes C:\test\.., which of course normalizes to C:\.

For a while I was thinking that there was special processing going on when a path node is nothing but wildcards, but I posted the behavior on DosTips and a responder reminded me of the . and .. directory entries on all but the drive root.

dbenham
  • 127,446
  • 28
  • 251
  • 390
  • The code for folder path validation was very interesting for me. So I tested the batch file with the argument strings as used by me for testing my batch file. I found out that passing to the batch file `"C:\Temp\*"` or `"C:\Temp\<"` or `"C:\Temp\>"` results in using ``C:\`` and `"C:\Temp\Test\?"` in using `C:\Temp` which was not expected by me. However, such invalid directory paths are most likely never passed to the batch file. The different approach to get the number of directories was interesting for me, too. Thank you for this alternative solution. I learned a lot from it. – Mofi Apr 29 '20 at 05:52
  • @Mofi - Very interesting, and at first puzzling result. See my updated answer. – dbenham Apr 29 '20 at 18:25
  • Wow, what a great extension to the answer with deep and absolutely right analysis on what happens on these special folder paths with wildcard patterns. Thank you very much, Dave. – Mofi Apr 29 '20 at 19:50
  • @Mofi - There is one more bit of weirdness. If the wildcard node is a folder at the volume root level, then the wildcards in that node are expanded normally. So "c:\*\*" becomes "c:\$Recycle.Bin\." on my machine. – dbenham Apr 30 '20 at 04:28
  • `%~f1` in batch file called with `"C:**"` expands on my tests on Windows XP to either first entry in root directory of drive `C:` on current directory is the root directory on this drive or to current directory path with `.` appended like `C:\Windows\.` on Windows directory being the current directory on NTFS drive `C:`. The same behavior can be observed on drive `F:` which is in my case a FAT32 drive. `"F:**"` results on usage of `%~f1` in expanding to `F:\Temp\.` on current directory is `F:\Temp` on drive `F:` or to `F:\Recycled` (first root entry) on root is the current directory. – Mofi Apr 30 '20 at 05:41
  • 1
    @Mofi - OMG, I forgot about the `.` and `..` directory in all DIR listings except the drive root. Thankfully DosTips user penpen set me straight. There is no special processing after all, `*` simply sees the `.` entry first. I updated my answer yet again. – dbenham Apr 30 '20 at 12:33
2

The simple solution is to insert the following command line below SET "FullPath=%%G":

SET "RelativePath=!FullPath:%Folder%\=!"

A closing parenthesis inside a double quoted argument string is interpreted as literal character and not as end of a command block on being found inside a command block.

Then the next line is changed to:

IF DEFINED RelativePath ECHO !RelativePath!>>"%Archive%.$$$"

This line writes the relative path into the file without a trailing space as there is no space anymore left to redirection operator >> which would be output also by ECHO.

So the batch file would be:

@ECHO OFF
CLS
SETLOCAL EnableExtensions EnableDelayedExpansion
IF "%~1" == "" EXIT /B

SET "Folder=%~1"
IF "%Folder:~-1%" == "\" SET "Folder=%Folder:~0,-1%"
FOR %%G IN ("%Folder%") DO SET "Archive=%%~nxG"
DEL "%Archive%.$$$" 2>NUL
ECHO Processing %Folder%...
ECHO(
FOR /F "DELIMS=" %%G IN ('DIR "%Folder%" /A:-D /B /O:N /S') DO (
   SET "FullPath=%%G"
   SET "RelativePath=!FullPath:%Folder%\=!"
   IF DEFINED RelativePath ECHO !RelativePath!>>"%Archive%.$$$"
)
ENDLOCAL
PAUSE

But there are several problems remaining with this solution.

  1. If the folder path assigned to environment variable Folder contains an equal sign, the string substitution inside last FOR loop does not work as expected.
  2. A folder path assigned to environment variable Folder with one or more exclamation marks is not correct processed because of enabled delayed expansion.
  3. A full qualified file name output by DIR in separate command process started in background with one or more exclamation marks is not correct processed because of enabled delayed expansion.
  4. The batch file does not work as expected on being called with a root folder path of a drive like D:\ or just \ to reference the root of current drive.
  5. The batch file does not work as expected on being called with a relative path.
  6. Batch file processing is exited with a syntax error message on being called with """ as this results in the syntactically incorrect command line IF """ == "" EXIT /B during batch file execution.
  7. An invalid folder path like C:\Temp\* or C:\Temp: passed to the batch file is in some cases not detected by the batch file and results in not expected behavior.

See the Microsoft documentation page Naming Files, Paths, and Namespaces with details about relative paths.

The commented batch file below works for even those unusual use cases.

@echo off
cls
setlocal EnableExtensions DisableDelayedExpansion

rem Do nothing if batch file called without a folder path
rem or with just one or more double quotes.
set "Folder=%~1"
if defined Folder set "Folder=%Folder:"=%"
if not defined Folder (
    echo Error: %~nx0 must be called with a folder path.
    goto EndBatch
)

rem Make sure the folder path contains backslashes and not forward slashes
rem and does not contain wildcard characters or redirection operators or a
rem horizontal tab character after removing all double quotes.
set "Folder=%Folder:/=\%"
for /F "delims=*?|<>    " %%I in ("%Folder%") do if not "%Folder%" == "%%I" (
    echo Error: %~nx0 must be called with a valid folder path.
    echo        "%~1" is not a valid folder path.
    goto EndBatch
)

rem Get full folder path in case of the folder was specified with a relative
rem path. If the folder path references the root of a drive like on using
rem "C:\" or just "\", redefine the folder path with full path for root of
rem the (current) drive. Determine also the name for the archive file to
rem create by this batch file in the current directory depending on name of
rem the folder to process respectively the drive in case of a root folder.
for %%I in ("%Folder%") do (
    set "Folder=%%~fI"
    set "ArchiveFile=%%~nxI.$$$"
)
if not "%Folder:~-1%" == "\" (
    set "Folder=%Folder%\"
) else (
    if "%Folder:~1,3%" == ":\" (
        set "ArchiveFile=Drive%Folder:~0,1%.$$$"
    ) else (
        for %%I in ("%Folder%.") do set "ArchiveFile=%%~nxI.$$$"
    )
)

rem Verify the existence of the folder. The code above processed also
rem folder paths of folders not existing at all and also invalid folder
rem paths containing for example a colon not (only) after drive letter.
if not exist "%Folder%" (
    echo Error: Folder "%Folder%" does not exist.
    goto EndBatch
)

rem Determine the number of folders in folder path.
set "FolderCount=0"
set "FolderRemain=%Folder%"
:CountFolders
set /A FolderCount+=1
for /F "tokens=1* delims=\" %%I in ("%FolderRemain%") do if not "%%J" == "" set "FolderRemain=%%J" & goto CountFolders

rem Write all files with path relative to base folder into the archive file.
echo Processing "%Folder%" ...
(for /F "tokens=%FolderCount%* delims=\" %%I in ('dir "%Folder%" /A:-D /B /O:N /S 2^>nul') do echo %%J)>"%ArchiveFile%"

rem Delete the archive file on being an empty file because of no file found.
for %%I in ("%ArchiveFile%") do if %%~zI == 0 (
    del "%ArchiveFile%"
    echo(
    echo Info: There are no files in folder: "%Folder%"
)

:EndBatch
endlocal
echo(
pause

ATTENTION: There should be a horizontal tab character after the delimiters *?|<> in the batch file.

To understand the commands used and how they work, open a command prompt window, execute there the following commands, and read the displayed help pages for each command, entirely and carefully.

  • call /? ... for an explanation of %~nx0 (name of batch file without path) and %~1 (argument 1 without surrounding double quotes)
  • cls /?
  • del /?
  • dir /?
  • echo /?
  • endlocal /?
  • for /?
  • goto /?
  • if /?
  • pause /?
  • rem /?
  • set /?
  • setlocal /?

See also:

Here is the enhanced batch file once again in a more compact form without comments:

@echo off & cls & setlocal EnableExtensions DisableDelayedExpansion

set "Folder=%~1"
if defined Folder set "Folder=%Folder:"=%"
if not defined Folder echo Error: %~nx0 must be called with a folder path.& goto EndBatch

set "Folder=%Folder:/=\%"
for /F "delims=*?|<>    " %%I in ("%Folder%") do if not "%Folder%" == "%%I" (
    echo Error: %~nx0 must be called with a valid folder path.
    echo        "%~1" is not a valid folder path.
    goto EndBatch
)

for %%I in ("%Folder%") do set "Folder=%%~fI" & set "ArchiveFile=%%~nxI.$$$"
if not "%Folder:~-1%" == "\" (set "Folder=%Folder%\") else if "%Folder:~1,3%" == ":\" (set "ArchiveFile=Drive%Folder:~0,1%.$$$") else for %%I in ("%Folder%.") do set "ArchiveFile=%%~nxI.$$$"
if not exist "%Folder%" echo Error: Folder "%Folder%" does not exist.& goto EndBatch

set "FolderCount=0"
set "FolderRemain=%Folder%"
:CountFolders
set /A FolderCount+=1
for /F "tokens=1* delims=\" %%I in ("%FolderRemain%") do if not "%%J" == "" set "FolderRemain=%%J" & goto CountFolders

echo Processing "%Folder%" ...
(for /F "tokens=%FolderCount%* delims=\" %%I in ('dir "%Folder%" /A:-D /B /O:N /S 2^>nul') do echo %%J)>"%ArchiveFile%"
for %%I in ("%ArchiveFile%") do if %%~zI == 0 del "%ArchiveFile%"& echo(& echo Info: There are no files in folder: "%Folder%"

:EndBatch
endlocal & echo(& pause
Mofi
  • 46,139
  • 17
  • 80
  • 143
  • Well, I've got to hand it to you. Cannot express my gratitude in showing me the solution for this specific problem, and also solving the other that came up late today (files containing exclamation mark e.g. TheBat!). Thank you very much for effort in enlightening me about batch script language... – Drakul Apr 26 '20 at 17:00