PowerShell in Batch hybrid - How to find several different strings in a txt and append to the top only if it does not exist?

205 views Asked by At

This is my attempt at learning PowerShell. No edu background in coding/scripting. Only learned Batch by reading different examples here in StackOverflow and Superuser, trying to adapt found snippets to what is needed, and a lot of trial and error. Please be kind!

There are several strings (without the numbering) that needs to be added to a txt file sequentially and to the top of the file:

  1. [/This/IsJust.AnExample]
  2. IsThisTrue=False
  3. IsThisFalse=

I attempted to "convert" what was found to something that might look usable in a Batchfile:

start "" /wait /min powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "(Get-ChildItem 'D:\Sample Path\%sample_var%\Sample file.txt' -File -recurse | Where{!(Select-String -SimpleMatch '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' -Path $_.fullname -Quiet)} | ForEach{ $Path = $_.FullName '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)',(Get-Content $Path) | Set-Content $Path })"

Expectation:

  1. Look for each string. If it doesn't exist, add it
  • The string(s) must only be added if they/it do(es) not exist

Example: If it's entirely missing

[/MoreRandom /Example/In /Here]
MaybeThisIsCorrect=False
MaybeIAmConfused=True
MaybeINeedToLearnTheBasicsOfPowershell=True
MyEnglishIsTerrible=True
Apologetic=True

Expected:

[/This/IsJust.AnExample]
IsThisTrue=False
IsThisFalse=

[/MoreRandom /Example/In /Here]
MaybeThisIsCorrect=False
MaybeIAmConfused=True
MaybeINeedToLearnTheBasicsOfPowershell=True
MyEnglishIsTerrible=True
Apologetic=True
  • The string(s) must be always added to the top of the file in the order they were written in and reordered if they were found in other lines

Example:

<newline/blank>
<last string>
<newline/blank>
<string1>
<newline/blank>
<existing content>

Expected:

<string1>
<string2>
<last string>
<newline/blank>
<existing content>
  • The top of the page must not have any spaces or blanks

Example:

<newline/blank>
<newline/blank>
<newline/blank>
<string1>
<last string>
<string2>
<newline/blank>
<existing content>

Expected:

<string1>
<string2>
<last string>
<newline/blank>
<existing content>
  • The last string added must have a blank after to separate it from the existing content Example:
<last string>
<newline/blank>
<existing content starts here>
  1. All must be inline, no external files such as .ps1, .bat, .txt, etc.
  2. It doesn't need an -include since it's only searching in a specific file.
  3. Must be usable in a batchfile because it's gonna be added to a for loop.
  4. Always able to handle spaces in file paths
  5. Must be able to handle encoding with BOM (some snippets added random non-Latin characters)

Result of original attempt:

  • It just fails without doing anything to the txt.

Result of attempts after receiving suggestions/info:

  • start "" /wait /min powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "(Get-ChildItem 'example.txt' -File -recurse -PipelineVariable FilePath | Where{!(Select-String -SimpleMatch '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' -Path $_.fullname -Quiet)} | ForEach{ [string[]]$ToAdd = [string]::Empty; $Out = Get-Content $FilePath -raw; Switch ($Out){{$_ -notmatch [regex]::Escape('[/This/IsJust.AnExample]')}{$ToAdd += '[/This/IsJust.AnExample]'};{$_ -notmatch 'IsThisTrue=False'}{$ToAdd += 'IsThisTrue=False'};{$_ -notmatch 'IsThisFalse='}{$ToAdd += 'IsThisFalse='}};$ToAdd,$Out | Set-Content -Path $FilePath})" - works but it always adds the missing string on the top of the page not in the order they were written in. It also does not add a blank (separator) in between the last string and the first line of the existing content.
  • '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' - Adds a single line instead of checking each string if it exists
  • ('[/This/IsJust.AnExample]','IsThisTrue=False','IsThisFalse=') - Adds all strings only if all of them of are missing. If there is a single string already present, it won't add the remaining.

Notes:

  • After further digging, the file was found out to be encoded as UTF-16 LE BOM
  • The file paths are always with spaces
  • Added the missing closing parenthesis causing the snippet to crash
  • Changed -NoP -Ex -By -c to -NoProfile -ExecutionPolicy Bypass -Command as suggested by @Compo

What is causing it to fail and how to properly do it?

A link to a dummies guide on how to convert PowerShell to Batch would also be nice.

Additionally, would appreciate to know which part can be reused in order to add more strings if needed in the future.

3

There are 3 answers

13
Aacini On BEST ANSWER

Mmm... If you want that the solution works in a Batch file, why not write it in a Batch file in first place? Besides, IMHO the Batch solution is simpler than the PowerShell one and it runs much faster than the PS one, specially if you want to run it in a loop...

EDIT 2023/10/16: Code modified as requested in comments...

@echo off
setlocal EnableDelayedExpansion

rem Define the strings that needs to be reviewed
set "n=0"
set "strings="
for %%a in (
   "[/This/IsJust.AnExample]"
   "IsThisTrue=False"
   "IsThisFalse="
   ) do (
   set /A n+=1
   set "string[!n!]=%%~a"
   set "strings=!strings!/C:"%%~a" "
)

rem Get the line number of the first line of "existing content"
set "skip="
for /F "tokens=1* delims=:" %%a in ('findstr /N /V /L %strings% test.txt') do (
   if not defined skip if "%%b" neq "" set /A "skip=%%a-1"
)
if defined skip (
   if "%skip%" neq "0" (
      set "skip=skip=%skip%"
   ) else (
      set "skip="
   )
)

rem Insert the strings at beginning of output file
(
for /L %%i in (1,1,%n%) do echo !string[%%i]!
echo/
for /F "%skip% delims=" %%a in (test.txt) do echo %%a
) > output.txt

rem Last step: update input file
REM move /Y output.txt test.txt

If you want to run this code in a loop you could directly insert it in the loop, or you can convert it into a subroutine (adding a :label at beginning and an exit /B at end) and call :label in the loop

PS - You said you need this code run in a for loop, but you don't specify which value will be modified in the loop... If you do so, we could write the complete solution

EDIT 2023/10/17 New code added because there are A LOT of changes!

This question have been modified several times with new specifications each time it is modified. Let's review your examples and descriptions so far:

  • In the first example, the input have not anyone of the strings. The expected result is OK.
  • In the second example the input have strings at beginning in different order and then the <existing content>. It does NOT specify that a string could be in the <existing content>
  • In the third example the input have strings and blank lines at beginning and then the <existing content> Again, it does NOT specify that a string could be in the <existing content>
  • The fourth example is even clearer: after the <last string> the result must contain a <newline/blank> and then <existing content starts here> What this "existing content" is? The contents of the input file below the strings (as specified). You never specified that THE EXISTING CONTENT OF THE INPUT FILE COULD INCLUDE A STRING!!! If the "existing content" have a string, then it MUST BE MODIFIED (by removing such a string) when it is passed to the result file. In such a case, it can't be longer called EXISTING CONTENT!!!!! In other words: if you called it "existing content", then you used a term opposite to the desired result (so it is a term that generates confusion, instead of being descriptive)...
  • In your comments you reaffirm the same specifications: In example #3 if D E F exists in the file, insert A B C (space) at beginning. Ok. In example #4 B C D E F exists, that is, the "existing content" is D E F and this content have NOT a string!
  • Finally, in your last comment, you say that my code "keeps adding the strings in the file even if they exist". This can only happen if the strings exists IN THE EXISTING CONTENTS, that is, in any line below the strings that appear at beginning of the file. In other words: if input is D C E B F you want as output A B C (blank) D E F, but my program generates A B C (blank) D C E B F, right? Why you didn't said THAT!

You have explained A LOT of things, but not the important ones...

You should help us to help you, not hide important information that makes it difficult to help you... :(

I hope the new code below solve you problem (at least, the last one)...

@echo off
setlocal EnableDelayedExpansion

rem Define the strings that needs to be reviewed
set "n=0"
set "strings="
for %%a in (
   "[/This/IsJust.AnExample]"
   "IsThisTrue=False"
   "IsThisFalse="
   ) do (
   set /A n+=1
   set "string[!n!]=%%~a"
   set "strings=!strings!/C:"%%~a" "
)

rem Generate the output file:
(
rem First, insert the strings at beginning
for /L %%i in (1,1,%n%) do echo !string[%%i]!
echo/
rem Then, insert the lines in the file that have NOT the strings
for /F "delims=" %%a in ('findstr /V /L %strings% test.txt') do echo %%a
) > output.txt

rem Last step: update input file
REM move /Y output.txt test.txt

LAST CODE: I give up...

@echo off
setlocal EnableDelayedExpansion

rem Define the strings that needs to be reviewed
set "n=0"
set "strings="
for %%a in (
   "[/This/IsJust.AnExample]"
   "IsThisTrue=False"
   "IsThisFalse="
   ) do (
   set /A n+=1
   set "string[!n!]=%%~a"
   set "strings=!strings!/C:"%%~a" "
)

rem Process files with .ini extension
for %%f in (*.ini) do (

   rem Generate the output file:
   (
   rem First, insert the strings at beginning
   for /L %%i in (1,1,%n%) do echo !string[%%i]!
   echo/
   rem Then, insert the lines in the file that have NOT the strings
   rem Convert a UTF-16 LE BOM file to UTF-8 via TYPE command
   for /F "delims=" %%a in ('type "%%f" ^| findstr /V /L %strings%') do echo %%a
   ) > output.txt

   rem Last step: update input file
   move /Y output.txt "%%f" > NUL

)
15
TheMadTechnician On

You are running separate commands within your ForEach-Object loop, but you don't denote that they're separate. Here, this bit:

|ForEach{ $Path = $_.FullName '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)',(Get-Content $Path) | Set-Content $Path }

If that were in a script with some decent formatting would look something like this:

| ForEach-Object {
    $Path = $_.FullName
    '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)',(Get-Content $Path) | Set-Content $Path
  }

So that's two commands within the ForEach. If you want to jam them both onto one line you need a semicolon to denote the end of one command, and the start of another. So in that one-liner you have you need to put a semicolon after $Path = $_.FullName so your entire line looks like this:

start "" /wait /min powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "(Get-ChildItem 'D:\Sample Path\%sample_var%\Sample file.txt' -File -recurse | Where{!(Select-String -SimpleMatch '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' -Path $_.fullname -Quiet)} | ForEach{ $Path = $_.FullName; '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)',(Get-Content $Path) | Set-Content $Path }"

Now, that won't really do what you want it to I don't think, it'll just add that one line to the top of your file, but it will at least make your one-liner do something.

Ok, to check each string there's a few ways to do it, but I prefer the switch cmdlet rather than a series of if statements. Long hand it would look something like this, we'll jam it into one line after:

Get-ChildItem 'D:\Sample Path\%sample_var%\Sample file.txt' -File -recurse -PipelineVariable FilePath | 
    Where{!(Select-String -SimpleMatch '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' -Path $_.fullname -Quiet)} | 
    ForEach{ 
        [string[]]$ToAdd = [string]::Empty
        $Out = Get-Content $FilePath -raw
        Switch ($Out){
            {$_ -notmatch '[/This/IsJust.AnExample]'} {$ToAdd += '[/This/IsJust.AnExample]'}
            {$_ -notmatch 'IsThisTrue=False'}         {$ToAdd += 'IsThisTrue=False'}
            {$_ -notmatch 'IsThisFalse='}             {$ToAdd += 'IsThisFalse='}
        }
        $ToAdd,$Out | Set-Content -Path $FilePath
        }

Then you just add a semicolon to the end of each line and remove the whitespace. Looks horrid, but works the same:

Get-ChildItem 'D:\Sample Path\%sample_var%\Sample file.txt' -File -recurse -PipelineVariable FilePath | Where{!(Select-String -SimpleMatch '([/This/IsJust.AnExample])|(IsThisTrue=False)|(IsThisFalse=)' -Path $_.fullname -Quiet)} | ForEach{ [string[]]$ToAdd = [string]::Empty; $Out = Get-Content $FilePath -raw; Switch ($Out){{$_ -notmatch '[/This/IsJust.AnExample]'}{$ToAdd += '[/This/IsJust.AnExample]'};{$_ -notmatch 'IsThisTrue=False'}{$ToAdd += 'IsThisTrue=False'};{$_ -notmatch 'IsThisFalse='}{$ToAdd += 'IsThisFalse='}};$ToAdd,$Out | Set-Content -Path $FilePath}

Edit: Ok, so you just need to ensure that all three lines are there in order at the top of each file. In that case you may as well strip the any of the three lines that do exist out of the file and inject them at the top.

Get-ChildItem 'D:\Sample Path\%sample_var%\Sample file.txt' -File -recurse -PipelineVariable FilePath |
    ForEach-Object -Begin {$Header = "[/This/IsJust.AnExample]`nIsThisTrue=False`nIsThisFalse="} -Process {
        $RawText = (Get-Content $FilePath -Raw) -replace '(?ms)^(\[/This/IsJust\.AnExample\]|IsThisTrue=False|IsThisFalse=)\r?\n'
        $Header,$RawText -join "`n"| Set-Content -Path $FilePath
        }

That way regardless of if it is missing none, or all three of those header lines, it will remove any and then place them all in order. Here it is shortened up and made into one line:

GCI 'D:\Sample Path\%sample_var%\Sample file.txt' -File -r -Pi FP |% -B {$H = "[/This/IsJust.AnExample]`nIsThisTrue=False`nIsThisFalse="} -P {$RT=(GC $FP -Ra) -replace '(?ms)^(\[/This/IsJust\.AnExample\]|IsThisTrue=False|IsThisFalse=)\r?\n';$H,$RT -join "`n"| SC -Pat $FP}
        
2
lit On

This may be wrong, but it seems to be what you are asking.

$TargetFile = '.\Sample file.txt'
$OutputFile = '.\Sample file updated.txt'
# Create an array of the strings to look for.
# This could easily be read from a file and contain hundreds of strings.
$Patterns = @(
    '[/This/IsJust.AnExample]'
    'IsThisTrue=False'
    'IsThisFalse='
)
# Create a hash to identify if each of the patterns is found.
$TrackingHash = @{}
foreach ($Pattern in $Patterns) {
    $TrackingHash[$Pattern] = $false
}
# Examine the file and indicate if a string from the array is ever found.
# If it is found, it will not be added to the top of the output file.
Get-Content -Path $TargetFile |
    ForEach-Object {
        if ($_ -in $Patterns) { $TrackingHash[$_] = $true }
    }
# Iterate over the hash. Add any line that was not found to the LinesToAdd array.
$LinesToAdd = @()
foreach ($h in $TrackingHash.GetEnumerator()) {
    Write-Verbose "$($h.Name): $($h.Value)"
    if ($h.Value -eq $false) {
        $LinesToAdd += $h.Name
    }
}
# Write the output file containing the LinesToAdd followed by the original target file content.
$LinesToAdd + (Get-Content $TargetFile) | Set-Content -Path $OutputFile 

UPDATE:

Store the script above in a file such as Find-StringsInFile.ps1. In a cmd.exe .bat file script, run it with PowerShell Core.

pwsh -NoLogo -NoProfile -File '.\Find-StringsInFile.ps1'

If you are on the older Windows PowerShell, use:

powershell -NoLogo -NoProfile -File '.\Find-StringsInFile.ps1'

To be more useful, the PowerShell script should have parameters. That is what you would want if you want to use this "inside a batch for loop" .bat file script.