batch

Freitag, 28. März 2008

BATCH_ Convert decimal number to hex string

Though hex numbers can be converted to decimals using SET /A MY_DECIMAL=0xFF, the other way around is not natively supported in Windows batch. However, the following subroutine does this job for numbers 0 <= x <= 255 (8bit).
:: Converts a decimal number to hex. Currently only numbers 0 <= x <= 255 are 
:: supported (0x00 <= x <= 0xFF). The result will NOT be prefixed with 0x!
:: 
:: @PARAM Integer decimal number 0 <= x <= 255
:: @RETURN String hex representation of number
:: @SINCE 1.0
:DEC2HEX
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET HEX=0123456789ABCDEF
SET /A T1=%1 / 16
SET /A T2=%1 %% 16
SET RESULT=!HEX:~%T1%,1!!HEX:~%T2%,1!
ENDLOCAL & SET RESULT=%RESULT%
GOTO :EOF

Note: This subroutine is part of the number.cmd library contained in the batchlib package since version 1.0.

BATCH_ Find virtual cygwin SCSI device for a CD-ROM drive

I needed a command line tool for automatic extraction (ripping) of CDDA discs (CD Digital Audio). After long hours of searching the only tool that came up was AKRip. However, AKRip is a library (DLL) and only a demo application providing a command line interface exists. As the link on the AKRip homepage is dead, I downloaded it from Sonic Spot. It's not working though, and always fails with an exception when trying to list the drives available in my system.

So I finally fell back to the ported linux application cdda2wav, running under windows using cygwin. Unfortunately cdda2wav accesses CD-ROM drives using (emulated and virtual) SCSI devices in the form of Bus,ID,Lun, e.g. 0,3,0. Here is what cdda2wav.exe -scanbus tells me what I have:
scsibus0:
        0,0,0     0) *
        0,1,0     1) *
        0,2,0     2) 'HL-DT-ST' 'CD-RW GCE-8320B ' '1.04' Removable CD-ROM
        0,3,0     3) 'LG (KOR)' 'DVD-ROM DRD8120B' '1e05' Removable CD-ROM
        0,4,0     4) *
        0,5,0     5) *
        0,6,0     6) *
        0,7,0     7) HOST ADAPTOR
scsibus1:
        1,0,0   100) 'KB9081Z ' 'YHQ276H         ' '1.0 ' Removable CD-ROM
        1,1,0   101) *
        1,2,0   102) *
        1,3,0   103) *
        1,4,0   104) *
        1,5,0   105) *
        1,6,0   106) *
        1,7,0   107) HOST ADAPTOR
Note that running cdda2wav with and without Administrator priviledges might return different results! Now I need to find a way to map these virtual SCSI devices to the drive letters Windows uses. It should look like this:
X: -> 1,0,0
Y: -> 0,3,0
Z: -> 0,2,0
I figured out a solution in three varying ways that should work on Windows 2000 systems and newer (XP, 2003). I will start to explain these with the easyest, most simple yet most specific solution, and fall back step by step to the most complicated but compatible one.


WMIC

WMIC, the Windows Management Instrumentation Console, is a native tool since Windows XP Professional and 2003 Server. Although I only need it to query information, it needs Administrator priviledges. Okay, what information does WMIC provide for my CD-ROM drives? Using the command WMIC.EXE Path Win32_CDROMDrive GET * /VALUE /format:list tells me this (output shortened to one drive only):
Availability=3
Capabilities={3,7}
CapabilityDescriptions=
Caption=HL-DT-ST CD-RW GCE-8320B
CompressionMethod=
ConfigManagerErrorCode=0
ConfigManagerUserConfig=FALSE
CreationClassName=Win32_CDROMDrive
DefaultBlockSize=
Description=CD-ROM Drive
DeviceID=IDE\CDROMHL-DT-ST_CD-RW_GCE-8320B________________1.04____\4&amp;2EA472A
E&amp;0&amp;1.0.0
Drive=Z:
DriveIntegrity=
ErrorCleared=
ErrorDescription=
ErrorMethodology=
FileSystemFlags=
FileSystemFlagsEx=
Id=Z:
InstallDate=
LastErrorCode=
Manufacturer=(Standard CD-ROM drives)
MaxBlockSize=
MaximumComponentLength=
MaxMediaSize=
MediaLoaded=FALSE
MediaType=CD-ROM
MfrAssignedRevisionLevel=
MinBlockSize=
Name=HL-DT-ST CD-RW GCE-8320B
NeedsCleaning=
NumberOfMediaSupported=
PNPDeviceID=IDE\CDROMHL-DT-ST_CD-RW_GCE-8320B________________1.04____\4&amp;2EA4
72AE&amp;0&amp;1.0.0
PowerManagementCapabilities=
PowerManagementSupported=
RevisionLevel=
SCSIBus=0
SCSILogicalUnit=0
SCSIPort=0
SCSITargetId=2
Size=
Status=OK
StatusInfo=
SystemCreationClassName=Win32_ComputerSystem
SystemName=CYPRESSOR
TransferRate=
VolumeName=
VolumeSerialNumber=
I marked the important information. There seems to be some information in SCSI writing, namely SCSIBus, SCSITargetId and SCSILogicalUnit. Using these I get 0,2,0 for drive Z: which is exactly what I wanted (see beginning). But how bad - looking at Drive X:, WMIC says 0,0,0 which is obviously wrong. AFAIK only happy guessing could lead to the correct Bus number, but I don't like guessing in automated scripts. That's why I marked the drive name, too, because it seems to be similar to the name cdda2wav told me. Let's compare drive names. First, extract the information from cdda2wav's output to return the device for a specified name:
:: Returns the cygwin SCSI CD-ROM device Bus,ID,Lun (e.g. 1,3,0) for the
:: provided drive name (e.g. "HL-DT-ST CD-RW GCE-8320B").
::
:: @PARAM String drive name
:: @RETURN String cygwin SCSI device
:: @REQUIRES tools\cdda2wav.exe
:: @REQUIRES lib\string.cmd
:GET_SCSI_CDROM_DEVICE_FOR_NAME
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
FOR /F "usebackq tokens=1,3,5,7 delims='	" %%A IN (`^
    %~dps0tools\cdda2wav.exe -scanbus 2^>^&1 ^| FIND "Removable CD-ROM"`) DO (
  :: A=device, B=vendor, C=model, D=version
  CALL %~dps0lib\string.cmd :TRIM "%%B"
  SET VENDOR=!RESULT!
  CALL %~dps0lib\string.cmd :TRIM "%%C"
  SET MODEL=!RESULT!
  SET NAME=!VENDOR! !MODEL!
  IF "!NAME!" == "%~1" (
    ENDLOCAL & SET RESULT=%%A
    GOTO :EOF
  )
)
ECHO ERROR: No CD-ROM device by the name '%~1' could be found. >&2
ENDLOCAL & SET RESULT=
GOTO :EOF
Now, I can query the drive name for a specific drive letter using the command WMIC.EXE Path Win32_CDROMDrive WHERE Drive='X:' GET Name /format:csv >NUL. SCSI drive name's will be postfixed with "SCSI CdRom Device", so that string has to be manually removed from the name. Here is all what it takes to get the device for a specific drive letter:
:: Returns the cygwin SCSI device Bus,ID,Lun (e.g. 1,3,0) for the provided
:: Windows drive letter (e.g. D:) using WMIC.EXE.
:: NOTE: WMIC is natively available since Windows XP/2003 and requires
:: Administrator priviledges.
::
:: @INTERNAL use :GET_SCSI_CDROM_DEVICE_FOR_DRIVE instead
:: @PARAM String Windows drive letter including colon
:: @RETURN String cygwin SCSI device
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_WMIC
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
FOR /F "usebackq skip=2 tokens=2 delims=," %%N IN (`^
    WMIC.EXE Path Win32_CDROMDrive ^
        WHERE Drive^='%~1' GET Name /format:csv 2^>NUL`) DO (
  SET DRIVENAME=%%N
  SET DRIVENAME=!DRIVENAME: SCSI CdRom Device=!
)
IF DEFINED DRIVENAME (
  CALL :GET_SCSI_CDROM_DEVICE_FOR_NAME "!DRIVENAME!"
) ELSE (
  ECHO ERROR: No CD-ROM drive could be found for drive letter '%~1'. >&2
  SET RESULT=
)
ENDLOCAL & SET RESULT=%RESULT%
GOTO :EOF




REG

Unfortunately WMIC need Administrator priviledges as stated above. So I thought about another method for retrieving data about CD-ROM drives. All the information that WMIC returns is available in the registry.
First, the used drive letters can be found in HKLM\SYSTEM\MountedDevices, named like \DosDevices\X:. The value is a 16bit little-endian hex string, storing something like \??\SCSI#CdRom&Ven_KB9081Z&Prod_YHQ276H&Rev_1.0#5&3a3a60d9&0&000#{53f5630d-b6bf-11d0-94f2-00a0c91efb8b}. Replace the # through backslashes, remove the first 4 characters and the last element \{53f5630d-b6bf-11d0-94f2-00a0c91efb8b} and you get the registry path below HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Enum\. And here I find the FriendlyName of our drive, in that case KB9081Z YHQ276H SCSI CdRom Device. From here on it's the very same as with WMIC.

Three simple steps to be taken:
  1. Query the drive letter from registry, convert the hex value to an ASCII string, and fix the registry path.
  2. Open the registry path and check if device's Class is CDROM.
  3. Query the FriendlyName.
:: Returns the cygwin SCSI CD-ROM device Bus,ID,Lun (e.g. 1,3,0) for the 
:: provided Windows drive letter (e.g. D:) using REG.EXE.
:: NOTE: REG.EXE is natively available since Windows XP/2003.
::
:: @INTERNAL use :GET_SCSI_CDROM_DEVICE_FOR_DRIVE instead
:: @PARAM String Windows drive letter including colon
:: @RETURN String cygwin SCSI device
:: @REQUIRES lib\string.cmd
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REG
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
FOR /F "usebackq tokens=3 delims=	" %%B IN (`^
    REG.EXE QUERY "HKLM\SYSTEM\MountedDevices" ^| ^
    FINDSTR /R "\\DosDevices\\%~1.*REG_BINARY.*5C00"`) DO (
  SET LINE=%%B
  :: remove high bytes (reduce 16bit hex to 8bit hex numbers)
  SET LINE=!LINE:000=0,!
  SET LINE=!LINE:00=,!
  :: extract registry path
  CALL %~dps0lib\string.cmd :HEX2ASC "!LINE:~0,-1!" ","
  SET REGPATH=!RESULT:~4,-39!
  SET REGPATH=HKLM\SYSTEM\CurrentControlSet\Enum\!REGPATH:#=\!
)
SET CDROM_DEVICE=
IF DEFINED REGPATH (
  FOR /F "tokens=1,2* delims=	 " %%C IN ('^
      REG.EXE QUERY "!REGPATH!" ^| ^
      FINDSTR /R "Class[^^A-Za-z0-9_-].*REG_SZ"') DO (
    IF "%%C" == "Class" IF "%%E" == "CDROM" (
      FOR /F "tokens=2* delims=	" %%M IN ('^
          REG.EXE QUERY "!REGPATH!" ^| FINDSTR /R "FriendlyName.*REG_SZ"') DO (
        SET DRIVENAME=%%N
        CALL :GET_SCSI_CDROM_DEVICE_FOR_NAME "!DRIVENAME: SCSI CdRom Device=!"
        SET CDROM_DEVICE=!RESULT!
      )
    )
  )
)
IF NOT DEFINED CDROM_DEVICE (
  ECHO ERROR: No CD-ROM drive could be found for drive letter '%~1'. >&2
)
ENDLOCAL & SET RESULT=%CDROM_DEVICE%
GOTO :EOF
Instead of converting little endian to big endian on the hex string, I simply remove all high bytes - which are always 00. Additionally I insert a comma as a delimiter as needed by the :HEX2ASC method. At first, I walked through the string, copying every first and second character, inserting a comma and simply omitting every third and fourth character. As this method was veeeery slow, I switched to the quick and dirty solution. Nevertheless, here is the good one:
:: remove high bytes and replace by comma
SET LINE_FIXED=
CALL %~dps0lib\string.cmd :STRLEN "!LINE!"
SET /A MAX=!RESULT! - 1
FOR /L %%I IN (0,2,!MAX!) DO (
  SET /A MOD=%%I%%4
  IF !MOD! == 0 (
    CALL %~dps0lib\string.cmd :SUBSTR "!LINE!" %%I 2
    SET LINE_FIXED=!LINE_FIXED!!RESULT!,
  )
)




REGEDIT

The last solution that should also work on Windows 2000 systems is much more complex. It works basically the same way as the REG.EXE one, however I have to export the registry keys to a file and work on that one. The problem is that long keys are spanned over multiple lines, indicated by a backslash at the end of the line. So I have to scan the file until finding my drive, and then starting concatenating the string until the last backslash. However I am using a little performance tweak here: Instead of checking each read line for my drive letter, I first retrieve the line where it is using FINDSTR, then start scanning until that line and begin concatenation. Testing each line was way to slow...
:: Returns the cygwin SCSI CD-ROM device Bus,ID,Lun (e.g. 1,3,0) for the 
:: provided Windows drive letter (e.g. D:) using REGEDIT.EXE.
::
:: @INTERNAL use :GET_SCSI_CDROM_DEVICE_FOR_DRIVE instead
:: @PARAM String Windows drive letter including colon
:: @RETURN String cygwin SCSI device
:: @REQUIRES lib\io.cmd
:: @REQUIRES lib\string.cmd
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REGEDIT
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
:: export registry key HKLM\SYSTEM\MountedDevices to a file
CALL %~dps0lib\io.cmd :CREATE_TEMPFILE
SET REG_FILENAME=!RESULT!
REGEDIT.EXE /E "!REG_FILENAME!" "HKEY_LOCAL_MACHINE\SYSTEM\MountedDevices"
:: parse registry file excluding empty lines
SET HEX=
FOR /F "usebackq tokens=1 delims=:" %%I IN (`^
    TYPE "!REG_FILENAME!" ^| FINDSTR /R /V "^^$" ^| ^
    FINDSTR /R /N "^^.\\\\DosDevices\\\\%~1.=hex:5c,00"`) DO (
  :: %%I is the line number where the searched key starts
  SET CURRENTLINE=
  SET LINESTR=
  SET LINENR=0
  :: as a key might span over multiple lines, concat these lines
  FOR /F "usebackq tokens=1,2* delims=: " %%M IN (`TYPE data.reg ^| FINDSTR /R /V "^^$"`) DO (
    SET /A LINENR=!LINENR! + 1
    :: found the key, check for spanning (line ending with backslash "\")
    IF !LINENR! == %%I (
      SET CURRENTLINE=%%O
      IF "!CURRENTLINE:~-1!" == "\" (
        SET LINESTR="!CURRENTLINE:~0,-1!"
      ) ELSE (
        SET HEX=!CURRENTLINE!
	GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REGEDIT_GET_NAME
      )
    ) ELSE IF DEFINED LINESTR (
      SET CURRENTLINE=%%M
      IF "!CURRENTLINE:~-1!" == "\" (
        SET LINESTR="!LINESTR:~1,-1!!CURRENTLINE:~0,-1!"
      ) ELSE (
        SET HEX=!LINESTR:~1,-1!!CURRENTLINE!
	SET LINESTR=
	GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REGEDIT_GET_NAME
      )
    )
  )
)
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REGEDIT_GET_NAME
SET CDROM_DEVICE=
IF DEFINED HEX (
  :: quick fix: convert 16bit hex to 8bit by removing high byte
  SET HEX=!HEX:,00=!
  CALL %~dps0lib\string.cmd :HEX2ASC "!HEX!" ","
  SET REGPATH=!RESULT:~4,-39!
  SET REGPATH=HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Enum\!REGPATH:#=\!
  REGEDIT.EXE /E "!REG_FILENAME!" "!REGPATH!"
  FOR /F %%X IN ('TYPE "!REG_FILENAME!" ^| FINDSTR /R "^^.Class.=.CDROM"') DO (
    FOR /F "tokens=2 delims==" %%N IN ('TYPE "!REG_FILENAME!" ^| FINDSTR /R "^^.FriendlyName.=."') DO (
      SET DRIVENAME=%%N
      CALL :GET_SCSI_CDROM_DEVICE_FOR_NAME !DRIVENAME: SCSI CdRom Device=!
      SET CDROM_DEVICE=!RESULT!
    )
  )
)
IF NOT DEFINED CDROM_DEVICE (
  ECHO ERROR: No CD-ROM drive could be found for drive letter '%~1'. >&2
)
DEL /F "!REG_FILENAME!"
ENDLOCAL & SET RESULT=%CDROM_DEVICE%
GOTO :EOF



Finally, here is my main method that checks what solution is available and selectes that one:
:: Returns the cygwin SCSI CD-ROM device Bus,ID,Lun (e.g. 1,3,0) for the 
:: provided Windows drive letter (e.g. D:).
::
:: @PARAM String Windows drive letter including colon
:: @RETURN String cygwin SCSI device
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE
:: Provides 3 internal methods to detect the device with auto-fallback.
:: 1) WMIC.EXE (since Windows XP/2003, requires Administrator priviledges)
:: 2) Registry through REG.EXE (since Windows XP/2003)
:: 3) Registry through REGEDIT.EXE (since Windows NT)
WMIC.EXE ALIAS /? >NUL 2>&1 || GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_REG_LABEL
FOR /F "skip=2 tokens=2 delims=:" %%T IN ('WMIC.EXE ALIAS /? 2^>^&1') DO (
  IF "%%T" == "Win32 Error" GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_REG_LABEL
  GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_WMIC_LABEL
)
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_WMIC_LABEL
CALL :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_WMIC "%~1"
GOTO :EOF
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_REG_LABEL
REG.EXE EXPORT /? >NUL 2>&1 || GOTO :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_REGEDIT_LABEL
CALL :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REG "%~1"
GOTO :EOF
:GET_SCSI_CDROM_DEVICE_FOR_DRIVE_REGEDIT_LABEL
CALL :GET_SCSI_CDROM_DEVICE_FOR_DRIVE_BY_REGEDIT "%~1"
GOTO :EOF

Copy that stuff into one file, copy my libraries to lib\io.cmd, lib\string.cmd and lib\number.cmd and add tools\cdda2wav.exe including the tools\cygwin1.dll. Now by typing CALL :GET_SCSI_CDROM_DEVICE_FOR_DRIVE "X:" you will receive the SCSI device in the variable %RESULT%.

Freitag, 30. November 2007

BATCH_ Trim String

My first solution I came up with involved substring replacement including a helper string added before and after the real string. This works as long as that helper string does not appear anywhere in the original string.
:: Trims whitespace at beginning and end of a string.
:: PARAM
:: 1 string to trim
:: RETURN String trimmed string
:TRIM
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET HLPSTR=#####%RANDOM%#####
SET STR="!HLPSTR!%~1!HLPSTR!"& SET STR=!STR:~1,-1!
:TRIM_INNER_LOOP
IF "!STR:%HLPSTR% =%HLPSTR%!" NEQ "!STR!" (
    SET STR="!STR:%HLPSTR% =%HLPSTR%!"& SET STR=!STR:~1,-1!
    GOTO :TRIM_INNER_LOOP
) ELSE IF "!STR: %HLPSTR%=%HLPSTR%!" NEQ "!STR!" (
    SET STR="!STR: %HLPSTR%=%HLPSTR%!"& SET STR=!STR:~1,-1!
    GOTO :TRIM_INNER_LOOP
)
SET STR="!STR:%HLPSTR%=!"& SET STR=!STR:~1,-1!
ENDLOCAL & SET RESULT="%STR%"& SET RESULT=!RESULT:~1,-1!
GOTO :EOF
So after thinking about that for a while (~10 seconds), it came to my mind that I do have a way to retrieve the first or the last character of a string. So this solution followed:
:: Trims whitespace at beginning and end of a string.
:: PARAM
:: 1 string to trim
:: RETURN String trimmed string
:TRIM
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET STR="%~1"& SET STR=!STR:~1,-1!
:TRIM_INNER_LOOP
IF "!STR!" NEQ "" (
    IF "!STR:~0,1!" == " " (
        SET STR=!STR:~1!
    ) ELSE IF "!STR:0,1!" == "	" (
        SET STR=!STR:~1!
    ) ELSE IF "!STR:~-1!" == " " (
        SET STR=!STR:~0,-1!
    ) ELSE IF "!STR:~-1!" == "	" (
        SET STR=!STR:~0,-1!
    ) ELSE (
        GOTO :TRIM_INNER_LOOP_END
    )
    GOTO :TRIM_INNER_LOOP
)
:TRIM_INNER_LOOP_END
ENDLOCAL & SET RESULT="%STR%"& SET RESULT=!RESULT:~1,-1!
GOTO :EOF
What you cannot see: I compare against space and then against tab. But I disliked the horrible IF-ELSE comparison stuff and ended with replacement check against a whitespace-string:
:: Trims whitespace at beginning and end of a string.
:: PARAM
:: 1 string to trim
:: RETURN String trimmed string
:TRIM
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET STR="%~1"& SET STR=!STR:~1,-1!
:: whitespace is space and tab
SET WHITESPACE= 	
:TRIM_INNER_LOOP
IF "!STR!" NEQ "" (
    IF "!WHITESPACE:%STR:~0,1%=!" NEQ "!WHITESPACE!" (
        SET STR=!STR:~1!
        GOTO :TRIM_INNER_LOOP
    ) ELSE IF "!WHITESPACE:%STR:~-1%=!" NEQ "!WHITESPACE!" (
        SET STR=!STR:~0,-1!
        GOTO :TRIM_INNER_LOOP
    )
)
ENDLOCAL & SET RESULT="%STR%"& SET RESULT=!RESULT:~1,-1!
GOTO :EOF

Mittwoch, 28. November 2007

BATCH_ Create libraries with valuable Batch subroutines

If you write some Batch subroutines that make common stuff like calculating the string length or testing for a number, you don't want to copy&paste these into each script that needs these. Be cool, there is a very easy solution how you can bundle all of your helper subroutines into one (or even more) files. Just copy them into an empty file, and add the following code at the very first line:
SHIFT /1 & GOTO %~1

That jumps to the label you provided as the first parameter after shifting all remaining parameters one down leaving %0 as it is.

Let's say you saved that file as C:\Windows\lib.cmd. Now you can call any subroutine from another script by issuing
CALL %SystemRoot%\lib.cmd :MY_SUBROUTINE param1 param2 ...

Remember to enable extensions and delayed variable expansion in each of your subroutines when needed - you cannot depend on the "frame" script's setting any more!
:MY_SUBROUTINE
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
... routine here ...
ENDLOCAL
GOTO :EOF

BATCH_ Test a String for Number

Testing a String for a number that can only contain the ciphers 0 to 9 and optionally a prefixed - character can be done with the following subroutine.
:: Checks if the first parameter is a number, that is it only contains the
:: characters 0 to 9 and additionally the minus as the first character.
:: PARAM
:: 1 string to test for number
:: RETURN String "true" if string is a valid number, "false" otherwise
:IS_NUMBER
SETLOCAL
FOR /F "tokens=*" %%A IN ('SET /A %~1 2^>^&1') DO (
    IF "%~1" == "%%A" (
       SET RESULT=true
    ) ELSE (
       SET RESULT=false
    )
)
ENDLOCAL & SET RESULT=%RESULT%
GOTO :EOF
This tests the input string using the SET /A arithmetic extension.
However this test two limits I can think of:
  • As SET /A echoes the error Missing operand. on invalid input, a test against that very string will return true.
  • A number with leading zeros like 007 will not be recognized as a number.
If you cannot live with these limitations, you have to replace this relatively fast solution with two loops doing something like this:
FOR EACH CHARACTER (
    FOR %%A IN (0-9) (
        IF (%%A == CHAR) (
            GOTO :END_INNER
        )
    )
    GOTO:EOF & FAIL
    :END_INNER
)

BATCH_ Redirect special characters into file

ECHOing special characters like german umlauts in Batch or creating files with such in their name does not cause any trouble. However, when redirecting into files
DIR /B *.* > list.txt
these files will be created using the same codepage (or encoding) like the command line. However, as that codepage differs in most cases from the encoding that is used to read that file in external programs or editors, special characters are not being displayed correctly.

Assuming that you are using a standard format for your textfiles, you can switch the encoding before redirection.
FOR /F "tokens=1* delims:" %%A IN ('CHCP') DO (
    SET /A CODEPAGE = %%B 2> NUL
)
CHCP 1252 > NUL
DIR /B *.* > list.txt
CHCP %CODEPAGE% > NUL
CHCP gives you the current codepage, however the output is localized (e.g. Active code page: 850 in English or Aktive Codepage: 850. in German). Therefore the tokens are delimited by the colon, which should work for most languages, I guess.
However, as you can see, the German output is ended with a dot, so that one is removed by converting the token to a number using SET /A. As that expression will work but give you a warning message about a missing operator (caused by the dot), redirection of error messages to NUL is added.

The rest ist simple: switching to standard text codepage 1252, redirecting and switching back to the original codepage.

Dienstag, 27. November 2007

BATCH_ Subroutines in Windows Batch

Since Windows 2000 you can use subroutines (depending on the understanding and definition of "function" and "procedure", one can also refer to them as "procedures", that can manipulate both local and global variables and don't return any value ("void")).

Basically, a subroutine is a number of commands between a label and a GOTO:EOF statement.
:MY_SUBROUTINE
ECHO Called MY_SUBROUTINE and ending now...
GOTO:EOF

GOTO:EOF is often written without a space, as it is used as a special GOTO command: It jumps to the End of File, thus ending the currently executed script.

That is the reason why a subroutine is not called with GOTO :MY_SUBROUTINE, but with
CALL :MY_SUBROUTINE

This executes the current batch AGAIN in the current context, but jumps immediately to the given label. And that is the reason why ending the script at the end of the subroutine - it just ends the execution started through the CALL command.

A subroutine has its own parameters, you don't have access to the "parent" batch parameters unless they were stored in an environment variable.
CALL :MY_SUBROUTINE "param 1" "param 2"
GOTO:EOF

:MY_SUBROUTINE
ECHO The first parameter is %~1.
ECHO The second parameter is %~2.
GOTO:EOF

You can see that there is another GOTO:EOF after the CALL command. You don't want your first subroutine being executed after your script finished, do you? This is the basic structure of a batch script using subroutines:
@ECHO off
REM Documentation here
REM Version 1.0 of 2007/11/11

... preprocessing, setting variables etc ...
... script body ....

GOTO:EOF
REM ############################
REM SUBROUTINES
:MY_SUBROUTINE
... subroutine body
GOTO:EOF


What about the local context I mentioned at the beginning? That does not differ from standard batch scripts:
:MY_SUBROUTINE
SETLOCAL
... local context here ...
ENDLOCAL
GOTO:EOF


As subroutines do not provide a way of returning any value (and, of course, there is no direct way to store the result of an execution in a variable), here is what I do. By convention, I reserve the global variable %RESULT% for storing the result of the last called subroutine that should return a value. You can think of it like putting a value onto a one-element stack.
:MY_SUBROUTINE
SET RESULT=4711
GOTO:EOF

This won't work if using local context within the subroutine, however due to the cmd.exe-nature there is way to preserve a variable after ending the local context:
:MY_SUBROUTINE
SETLOCAL
SET RESULT=4711
ENDLOCAL & SET RESULT=%RESULT%
GOTO:EOF

A little explanation: As the batch reads a full line, expands all variables, parses the line and then finally executes all contained commands in order, the important line looks like this after expansion:
ENDLOCAL & SET RESULT=4711
See, variable preserved and available in global context. Sometimes you want to preserve more than one variable, so you add additional SET statements. But be careful and do not insert a space before the ampersand - that space will contained in your variable!
ENDLOCAL & SET A=%A%& SET B=%B%& SET C=%C%

A little advise at the end: document your subroutines, otherwise you won't know what parameters it takes, what global variables it manipulates or requires or what value it "returns". I use double colons for subroutine documentation.
:: Shortens a string to the given number of characters.
:: PARAMS
:: 1 string to shorten
:: 2 number of characters
:: RESULT String shortened string
:SHSTR
SETLOCAL ENABLEDELAYEDEXPANSION
SET TEMP=%~1
ENDLOCAL & SET RESULT=!TEMP:~0,%~2!
GOTO:EOF

See also this article about handling parameters and variables containing special characters.

Sonntag, 25. November 2007

BATCH_ input parameter with Ampersand

The Ampersand

What is the ampersand? The ampersand (or And-sign, '&') is used for conditinal execution in batch scripts. So ECHO one & ECHO two will give you the output
one
two

The problem with the ampersand is, that it is a special character that can occur in paths and filenames (other characters like >, <, : etc. are not allowed!) passed to batched scripts. Of course, these characters can occur in user input, when reading text files etc, but that's not the point here because I want to focus on the ampersand only.
When it occurs in a batch script parameter, it will be either quoted or escaped:
:: Standard file
C:\>script.cmd file1.txt
:: File with spaces needs quotes
C:\>script.cmd "file 1.txt"
C:\>script.cmd "C:\Documents and Settings\file1.txt"
:: same applies to the ampersand occuring in paths
C:\>script.cmd "C:\files & more\file.txt"
:: but an ampersand itself can be also escaped
C:\>script.cmd files^&more


Batch script parameters

You can refer to parameters to your batch script with %1 to %9. You will get the parameter just as it was entered. You will not "see" the carets (^) of an escaped string. So if the first parameter entered is "C:\files & more\file.txt", an ECHO will give you exactly that phrase:
ECHO My input is %1.
results in
My input is "C:\files & more\file.txt".

You can also assign the parameter's value to an environment variable with the statement SET PARAM=%1. But what can we do about the annoying quotes around the string? The batch allows us multiple parameter substitutions, which all look like %~[op]n. So %~d1 gives us the drive name of the first parameter regarding it as an absolute or relative file path. All substitutions remove the optional surrounding quotes; if you just want to remove the quotes, you use %~1 without any operator.

But if the string contains an escaped ampersand or you remove the surrounding quotes from a string with an ampersand, that character will be treated by the interpretor as the conditional command execution character which will obviously break you script.

Parsing and executing a batch script

The way the batch interpretor works itself through a batch file is very special. In short this looks like this:
  1. Read a line. Round brackets spanning lines count as a single line.
  2. Expand all environment variables and batch script parameters by replacing them through their values. Optional substitution operations can be performed.
  3. Parse the line, identify commands, strings, operators etc.
  4. Perform delayed variable expansion on environment variables if enabled. Optional substitution operations can be performed.
  5. Execute all identified commands in order.
I assume you are already familiar with round brackets and substitution operations, so I will only explain the delayed variable expansion. Variables to be delayed expanded are written with exclamation marks instead of percent signs:
ECHO This %VARIABLE% expands before this !VARIABLE!.
Because the expansion takes place AFTER parsing, all special characters (including ampersand, caret, pipe etc.) will be indirectly escaped. That's why you can use this technique to handle strings with ampersands!
You enable delayed variable expansion with
SETLOCAL ENABLEDELAYEDEXPANSION


Working with Variables

As I told above, parameters with ampersand easily break your script. So you have to either quote or escape your parameters when using them. Just - you cannot escape parameters in a script, that only works for variables! Alright, let's quote - but what if a parameter is already quoted?
I told you above that through parameter substitution you can remove the quotes. With %~1 you are left with an unquoted string possibly containing one ore ampersands waiting to break your script. So ALWAYS use something like
ECHO "This is my first parameter: %~1."
SET PARAM="%~1"
IF "%~1" == "a" ECHO First parameter is a!

Okay, now you have a quoted string - but that sometimes sucks like in the ECHO line, because that command also echoes the quotes.
Aaaahh, yes, I wrote something about substitution operations.
SET STR="%~1"
ECHO This is my parameter: %STR:~1,-1%.
That removes the first and the last character, which are my manually added quotes. But dammit, it's unquoted now which breaks the script again! Hmmm, wait, there was something about "delayed" variable expansion after parsing? Right, that's the way to use unquoted strings WITH ampersands! As the variables are expanded AFTER parsing, special characters won't break your code because they will always appear as strings.
SET STR="%~1"
ECHO This is my parameter: !STR:~1,-1!.
Assignment works as well:
SET STR="%~1"
SET STR=!STR:~1,-1!
An now we got it: The variable STR contains the unquoted input parameter string. So if you want to use that variable, you either have to quote it again or use delayed expansion:
ECHO The variable STR contains the value !STR!.
ECHO "The variable STR contains the value %STR%".

Hmm, but what about when one actually cannot use delayed expansion? For example when using subroutines with local variables of which one should be left after return?
:MY_SUBROUTINE
SETLOCAL
.... assign many variables here etc. ....
ENDLOCAL & SET STR="%STR:"=^"%"& SET STR=!STR:~1,-1!
GOTO :EOF
This special technique to allow both local and global context needs two SET statements (note the missing space between quote and ampersand!!!)? That looks really bad, but is in fact the only clean solution.
However, if you are sure that the only special character contained in that string is the ampersand (e.g. it is an original file path), you can manually escape that string:
ENDLOCAL & SET STR=%STR:&=^&%
That has the same effect like above, but only for the ampersand!

Notes

  • The %0 parameter (storing the script name) can also be placed in a path containing ampersands. So the rules stated above also apply to this parameter.
  • Avoid using %*. It merges all parameters into one single string. That means, it can contain quoted parameters, so you cannot quote %*, and it can contain unquoted, escaped parameters, so you must use quotes. You see, there is no way handling it correctly, so you should use the shift command instead.
  • A common IF expression is
    IF "%TEST%" == "abcd" GOTO :DO_IT
    This works unless the variable contains double quotes. You could use other characters like [] to surround your variables in case they are empty, however this would still break in case of an ampersand. So here, you can use two equitable construtions: escape the quotes or use delayed variable expansion:
    IF "%TEST:"=^"%" == "abcd" GOTO :DO_IT
    IF "!TEST!" == "abcd" GOTO :DO_IT
  • As for FOR constructs, you should be very careful and use delayed expansion and quotation whenever possible. Sometimes you have to work the right solution out by trial and error.

BATCH_ Strip surrounding double quotes from string

Testing a batch string for surrounding quotes is quite annoying. Here is a solution for Windows 2000 and newer:
:: %T% contains the test-string
IF "%T:~0,1%%T:~0,1%" == """" IF "%T:~-1%%T:~-1%" == """" (
  SET T=%T:~1,-1%
)
You can see, I am using the first respectively the last character twice, because a single double quote would break the IF. I'll explain it with this little example:
T keeps the string "Hello World!" with the surrounding quotes. The (simplified) test

IF %T:~0,1% == " (...)
would expand to
IF " == " (...)
which will be parsed as
IF <string>
with the string being " == " (...). Cannot work, huh?

BATCH_ String length

This little subroutine calculates the string length in Batch (cmd.exe) using the divide and conquer algorithm. It starts with the maximum string length possible under Windows XP (4191, NT and 2000 are limited to 2047, see http://support.microsoft.com/kb/830473). It divides the string in two equal halfs and tests if the character in the middle exists. If not, does that again with the left half, if it does, stores the length including that character and starts of for the rest of the string.
:: Returns the string length.
:: PARAM
:: 1 String string to get length of
:: RETURN Integer string length
:STRLEN
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET STR="%~1"& SET STR=!STR:~1,-1!
SET LEN=0
:: Max string length is 8191, see http://support.microsoft.com/kb/830473
SET POS=8192
:STRLEN_LOOP
SET /A POS /= 2
IF NOT "!STR!" == "" (
    IF NOT "!STR:~%POS%,1!" == "" (
        SET /A LEN += %POS% + 1
        :: work on rest, that is after POS+1
        SET STR=!STR:~%POS%!
        SET STR=!STR:~1!
    )
    GOTO :STRLEN_LOOP
)
ENDLOCAL & SET RESULT=%LEN%
GOTO :EOF
I have to double check the string because substring extraction %STR:~x,y% on an empty string leaves you with ~x,y. However, extracting a substring after the end of a non-empty string works as expected and returns the empty string.

I did some performance testing, and though jumping to labels is heavily dependent of script layout and size, 100 calculations of a string no matter what length lasted about 1 sec and 300 ms on an AMD Athlon XP 3200+.

If you are using only very short strings, you can loop through each character and test it for existence:
:: Calculates the string length of the first parameter.
:: PARAM
:: 1 String to return length of
:: RESULT Integer length of tring
:STRLEN
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION
SET I=-1
SET T=%~1
:STRLEN_LOOP
SET /A I += 1
IF NOT "!T!" == "" IF NOT "!T!" == "!T:~0,%I%!" (
    GOTO :STRLEN_LOOP
)
ENDLOCAL & SET RESULT=%I%
GOTO :EOF
However, the cost scales with the string length. Compared with the first solution, it is faster (less than 1 second for 100 tests) for strings shorter than 10 characters, and for 16 characters takes exactly the same amount of time.

Reflog

Informationstechnische Howtos, Hinweise und Merkwürdiges

Batchlib v1.0 2008-03-29

Aktuelle Beiträge

HOWTO_ O2 DSL Surf &...
Der O2 DSL Surf & Phone-Router ist für die alleinige...
cypressor - 12. Feb, 19:57
Uptweak Windows XP Home...
There are a lot of annoying limitations in Windows...
cypressor - 9. Okt, 19:30
BATCHLIB_ Batchlib package...
Download Batchlib package v1.0 (5 KB zip file) What...
cypressor - 29. Mär, 19:10
BATCHLIB_ Batchlib library...
The batchlib library string.cmd is part of the batchlib...
cypressor - 29. Mär, 18:10

Homepage Ticker

Links

Status

Online seit 6815 Tagen
Zuletzt aktualisiert: 28. Jun, 11:32
RSS XML 1.0 Button-Get-Firefox

batch
batchlib
howto
tech
video
Profil
Abmelden
Weblog abonnieren