New Tools - File and Folder Archiver
Let's continue with recent additions to my PowerShell toolbox. Another recurring task is that I often want to create a ZIP file of selected files from a folder. For this newsletter, I occasionally create a zip file of content and share it on Dropbox. Using Compress-Archive
is simple enough. The hurdle was selecting the files I wanted to include.
An Object Picker
If I only need to select files from a given folder, I can easily use Out-ConsoleGridView
, since I'm using PowerShell 7 primarily. I could just as easily use Out-GridView
. If you don't have Out-ConsoleGridView
, install the Microsoft.PowerShell.ConsoleGuiTools
module from the PowerShell Gallery.
Using the Out-ConsoleGridView
cmdlet, I can select files from a folder
dir c:\temp\ -file | Out-ConsoleGridView -Title "Select files" -OutputMode Multiple
This works just like Out-GridView
but in a console window. I can select multiple files and click OK.
The selected files are returned as objects.
New-FolderArchive
Because Compress-Archive
accepts pipeline input, I can easily create a zip file with a one-line expression.
dir c:\temp\ -file | Out-ConsoleGridView -Title "Select files" -OutputMode Multiple | Compress-archive -DestinationPath c:\Work\selected.zip -CompressionLevel Optimal
To make this re-usable, I wrapped the code in a function called New-FolderArchive
.
#requires -version 7.4
#requires -module Microsoft.PowerShell.ConsoleGuiTools
#this function could be revised to use Out-GridView instead of Out-ConsoleGridView.
Function New-FolderArchive {
[cmdletbinding(SupportsShouldProcess)]
[OutputType('System.IO.FileInfo')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = "Specify the folder to use."
)]
[ValidateNotNullOrEmpty()]
[String]$Path = ".",
[Parameter(HelpMessage ="Specify the file name and path for the ZIP archive.")]
[ValidatePattern(".*\.zip$")]
[string]$Archive = ".\Archive.zip"
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
$selected = Get-ChildItem -Path $Path -file |
Out-ConsoleGridView -Title "Select files to archive" -OutputMode Multiple
if ($Selected) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Selected $($Selected.Count) files"
#An existing archive with the same name will be overwritten
if ($PSCmdlet.ShouldProcess("$($Selected.Count) selected files", "Archive to $Archive")) {
$Selected | Compress-Archive -DestinationPath $Archive -CompressionLevel Optimal -Force -PassThru
}
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close New-FolderArchive
Even though Compress-Archive
supports -WhatIf
, I'm writing my own WhatIf
handler so that I can control the message displayed when the function is run with -WhatIf
.
if ($PSCmdlet.ShouldProcess("$($Selected.Count) selected files", "Archive to $Archive")) {
$Selected | Compress-Archive -DestinationPath $Archive -CompressionLevel Optimal -Force -PassThru
}
You should use SupportsShouldProcess
in the cmdletbinding
attribute to enable the -WhatIf
and -Confirm
parameters whenever creating or modifying files.
PS C:\> New-FolderArchive -Path c:\work -Archive c:\temp\Demo.zip -WhatIf
What if: Performing the operation "Archive to c:\temp\Demo.zip" on target "12 selected files".
I expect this will meet most of my needs. However, there may be more complicated scenarios where I need to select files from multiple folders or filter files based on some criteria.
Zip Options
For more granular control over ZIP archives, I can use the System.IO.Compression
class from the .NET Framework. I wrote about this earlier this year.
I can easily create a new ZIP file.
$zip = [System.IO.Compression.ZipFile]::Open("c:\work\FooFoo.zip","Create")
The file you create must not already exist or you'll get an error.
I like that I can add a comment to the ZIP file.
$zip.comment = "This is a demo zip file"
`````
This is already an advantage over `Compress-Archive`.
I have several options for adding items to the ZIP file. I can add a file.
```powershell
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,"C:\Scripts\1000FemaleNames.txt","1000FemaleNames.txt","SmallestSize")
The parameter syntax is (ZipArchive, source file, entry name, compression level)
. The compression level can be NoCompression
, Fastest
, or Optimal
. PowerShell 7 adds 'SmallestSize
. The entry name is how the file will be stored in the ZIP file.
It isn't that difficult to add multiple files to the ZIP file.
dir c:\scripts\*names.txt | foreach {
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,$_.FullName,$_.Name,"SmallestSize")
}
When finished, I can close the ZIP file.
$zip.dispose()
This is what commits the entries to the ZIP file and writes the content to disk.
> I can't find a ways to show the comment using Windows Explorer.
Adding Folders
The code snippet I just showed you creates the files in the root of the ZIP file. There is no folder structure. To do that, you need to define an entry for each file.
$zip = [System.IO.Compression.ZipFile]::Open("c:\work\BarBar.zip","Create")
$zip.comment = "Source files for creating test accounts"
dir c:\scripts\*names.txt | foreach-object {
$entry = Join-Path -Path "TestData" -ChildPath $_.Name
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,$_.FullName,$Entry,"Optimal")
}
$zip.Dispose()
The $Entry
value is nothing more than a path which gets stored in the ZIP file.
With this approach, I can add files from different folders and create a folder structure in the ZIP file.
$zip = [System.IO.Compression.ZipFile]::Open("c:\work\Names.zip","Create")
$zip.comment = "Source files for creating test accounts"
dir c:\scripts\*names.txt | foreach-object {
Write-Host "Adding $($_.FullName)" -ForegroundColor Green
$parent = Split-Path $_.Directory -Leaf
$entry = Join-Path -Path $Parent -ChildPath $_.Name
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,$_.FullName,$Entry,"Optimal")
}
dir C:\temp\jsondata\*.json | foreach-object {
Write-Host "Adding $($_.FullName)" -ForegroundColor Green
$parent = Split-Path $_.Directory -Leaf
$entry = Join-Path -Path $Parent -ChildPath $_.Name
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,$_.FullName,$entry,"Optimal")
}
And I can still files to the root of the ZIP file.
Get-Item C:\temp\readme.html | foreach-object {
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,$_.FullName,$_.Name,"Optimal")
}
$zip.Dispose()
If the ZIP file already exists, I can open it in Update
mode and use the same code to add more files.
$zip = [System.IO.Compression.ZipFile]::Open("c:\work\Names.zip","Update")
Get-ChildItem -Path "c:\temp\foo" -Recurse | foreach-object {
#there is no method to add a folder, so you need to add each
#file and subfolder as a separate entry
if (-not $_.PSIsContainer) {
$relativePath = $_.FullName.Substring(3) # Remove the drive letter and colon
Write-Host "Adding $($_.FullName)" -ForegroundColor Green
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $relativePath, "Optimal")
}
}
$zip.Dispose()
Set-ZipArchive
With these concepts in mind, and verified with testing, I wrote a second function for more complicated scenarios. I called it Set-ZipArchive
.
Function Set-ZipArchive {
[cmdletbinding(SupportsShouldProcess,DefaultParameterSetName = 'File')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = "Specify the input files to add to the zip file.",
ParameterSetName = 'File'
)]
[ValidateNotNullOrEmpty()]
[System.IO.FileInfo]$FilePath,
[System.IO.Compression.CompressionLevel]$CompressionLevel = "Optimal",
[Parameter(ParameterSetName = 'File',HelpMessage = "Include the parent folder name in the zip file.")]
[switch]$IncludeParent,
[Parameter(
Position = 0,
HelpMessage = "Specify the input folder to add to the zip file.",
ParameterSetName = 'Folder'
)]
[ValidateNotNullOrEmpty()]
[System.IO.DirectoryInfo]$FolderPath,
[Parameter(Mandatory,HelpMessage = "Specify the path and filename to the zip file.")]
[ValidateNotNullOrEmpty()]
[Alias("Path")]
[string]$ArchivePath,
[Parameter(HelpMessage = "Append to an existing archive.")]
[switch]$Append,
[Parameter(HelpMessage ="Specify an optional comment for the ZIP archive.")]
[string]$Comment,
[Parameter(HelpMessage = "Get the zip file object.")]
[switch]$Passthru
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
if ((Test-Path $ArchivePath) -AND (-Not $Append)) {
Write-Warning "$ArchivePath already exists. Use -Append to add to the existing archive or delete the file and re-run this command."
#Bail out
Break
}
if ($Append -AND (-Not (Test-Path $ArchivePath))) {
Write-Warning "Cannot append to $ArchivePath because it does not exist."
Break
}
elseif ($Append -AND (Test-Path $ArchivePath)) {
If ($PSCmdlet.ShouldProcess($ArchivePath,"Update and append")) {
$ZipArchiveFile = [System.IO.Compression.ZipFile]::Open($ArchivePath,"Update")
}
}
else {
If ($PSCmdlet.ShouldProcess($ArchivePath,"Create")) {
$ZipArchiveFile = [System.IO.Compression.ZipFile]::Open($ArchivePath,"Create")
}
}
if ($Comment -AND ($PSCmdlet.ShouldProcess("Add comment to $ArchivePath","Comment: $Comment"))) {
$ZipArchiveFile.comment = $Comment
}
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Processing archive $ArchivePath"
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Detected parameter set $($PSCmdlet.ParameterSetName)"
#is the input a file or a folder?
If ($PSCmdlet.ParameterSetName -eq 'File') {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Archiving file $FilePath"
If ($IncludeParent) {
$parent = Split-Path -Path $FilePath.Directory -Leaf
$entry = Join-Path -Path $parent -ChildPath $FilePath.Name
if ($PSCmdlet.ShouldProcess($ArchivePath,"Add $FilePath with parent")) {
[void]([System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($ZipArchiveFile,$FilePath.FullName,$entry,$CompressionLevel))
}
}
else {
if ($PSCmdlet.ShouldProcess($ArchivePath,"Add $FilePath")) {
[void]([System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($ZipArchiveFile,$_.FullName,$_.Name,$CompressionLevel))
}
}
} #file
else {
#folder assume recurse
Get-ChildItem $FolderPath -Recurse | foreach-object {
if (-not $_.PSIsContainer -AND ($PSCmdlet.ShouldProcess($ArchivePath,"Add $($_.FullName)"))) {
$relativePath = $_.FullName.Substring(3) # Remove the drive letter and colon
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($ZipArchiveFile, $_.FullName, $relativePath, $CompressionLevel)
}
} #foreach
}
} #process
End {
if ($ZipArchiveFile) { $ZipArchiveFile.Dispose()}
If ($Passthru -AND $ZipArchiveFile) {
Get-Item -Path $ArchivePath
}
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
}
}
I can still use Out-ConsoleGridView
to select files.
$splat= @{
ArchivePath = "C:\temp\MyProject.zip"
IncludeParent = $True
}
dir c:\scripts -file | Out-ConsoleGridView -Title "Select files" -OutputMode Multiple |
Set-ZipArchive @splat -comment "Files for my special project"
#append to existing files
$splat['Append'] = $True
dir c:\work -file | Out-ConsoleGridView -Title "Select files" -OutputMode Multiple |
Set-ZipArchive @splat
I can include folders, but I need to use slightly different syntax:
dir c:\temp -Directory | Out-ConsoleGridView -Title "Select folders" -OutputMode Multiple |
Foreach-Object -Begin {$splat.Remove("IncludeParent")} -process {
Set-ZipArchive -FolderPath $_.fuLLName @splat
}
I have to use ForEach-Object
. Why didn't I use the pipeline? My function has multiple parameter sets and typed parameters.
[Parameter(
Position = 0,
HelpMessage = "Specify the input folder to add to the zip file.",
ParameterSetName = 'Folder'
)]
[ValidateNotNullOrEmpty()]
[System.IO.DirectoryInfo]$FolderPath
A directory object based on a file object. You can see that in the defined type names.
PS C:\> (Get-item C:\work\).psobject.typenames
System.IO.DirectoryInfo
System.IO.FileSystemInfo
System.MarshalByRefObject
System.Object
When I pipe a directory object to my function, the object is being "caught" by the FilePath
parameter which is set for the [System.IO.FileInfo]
type. This is also the default parameter set. If I change the default parameter set to Folder
and revised the FolderPath
parameter to accept a pipeline input, I should be able to use a pipelined expression. This is still a new function so I will have to see how I end up using it
Summary
As I was toolmaking, I easily solved one challenge which led me down another rabbit hole. The end result is that I now have two tools I can use to create custom ZIP files directly from the PowerShell console.