Eigenverft.Manifested.Drydock.Compression.ps1
|
function Compress-Directory { <# .SYNOPSIS Creates a zip archive from a source directory by streaming the tree through one ZipArchive. .DESCRIPTION Creates the archive with System.IO.Compression.ZipArchive instead of Compress-Archive. The archive is opened once for the complete directory tree. Relative directory structure and empty directories are preserved. File content is streamed in bounded chunks so long-running work can emit a compact heartbeat about every 15 seconds without using Write-Progress. The archive is built in a temporary file beside the destination and is published only after the temporary archive can be reopened successfully. An interrupted or failed run therefore does not replace an existing valid archive. .PARAMETER SourceDirectory Materialized directory whose contents will be zipped. .PARAMETER DestinationFile Full path to the resulting .zip file. .PARAMETER FilePolicy Behavior when DestinationFile already exists. - SkipIfExists: skip work if the archive exists. - OverwriteIfExists: replace the existing archive only after a new archive was created successfully (default). .PARAMETER CompressionLevel Compression level for zip entries. Valid values: Optimal, Fastest, NoCompression. .PARAMETER LogDetailLevel Summary writes start, progress, status, warning, and final summary messages. Detailed also writes directory, reparse-point, file-entry, validation, and publication details. .EXAMPLE Compress-Directory -SourceDirectory "C:\Data\Reports" -DestinationFile "C:\Temp\reports.zip" Creates or replaces C:\Temp\reports.zip after the new archive was completed and validated. .EXAMPLE Compress-Directory -SourceDirectory "D:\Archive\App_260819" -DestinationFile "D:\ZipArchive\App_260819.zip" -CompressionLevel Fastest Streams the complete directory tree into one zip archive using fast compression. .NOTES - Compatible with Windows PowerShell 5/5.1 and PowerShell 7+. - Keeps the existing Compress-Directory parameters and adds LogDetailLevel for long-running visibility. - Does not follow directory reparse points; the reparse-point directory entry itself is retained. #> [CmdletBinding(PositionalBinding=$false)] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$SourceDirectory, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$DestinationFile, [Parameter()] [ValidateSet('SkipIfExists','OverwriteIfExists')] [string]$FilePolicy = 'OverwriteIfExists', [Parameter()] [ValidateSet('Optimal','Fastest','NoCompression')] [string]$CompressionLevel = 'Optimal', [Parameter()] [ValidateSet('Detailed','Summary')] [string]$LogDetailLevel = 'Summary' ) function local:_Write-StandardMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] # This function is globally exempt from the GENERAL POWERSHELL REQUIREMENTS unless explicitly stated otherwise. [CmdletBinding()] param( [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message, [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$Level='INF', [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$MinLevel ) if ($null -eq $Message) { $Message = [string]::Empty } $sevMap=@{TRC=0;DBG=1;INF=2;WRN=3;ERR=4;FTL=5} if(-not $PSBoundParameters.ContainsKey('MinLevel')){ $gv=Get-Variable ConsoleLogMinLevel -Scope Global -ErrorAction SilentlyContinue $MinLevel=if($gv -and $gv.Value -and -not [string]::IsNullOrEmpty([string]$gv.Value)){[string]$gv.Value}else{'INF'} } $lvl=$Level.ToUpperInvariant() $min=$MinLevel.ToUpperInvariant() $sev=$sevMap[$lvl];if($null -eq $sev){$lvl='INF';$sev=$sevMap['INF']} $gate=$sevMap[$min];if($null -eq $gate){$min='INF';$gate=$sevMap['INF']} if($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4){$lvl=$min;$sev=$gate} if($sev -lt $gate){return} $ts=[DateTime]::UtcNow.ToString('yy-MM-dd HH:mm:ss.ff') $stack=Get-PSCallStack ; $helperName=$MyInvocation.MyCommand.Name ; $helperScript=$MyInvocation.MyCommand.ScriptBlock.File ; $caller=$null if($stack){ # 1: prefer first non-underscore function not defined in the helper's own file for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_') -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } # 2: fallback to first non-underscore function (any file) if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_')){$caller=$f;break} } } # 3: fallback to first non-helper frame not from helper's own file if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } } # 4: final fallback to first non-helper frame if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName){$caller=$f;break} } } } if(-not $caller){$caller=[pscustomobject]@{ScriptName=$PSCommandPath;FunctionName=$null}} $lineNumber=$null ; $p=$caller.PSObject.Properties['ScriptLineNumber'];if($p -and $p.Value){$lineNumber=[string]$p.Value} if(-not $lineNumber){ $p=$caller.PSObject.Properties['Position'] if($p -and $p.Value){ $sp=$p.Value.PSObject.Properties['StartLineNumber'];if($sp -and $sp.Value){$lineNumber=[string]$sp.Value} } } if(-not $lineNumber){ $p=$caller.PSObject.Properties['Location'] if($p -and $p.Value){ $m=[regex]::Match([string]$p.Value,':(\d+)\s+char:','IgnoreCase');if($m.Success -and $m.Groups.Count -gt 1){$lineNumber=$m.Groups[1].Value} } } $file=if($caller.ScriptName){Split-Path -Leaf $caller.ScriptName}else{'cmd'} if($file -ne 'console' -and $lineNumber){$file="{0}:{1}" -f $file,$lineNumber} $prefix="[$ts " $suffix="] [$file] $Message" $cfg=@{TRC=@{Fore='DarkGray';Back=$null};DBG=@{Fore='Cyan';Back=$null};INF=@{Fore='Green';Back=$null};WRN=@{Fore='Yellow';Back=$null};ERR=@{Fore='Red';Back=$null};FTL=@{Fore='Red';Back='DarkRed'}}[$lvl] $fore=$cfg.Fore $back=$cfg.Back $isInteractive = [System.Environment]::UserInteractive if($isInteractive -and ($fore -or $back)){ Write-Host -NoNewline $prefix if($fore -and $back){Write-Host -NoNewline $lvl -ForegroundColor $fore -BackgroundColor $back} elseif($fore){Write-Host -NoNewline $lvl -ForegroundColor $fore} elseif($back){Write-Host -NoNewline $lvl -BackgroundColor $back} Write-Host $suffix } else { Write-Host "$prefix$lvl$suffix" } if($sev -ge 4 -and $ErrorActionPreference -eq 'Stop'){throw ("ConsoleLog.{0}: {1}" -f $lvl,$Message)} } function local:_Write-DetailMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory=$true)][string]$Message) if ($LogDetailLevel -eq 'Detailed') { _Write-StandardMessage -Message ("[DETAIL] {0}" -f $Message) -Level 'DBG' -MinLevel 'DBG' } } function local:_Format-ByteSize { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([Parameter(Mandatory=$true)][long]$Bytes) if ($Bytes -lt 1KB) { return ("{0:N0} B" -f $Bytes) } if ($Bytes -lt 1MB) { return ("{0:N1} KB" -f ([double]$Bytes / 1KB)) } if ($Bytes -lt 1GB) { return ("{0:N1} MB" -f ([double]$Bytes / 1MB)) } if ($Bytes -lt 1TB) { return ("{0:N2} GB" -f ([double]$Bytes / 1GB)) } return ("{0:N2} TB" -f ([double]$Bytes / 1TB)) } _Write-StandardMessage -Message '--- Compress directory to zip archive (streaming) ---' -Level 'INF' if (-not ([System.Management.Automation.PSTypeName]'System.IO.Compression.ZipArchive').Type) { Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop } $SourceResolvedPath = Resolve-Path -LiteralPath $SourceDirectory -ErrorAction SilentlyContinue if ($null -eq $SourceResolvedPath) { throw ("Source directory not found: {0}" -f $SourceDirectory) } $SourceFullPath = $SourceResolvedPath.Path if (-not (Test-Path -LiteralPath $SourceFullPath -PathType Container)) { throw ("Path is not a directory: {0}" -f $SourceFullPath) } $DestinationFullPath = [System.IO.Path]::GetFullPath($DestinationFile) $DestinationParentPath = [System.IO.Path]::GetDirectoryName($DestinationFullPath) if ([string]::IsNullOrEmpty($DestinationParentPath)) { throw ("Destination directory could not be resolved: {0}" -f $DestinationFile) } if (-not (Test-Path -LiteralPath $DestinationParentPath -PathType Container)) { New-Item -ItemType Directory -Path $DestinationParentPath -Force -ErrorAction Stop | Out-Null _Write-StandardMessage -Message ("[CREATE] Created output directory: {0}" -f $DestinationParentPath) -Level 'INF' } $DestinationFileExists = Test-Path -LiteralPath $DestinationFullPath -PathType Leaf if ($DestinationFileExists -and $FilePolicy -eq 'SkipIfExists') { _Write-StandardMessage -Message ("[SKIP] Zip already present, skipped: {0}" -f $DestinationFullPath) -Level 'INF' return } $tempFile = Join-Path -Path $DestinationParentPath -ChildPath (".{0}.{1}.tmp" -f [System.IO.Path]::GetFileName($DestinationFullPath), [Guid]::NewGuid().ToString('N')) $sourceRoot = (New-Object System.IO.DirectoryInfo($SourceFullPath)).FullName.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $sourcePrefix = $sourceRoot + [System.IO.Path]::DirectorySeparatorChar $pathComparison = if ($env:OS -eq 'Windows_NT') { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } $compressionEnum = [System.IO.Compression.CompressionLevel]::$CompressionLevel $reparsePoint = [System.IO.FileAttributes]::ReparsePoint $largeFileSizeThresholdBytes = [long](200 * 1024 * 1024) # ZIP/DOS timestamps support calendar years 1980 through 2107 with two-second resolution. $zipTimestampMinimumYear = 1980 $zipTimestampMaximumYear = 2107 $copyBuffer = New-Object byte[] (1024 * 1024) $archiveStream = $null $zip = $null $fileCount = 0L $directoryCount = 0L $byteCount = 0L $timer = [System.Diagnostics.Stopwatch]::StartNew() $progressState = [PSCustomObject]@{ IntervalMilliseconds = 15000L NextAtMilliseconds = 15000L } function local:_Write-ProgressMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string]$CurrentPath) $elapsedMilliseconds = $timer.ElapsedMilliseconds if ($elapsedMilliseconds -lt $progressState.NextAtMilliseconds) { return } $zipBytes = 0L if ($archiveStream) { try { $zipBytes = [long]$archiveStream.Length } catch { _Write-DetailMessage "Could not read current temporary zip size. $($_.Exception.Message)" } } $currentSuffix = if ([string]::IsNullOrEmpty($CurrentPath)) { '' } else { " Current: '$CurrentPath'." } _Write-StandardMessage -Message ( "[PROGRESS] Running {0}. Files/directories: {1}/{2}; source: {3}; zip: {4}.{5}" -f $timer.Elapsed.ToString('hh\:mm\:ss'), $fileCount, $directoryCount, (_Format-ByteSize -Bytes $byteCount), (_Format-ByteSize -Bytes $zipBytes), $currentSuffix ) -Level 'INF' while ($progressState.NextAtMilliseconds -le $elapsedMilliseconds) { $progressState.NextAtMilliseconds = $progressState.NextAtMilliseconds + $progressState.IntervalMilliseconds } } try { _Write-StandardMessage -Message ( "[STATUS] Starting streaming compression from '{0}' to '{1}' (Level={2}, Policy={3}, Detail={4})." -f $SourceFullPath, $DestinationFullPath, $CompressionLevel, $FilePolicy, $LogDetailLevel ) -Level 'INF' _Write-DetailMessage ("Temporary archive path: '{0}'." -f $tempFile) $archiveStream = [System.IO.File]::Open($tempFile, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) $zip = New-Object System.IO.Compression.ZipArchive($archiveStream, [System.IO.Compression.ZipArchiveMode]::Create, $false) $pendingDirectories = New-Object 'System.Collections.Generic.Stack[System.IO.DirectoryInfo]' $pendingDirectories.Push((New-Object System.IO.DirectoryInfo($sourceRoot))) while ($pendingDirectories.Count -gt 0) { $currentDirectory = $pendingDirectories.Pop() foreach ($childDirectory in $currentDirectory.GetDirectories()) { $relativeDirectoryPath = $childDirectory.FullName.Substring($sourcePrefix.Length) $directoryEntryName = $relativeDirectoryPath.Replace([System.IO.Path]::DirectorySeparatorChar, '/').Replace([System.IO.Path]::AltDirectorySeparatorChar, '/').TrimEnd('/') + '/' [void]$zip.CreateEntry($directoryEntryName) $directoryCount++ _Write-DetailMessage ("Added directory entry '{0}'." -f $directoryEntryName) if (($childDirectory.Attributes -band $reparsePoint) -eq 0) { $pendingDirectories.Push($childDirectory) } else { _Write-DetailMessage ("Directory reparse point retained as entry but not traversed: '{0}'." -f $childDirectory.FullName) } _Write-ProgressMessage -CurrentPath $relativeDirectoryPath } foreach ($file in $currentDirectory.GetFiles()) { if ([string]::Equals($file.FullName, $DestinationFullPath, $pathComparison) -or [string]::Equals($file.FullName, $tempFile, $pathComparison)) { continue } $relativeFilePath = $file.FullName.Substring($sourcePrefix.Length) $entryName = $relativeFilePath.Replace([System.IO.Path]::DirectorySeparatorChar, '/').Replace([System.IO.Path]::AltDirectorySeparatorChar, '/') _Write-DetailMessage ("Writing file '{0}' as entry '{1}' ({2})." -f $file.FullName, $entryName, (_Format-ByteSize -Bytes ([long]$file.Length))) if ([long]$file.Length -ge $largeFileSizeThresholdBytes) { _Write-StandardMessage -Message ("[STATUS] Compressing large file (about {0}), this may take a while: '{1}'." -f (_Format-ByteSize -Bytes ([long]$file.Length)), $relativeFilePath) -Level 'INF' } $entry = $zip.CreateEntry($entryName, $compressionEnum) # Preserve the source modification time when it is representable by the ZIP/DOS timestamp format. if ($file.LastWriteTime.Year -ge $zipTimestampMinimumYear -and $file.LastWriteTime.Year -le $zipTimestampMaximumYear) { $entry.LastWriteTime = [DateTimeOffset]$file.LastWriteTime } $sourceStream = $null $entryStream = $null try { $sourceStream = [System.IO.File]::Open($file.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) $entryStream = $entry.Open() do { $readCount = $sourceStream.Read($copyBuffer, 0, $copyBuffer.Length) if ($readCount -gt 0) { $entryStream.Write($copyBuffer, 0, $readCount) $byteCount = $byteCount + $readCount _Write-ProgressMessage -CurrentPath $relativeFilePath } } while ($readCount -gt 0) } finally { if ($entryStream) { $entryStream.Dispose() } if ($sourceStream) { $sourceStream.Dispose() } } $fileCount++ _Write-ProgressMessage -CurrentPath $relativeFilePath } } $zip.Dispose() $zip = $null $archiveStream.Dispose() $archiveStream = $null $validationStream = $null $validationZip = $null $validatedEntryCount = 0 try { $validationStream = [System.IO.File]::Open($tempFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) $validationZip = New-Object System.IO.Compression.ZipArchive($validationStream, [System.IO.Compression.ZipArchiveMode]::Read, $false) $validatedEntryCount = $validationZip.Entries.Count _Write-DetailMessage ("Reopened temporary archive successfully; entries={0}." -f $validatedEntryCount) } finally { if ($validationZip) { $validationZip.Dispose() } if ($validationStream) { $validationStream.Dispose() } } _Write-DetailMessage ("Publishing completed temporary archive to '{0}'." -f $DestinationFullPath) if ($DestinationFileExists) { Remove-Item -LiteralPath $DestinationFullPath -Force -ErrorAction Stop } [System.IO.File]::Move($tempFile, $DestinationFullPath) $tempFile = $null $zipByteCount = [long](Get-Item -LiteralPath $DestinationFullPath -Force -ErrorAction Stop).Length _Write-StandardMessage -Message ( "[SUMMARY] Completed in {0}. Files/directories/entries: {1}/{2}/{3}; source/zip: {4}/{5}; level: {6}; from: {7}; to: {8}" -f $timer.Elapsed.ToString('hh\:mm\:ss'), $fileCount, $directoryCount, $validatedEntryCount, (_Format-ByteSize -Bytes $byteCount), (_Format-ByteSize -Bytes $zipByteCount), $CompressionLevel, $SourceFullPath, $DestinationFullPath ) -Level 'INF' } catch { $errorMessage = $_.Exception.Message _Write-StandardMessage -Message ("[ERR] Failed to create archive: {0}" -f $errorMessage) -Level 'ERR' throw } finally { if ($zip) { $zip.Dispose() } if ($archiveStream) { $archiveStream.Dispose() } if ($tempFile -and (Test-Path -LiteralPath $tempFile -PathType Leaf)) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } $timer.Stop() } } function Add-FileToZipArchive { <# .SYNOPSIS Adds or updates a single file in a zip archive. .DESCRIPTION Uses System.IO.Compression.ZipArchive to add or update a single file inside a .zip archive. If the destination zip does not exist, it is created. Optionally deletes the source file after a successful update. Entry behavior when an item already exists inside the zip is controlled via EntryPolicy. Optionally places the file into a date-based subfolder inside the zip based on UTC time. Includes a retry policy and a failure policy (Strict vs BestEffort). .PARAMETER SourceFile Full path to the file that should be stored inside the zip. .PARAMETER DestinationZip Full path to the target .zip archive. Created if it does not exist. .PARAMETER CompressionLevel Compression level for the zip entry. Valid values: Optimal, Fastest, NoCompression. .PARAMETER EntryPolicy Behavior when an entry with the same name already exists in the zip. - OverwriteIfExists (default): overwrite the existing entry. - SkipIfExists: skip this file if an entry already exists. - FailIfExists: throw (or skip in BestEffort mode) if an entry already exists. .PARAMETER DeleteSource Controls whether the source file is deleted after a successful write to the zip. - Delete (default): delete the source file. - Keep: keep the source file. .PARAMETER DateFolderPattern Controls whether the entry is placed into a UTC date-based subfolder inside the zip. - None (default): no date subfolder, just the filename. - YYMMDD: folder like archived-at_utc_241123. - YYYYMMDD: folder like archived-at_utc_20251123. - YYYYMM: folder like archived-at_utc_202511. - YYYY: folder like archived-at_utc_2025. - YYYYMMDD_HH: folder like archived-at_utc_20251123_09 (hourly buckets). .PARAMETER RetryCount Number of attempts to write into the zip before failing or skipping. Default: 3. .PARAMETER RetryDelayMilliseconds Delay between retry attempts, in milliseconds. Default: 500. .PARAMETER FailurePolicy Controls how failures are surfaced. - Strict (default): throw on unrecoverable failures. - BestEffort: log and skip on unrecoverable failures (no throw). .EXAMPLE Add-FileToZipArchive -SourceFile "C:\Logs\app-2025-11-23.log" -DestinationZip "C:\Archives\logs.zip" .EXAMPLE Add-FileToZipArchive -SourceFile "/var/log/app.log.1" -DestinationZip "/var/archive/app-logs.zip" -DeleteSource Keep .EXAMPLE Add-FileToZipArchive -SourceFile "/var/log/app.log.1" -DestinationZip "/var/archive/app-logs.zip" -EntryPolicy SkipIfExists .EXAMPLE # Store entries in daily UTC subfolders, e.g. archived-at_utc_241123/app.log Add-FileToZipArchive -SourceFile "C:\Logs\app.log" -DestinationZip "C:\Archives\logs.zip" -DateFolderPattern YYMMDD .EXAMPLE # Best-effort behavior for scheduled tasks: never throw on zip contention Add-FileToZipArchive -SourceFile "C:\Logs\app.log" -DestinationZip "C:\Archives\logs.zip" -FailurePolicy BestEffort #> [CmdletBinding(PositionalBinding=$false)] [Alias("Into-Zip")] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$SourceFile, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$DestinationZip, [Parameter()] [ValidateSet('Optimal','Fastest','NoCompression')] [string]$CompressionLevel = 'Optimal', [Parameter()] [ValidateSet('OverwriteIfExists','SkipIfExists','FailIfExists')] [string]$EntryPolicy = 'OverwriteIfExists', [Parameter()] [ValidateSet('Delete','Keep')] [string]$DeleteSource = 'Delete', [Parameter()] [ValidateSet('None','YYMMDD','YYYYMMDD','YYYYMM','YYYY','YYYYMMDD_HH')] [string]$DateFolderPattern = 'None', [Parameter()] [ValidateRange(1,60)] [int]$RetryCount = 3, [Parameter()] [ValidateRange(10,600000)] [int]$RetryDelayMilliseconds = 500, [Parameter()] [ValidateSet('Strict','BestEffort')] [string]$FailurePolicy = 'BestEffort' ) function local:_Write-StandardMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message, [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$Level='INF', [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$MinLevel ) if ($null -eq $Message) { $Message = [string]::Empty } $sevMap=@{TRC=0;DBG=1;INF=2;WRN=3;ERR=4;FTL=5} if(-not $PSBoundParameters.ContainsKey('MinLevel')){ $gv=Get-Variable ConsoleLogMinLevel -Scope Global -ErrorAction SilentlyContinue $MinLevel=if($gv -and $gv.Value -and -not [string]::IsNullOrEmpty([string]$gv.Value)){[string]$gv.Value}else{'INF'} } $lvl=$Level.ToUpperInvariant() $min=$MinLevel.ToUpperInvariant() $sev=$sevMap[$lvl];if($null -eq $sev){$lvl='INF';$sev=$sevMap['INF']} $gate=$sevMap[$min];if($null -eq $gate){$min='INF';$gate=$sevMap['INF']} if($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4){$lvl=$min;$sev=$gate} if($sev -lt $gate){return} $ts=[DateTime]::UtcNow.ToString('yy-MM-dd HH:mm:ss.ff') $stack=Get-PSCallStack ; $helperName=$MyInvocation.MyCommand.Name ; $helperScript=$MyInvocation.MyCommand.ScriptBlock.File ; $caller=$null if($stack){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_') -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_')){$caller=$f;break} } } if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } } if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName){$caller=$f;break} } } } if(-not $caller){$caller=[pscustomobject]@{ScriptName=$PSCommandPath;FunctionName=$null}} $lineNumber=$null $p=$caller.PSObject.Properties['ScriptLineNumber'];if($p -and $p.Value){$lineNumber=[string]$p.Value} if(-not $lineNumber){ $p=$caller.PSObject.Properties['Position'] if($p -and $p.Value){ $sp=$p.Value.PSObject.Properties['StartLineNumber'];if($sp -and $sp.Value){$lineNumber=[string]$sp.Value} } } if(-not $lineNumber){ $p=$caller.PSObject.Properties['Location'] if($p -and $p.Value){ $m=[regex]::Match([string]$p.Value,':(\d+)\s+char:','IgnoreCase');if($m.Success -and $m.Groups.Count -gt 1){$lineNumber=$m.Groups[1].Value} } } $file=if($caller.ScriptName){Split-Path -Leaf $caller.ScriptName}else{'cmd'} if($file -ne 'console' -and $lineNumber){$file="{0}:{1}" -f $file,$lineNumber} $prefix="[$ts " $suffix="] [$file] $Message" $cfg=@{ TRC=@{Fore='DarkGray';Back=$null} DBG=@{Fore='Cyan';Back=$null} INF=@{Fore='Green';Back=$null} WRN=@{Fore='Yellow';Back=$null} ERR=@{Fore='Red';Back=$null} FTL=@{Fore='Red';Back='DarkRed'} }[$lvl] $fore=$cfg.Fore $back=$cfg.Back $isInteractive = [System.Environment]::UserInteractive if($isInteractive -and ($fore -or $back)){ Write-Host -NoNewline $prefix if($fore -and $back){Write-Host -NoNewline $lvl -ForegroundColor $fore -BackgroundColor $back} elseif($fore){Write-Host -NoNewline $lvl -ForegroundColor $fore} elseif($back){Write-Host -NoNewline $lvl -BackgroundColor $back} Write-Host $suffix } else { Write-Host "$prefix$lvl$suffix" } if($sev -ge 4 -and $ErrorActionPreference -eq 'Stop'){throw ("ConsoleLog.{0}: {1}" -f $lvl,$Message)} } # First call: title (no tag prefix in message) _Write-StandardMessage -Message '--- Add single file into zip archive ---' -Level 'INF' # Resolve and validate source file, but SKIP/WARN if missing or invalid. $SourceResolved = Resolve-Path -LiteralPath $SourceFile -ErrorAction SilentlyContinue if ($null -eq $SourceResolved) { _Write-StandardMessage -Message ("[SKIP] Source file not found; skipping: {0}" -f $SourceFile) -Level 'WRN' return } $SourceFullPath = $SourceResolved.Path if (-not (Test-Path -LiteralPath $SourceFullPath -PathType Leaf)) { _Write-StandardMessage -Message ("[SKIP] Path is not a file; skipping: {0}" -f $SourceFullPath) -Level 'WRN' return } # Build date-based subfolder (UTC) if requested, using "archived-at_utc_<pattern>". $utcNow = [DateTime]::UtcNow $dateCore = $null switch ($DateFolderPattern) { 'None' { $dateCore = $null } 'YYMMDD' { $dateCore = $utcNow.ToString('yyMMdd') } 'YYYYMMDD' { $dateCore = $utcNow.ToString('yyyyMMdd') } 'YYYYMM' { $dateCore = $utcNow.ToString('yyyyMM') } 'YYYY' { $dateCore = $utcNow.ToString('yyyy') } 'YYYYMMDD_HH' { $dateCore = $utcNow.ToString('yyyyMMdd_HH') } default { $dateCore = $null } } $dateFolder = if ([string]::IsNullOrEmpty($dateCore)) { $null } else { "archived-at_utc_{0}" -f $dateCore } # Ensure destination parent directory exists when needed. $DestinationParentPath = Split-Path -Path $DestinationZip -Parent if ($null -ne $DestinationParentPath -and $DestinationParentPath -ne '') { if (-not (Test-Path -LiteralPath $DestinationParentPath -PathType Container)) { New-Item -ItemType Directory -Path $DestinationParentPath -Force | Out-Null _Write-StandardMessage -Message ("[CREATE] Created output directory: {0}" -f $DestinationParentPath) -Level 'INF' } } $zipExists = Test-Path -LiteralPath $DestinationZip -PathType Leaf $entryLeafName = [System.IO.Path]::GetFileName($SourceFullPath) $entryName = if ([string]::IsNullOrEmpty($dateFolder)) { $entryLeafName } else { "{0}/{1}" -f $dateFolder, $entryLeafName } $operationResult = 'None' # 'None' | 'Wrote' | 'Skipped' for ($attempt = 1; $attempt -le $RetryCount; $attempt++) { $fs = $null $zip = $null try { $fsMode = if ($zipExists) { [System.IO.FileMode]::Open } else { [System.IO.FileMode]::Create } $zipMode = if ($zipExists) { [System.IO.Compression.ZipArchiveMode]::Update } else { [System.IO.Compression.ZipArchiveMode]::Create } if (-not $zipExists -and $attempt -eq 1) { _Write-StandardMessage -Message ("[CREATE] Creating new zip archive: {0}" -f $DestinationZip) -Level 'INF' } elseif ($zipExists -and $attempt -eq 1) { _Write-StandardMessage -Message ("[STATUS] Opening existing zip archive: {0}" -f $DestinationZip) -Level 'INF' } $fs = [System.IO.File]::Open($DestinationZip, $fsMode, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) $zip = New-Object System.IO.Compression.ZipArchive($fs, $zipMode, $false) # Inspect existing entries (match by full entry name, including optional date folder). $existingEntry = $null foreach ($e in $zip.Entries) { if ($e.FullName -eq $entryName) { $existingEntry = $e break } } if ($existingEntry) { switch ($EntryPolicy) { 'SkipIfExists' { _Write-StandardMessage -Message ("[SKIP] Entry '{0}' already exists in '{1}'; skipping and keeping source file: {2}" -f $entryName, $DestinationZip, $SourceFullPath) -Level 'INF' $operationResult = 'Skipped' return } 'FailIfExists' { if ($FailurePolicy -eq 'BestEffort') { _Write-StandardMessage -Message ( "[SKIP] (BestEffort) Entry '{0}' already exists in '{1}' (EntryPolicy=FailIfExists, FailurePolicy=BestEffort); treating as warning and skipping, keeping source file: {2}" -f $entryName, $DestinationZip, $SourceFullPath ) -Level 'WRN' $operationResult = 'Skipped' return } else { _Write-StandardMessage -Message ( "[ERR] Entry '{0}' already exists in '{1}' (EntryPolicy=FailIfExists)." -f $entryName, $DestinationZip ) -Level 'ERR' throw ("Zip entry '{0}' already exists in '{1}' and EntryPolicy is FailIfExists." -f $entryName, $DestinationZip) } } 'OverwriteIfExists' { _Write-StandardMessage -Message ("[STATUS] Entry '{0}' already exists in '{1}'; overwriting per EntryPolicy." -f $entryName, $DestinationZip) -Level 'INF' $existingEntry.Delete() } } } else { if ($zipExists) { _Write-StandardMessage -Message ("[STATUS] Adding new entry '{0}' to existing archive: {1}" -f $entryName, $DestinationZip) -Level 'INF' } } _Write-StandardMessage -Message ( "[STATUS] Writing file '{0}' as entry '{1}' to '{2}' (Level={3}, EntryPolicy={4}, Attempt={5}/{6})." -f $SourceFullPath, $entryName, $DestinationZip, $CompressionLevel, $EntryPolicy, $attempt, $RetryCount ) -Level 'INF' $compressionEnum = [System.IO.Compression.CompressionLevel]::$CompressionLevel $entry = $zip.CreateEntry($entryName, $compressionEnum) $sourceStream = $null $entryStream = $null try { $sourceStream = [System.IO.File]::OpenRead($SourceFullPath) $entryStream = $entry.Open() $sourceStream.CopyTo($entryStream) } finally { if ($entryStream) { $entryStream.Dispose() } if ($sourceStream) { $sourceStream.Dispose() } } _Write-StandardMessage -Message ("[OK] File stored in zip as '{0}' in '{1}'." -f $entryName, $DestinationZip) -Level 'INF' $operationResult = 'Wrote' break } catch { $err = $_.Exception.Message if ($attempt -lt $RetryCount) { _Write-StandardMessage -Message ( "[RETRY] Failed to add file to zip (attempt {0}/{1}); retrying in {2} ms. Error: {3}" -f $attempt, $RetryCount, $RetryDelayMilliseconds, $err ) -Level 'WRN' Start-Sleep -Milliseconds $RetryDelayMilliseconds continue } else { if ($FailurePolicy -eq 'BestEffort') { _Write-StandardMessage -Message ( "[SKIP] (BestEffort) Failed to add file to zip after {0} attempts; treating as warning and giving up, continuing run. Error: {1}" -f $RetryCount, $err ) -Level 'WRN' $operationResult = 'Skipped' return } else { _Write-StandardMessage -Message ( "[ERR] Failed to add file to zip after {0} attempts: {1}" -f $RetryCount, $err ) -Level 'ERR' throw ("Failed to add file to zip after {0} attempts. {1}" -f $RetryCount, $err) } } } finally { if ($zip) { $zip.Dispose() } if ($fs) { $fs.Dispose() } } } if ($operationResult -eq 'Wrote' -and $DeleteSource -eq 'Delete') { try { Remove-Item -LiteralPath $SourceFullPath -Force _Write-StandardMessage -Message ("[DELETE] Removed source file: {0}" -f $SourceFullPath) -Level 'INF' } catch { $err2 = $_.Exception.Message if ($FailurePolicy -eq 'BestEffort') { _Write-StandardMessage -Message ( "[WRN] (BestEffort) Failed to delete source file '{0}'; treating as warning and continuing. Error: {1}" -f $SourceFullPath, $err2 ) -Level 'WRN' return } else { _Write-StandardMessage -Message ( "[WRN] Failed to delete source file '{0}': {1}" -f $SourceFullPath, $err2 ) -Level 'WRN' throw ("Failed to delete source file '{0}': {1}" -f $SourceFullPath, $err2) } } } } function Compress-B64Def { [CmdletBinding()] param() Add-Type -AssemblyName System.IO.Compression $t = Get-Clipboard -Raw if ([string]::IsNullOrEmpty([string]$t)) { throw 'Clipboard empty.' } $src = [Text.Encoding]::UTF8.GetBytes([string]$t) $ms = New-Object IO.MemoryStream try { $ds = New-Object IO.Compression.DeflateStream( $ms, [IO.Compression.CompressionMode]::Compress, $true ) try { $ds.Write($src, 0, $src.Length) } finally { $ds.Dispose() } $b64 = [Convert]::ToBase64String($ms.ToArray()) $imp = 'function Import-B64Def{[CmdletBinding()]param([Parameter(Mandatory=$true,Position=0)][string]$Text);Add-Type -AssemblyName System.IO.Compression;$z=[Convert]::FromBase64String($Text);$i=New-Object IO.MemoryStream(,$z);$o=New-Object IO.MemoryStream;try{$d=New-Object IO.Compression.DeflateStream($i,[IO.Compression.CompressionMode]::Decompress);try{$b=New-Object byte[] 4096;do{$n=$d.Read($b,0,$b.Length);if($n -gt 0){$o.Write($b,0,$n)}}while($n -gt 0)}finally{$d.Dispose()};[scriptblock]::Create([Text.Encoding]::UTF8.GetString($o.ToArray()))}finally{$o.Dispose();$i.Dispose()}}' $out = $imp + ';$s=''' + $b64 + ''';. (Import-B64Def $s)' Set-Clipboard -Value $out Write-Output $out } finally { $ms.Dispose() } } # Add-FileToZipArchive -SourceFile "C:\Temp\Eigenverft.App.ReverseProxy-drops\sln\Eigenverft.App.ReverseProxy\production\0.1.20256.47288\LICENSE-SERILOG_EXTENSIONS_HOSTING" -DestinationZip "C:\Archives\logs.zip" -DateFolderPattern YYYYMMDD # Write-Output "Done" |