Public/Optimize-FoFile.ps1

function Optimize-FoFile {
    <#
    .SYNOPSIS
    Optimizes one or more files using FileOptimizer plugin chains.
 
    .DESCRIPTION
    Resolves settings, selects pipeline groups per file extension, runs the plugin
    chain, and optionally records history. Supports -WhatIf for dry-run output.
    Each invocation is one history batch when HistoryEnabled is true.
 
    .PARAMETER Path
    File or directory paths to optimize. Directories are not expanded unless -Recurse is set.
 
    .PARAMETER ConfigPath
    Optional local JSON config file merged after global config.
 
    .PARAMETER ContinueOnError
    When optimizing multiple files, record per-file errors and continue instead of stopping the batch.
 
    .PARAMETER Level
    Optimization level (0–9). Default from config.
 
    .PARAMETER PluginSearchMode
    How to resolve plugin executables: PortableFirst, PathFirst, PortableOnly, or PathOnly.
 
    .PARAMETER PluginPath
    Portable plugin directory (Plugins64/Plugins32).
 
    .PARAMETER OutputMode
    TempMove, Replace, OptimizedSuffix, BackupSuffix, or BackupMove.
 
    .PARAMETER ReportVerbosity
    Compact, Standard (default), or Verbose console/report layout.
 
    .PARAMETER SizeDisplayUnit
    Auto (default pretty), Bytes, KB, MB, or GB for size display.
 
    .PARAMETER DurationDisplayUnit
    Pretty (default multi-unit duration) or Milliseconds for duration display.
 
    .PARAMETER ShowProgress
    Show batch progress (file i of N) and nested per-step progress during optimization.
 
    .PARAMETER PassThru
    Return optimization result objects to the pipeline. Without this switch, only the
    host report controlled by ReportVerbosity is written (no object dump).
 
    .PARAMETER Recurse
    When Path is a directory, include files in subdirectories.
 
    .PARAMETER MissingToolsPolicy
    How to handle required plugin tools that are not found: Error (default),
    SkipTool (skip those steps), or SkipFile (skip the whole file).
 
    .PARAMETER AcknowledgeOutdatedPlugins
    Persist acknowledgment of the current minimum plugin-bundle version and continue
    with a warning when the installed bundle is older than required.
 
    .EXAMPLE
    Optimize-FoFile -Path .\images\photo.png
 
    .EXAMPLE
    Optimize-FoFile -Path .\docs -Recurse -WhatIf
 
    .EXAMPLE
    .\Scripts\Optimize-File.ps1 .\images\*.png
    # CLI wrapper with the same parameters.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Optimize')]
    [OutputType([object[]])]
    param(
        [Parameter(ParameterSetName = 'Optimize', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'PSPath')]
        [string[]]$Path,
        [string]$ConfigPath,
        [ValidateRange(0, 9)]
        [nullable[int]]$Level,
        [ValidateSet('PortableFirst', 'PathFirst', 'PortableOnly', 'PathOnly')]
        [string]$PluginSearchMode,
        [string]$PluginPath,
        [ValidateRange(0, 3)]
        [nullable[int]]$LogLevel,
        [ValidateRange(0, 3)]
        [nullable[int]]$ReportLogLevel,
        [string]$ReportPath,
        [ValidateSet('Compact', 'Standard', 'Verbose')]
        [string]$ReportVerbosity,
        [ValidateSet('Auto', 'Bytes', 'KB', 'MB', 'GB')]
        [string]$SizeDisplayUnit,
        [ValidateSet('Pretty', 'Milliseconds')]
        [string]$DurationDisplayUnit,
        [ValidateSet('Replace', 'OptimizedSuffix', 'BackupSuffix', 'BackupMove', 'TempMove')]
        [string]$OutputMode,
        [string]$BackupPath,
        [string]$BackupSuffix,
        [string]$OptimizedSuffix,
        [string]$TempBackupPath,
        [nullable[bool]]$HistoryEnabled,
        [string]$HistoryPath,
        [ValidateSet('Error', 'SkipTool', 'SkipFile')]
        [string]$MissingToolsPolicy,
        [nullable[bool]]$SkipMissingTools,
        [switch]$ShowProgress,
        [switch]$PassThru,
        [switch]$Recurse,
        [switch]$ContinueOnError,
        [switch]$AcknowledgeOutdatedPlugins
    )

    begin {
        $script:FoBatchResults = [System.Collections.Generic.List[object]]::new()
        $script:FoBatchSettings = $null
        $script:FoBatchId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + ([guid]::NewGuid().ToString('N').Substring(0, 8))
    }

    process {
        if (-not $Path) { return }

        if ($AcknowledgeOutdatedPlugins) {
            $min = Get-FoMinimumPluginBundleVersion
            $ackConfig = if ($ConfigPath) { $ConfigPath } else { $null }
            Set-FoAcknowledgedPluginBundleMinimum -MinimumVersion $min -ConfigPath $ackConfig
        }

        $settings = Merge-FoSettings -BoundParameters $PSBoundParameters
        if ($AcknowledgeOutdatedPlugins) {
            $settings.AcknowledgedPluginBundleMinimum = Get-FoMinimumPluginBundleVersion
        }
        $script:FoBatchSettings = $settings
        $targets = @(Get-FoTargetFiles -Path $Path -Recurse:$Recurse)
        $batchTotal = $targets.Count
        $batchIndex = 0
        $verboseHost = (Get-FoReportVerbosity -Settings $settings) -eq 'Verbose'

        foreach ($file in $targets) {
            $batchIndex++
            if ($ShowProgress -and $batchTotal -gt 0) {
                $fileLabel = Split-Path -Leaf $file
                $pct = [int]((($batchIndex - 1) / $batchTotal) * 100)
                Write-Progress -Id 1 -Activity 'Optimizing files' `
                    -Status ("File {0} of {1}: {2}" -f $batchIndex, $batchTotal, $fileLabel) `
                    -PercentComplete $pct
            }

            $fileResult = $null
            $gate = Test-FoFileGate -Path $file -Settings $settings
            if (-not $gate.Pass) {
                Write-FoLog -LogLevel $settings.LogLevel -RequiredLevel 1 -Message "Skipped $file ($($gate.Reason))"
                $fileResult = [PSCustomObject]@{ Path = $file; Status = 'Skipped'; Reason = $gate.Reason }
                $script:FoBatchResults.Add($fileResult)
            }
            elseif ((Get-Item -LiteralPath $file).Length -eq 0) {
                Write-FoLog -LogLevel $settings.LogLevel -RequiredLevel 1 -Message "Skipped $file (zero-byte file)"
                $fileResult = [PSCustomObject]@{ Path = $file; Status = 'Skipped'; Reason = 'ZeroByte' }
                $script:FoBatchResults.Add($fileResult)
            }
            else {
                $groups = Get-FoPipelineGroupsForFile -Path $file -Settings $settings
                if ($groups.Count -eq 0) {
                    Write-FoLog -LogLevel $settings.LogLevel -RequiredLevel 1 -Message "Skipped $file (unsupported extension)"
                    $fileResult = [PSCustomObject]@{ Path = $file; Status = 'Skipped'; Reason = 'Unsupported' }
                    $script:FoBatchResults.Add($fileResult)
                }
                elseif ($PSCmdlet.ShouldProcess($file, 'Optimize file')) {
                    try {
                        $fileResult = Invoke-FoPluginChain -Path $file -Settings $settings `
                            -ShowProgress:$ShowProgress -ProgressParentId 1 -Confirm:$false
                        if ($fileResult.Status -eq 'Optimized') {
                            if ($settings.HistoryEnabled) {
                                Add-FoHistoryEntry -Result $fileResult -Settings $settings -BatchId $script:FoBatchId
                            }
                        }
                        $script:FoBatchResults.Add($fileResult)
                    }
                    catch {
                        if ($ContinueOnError) {
                            Write-FoLog -LogLevel $settings.LogLevel -RequiredLevel 1 -Message "Error optimizing ${file}: $($_.Exception.Message)"
                        }
                        else {
                            Write-Error $_
                        }
                        $fileResult = [PSCustomObject]@{ Path = $file; Status = 'Error'; Reason = $_.Exception.Message }
                        $script:FoBatchResults.Add($fileResult)
                        if (-not $ContinueOnError) { throw }
                    }
                }
                elseif ($WhatIfPreference) {
                    $fileResult = Invoke-FoPluginChain -Path $file -Settings $settings -ShowProgress:$false -Confirm:$false
                    $script:FoBatchResults.Add($fileResult)
                }
            }

            # Verbose: pair step chain output with this file's report before the next file starts.
            if ($verboseHost -and $null -ne $fileResult) {
                $moreFiles = $batchIndex -lt $batchTotal
                Write-FoOptimizeResultHostBlock -Result $fileResult -Settings $settings -TrailingBlank:$moreFiles
            }

            if ($ShowProgress -and $batchTotal -gt 0) {
                $fileLabel = Split-Path -Leaf $file
                $pctDone = [int](($batchIndex / $batchTotal) * 100)
                Write-Progress -Id 1 -Activity 'Optimizing files' `
                    -Status ("File {0} of {1}: {2}" -f $batchIndex, $batchTotal, $fileLabel) `
                    -PercentComplete $pctDone
            }
        }

        if ($ShowProgress) {
            Write-Progress -Id 1 -Activity 'Optimizing files' -Completed
        }
    }

    end {
        if ($script:FoBatchSettings -and $script:FoBatchResults.Count -gt 0) {
            # Compact / Standard render here. Verbose blocks are written per file in process.
            Write-FoOptimizeResults -Results @($script:FoBatchResults) -Settings $script:FoBatchSettings

            if ($script:FoBatchSettings.ReportPath) {
                Write-FoReport -Results @($script:FoBatchResults) -Settings $script:FoBatchSettings -ReportPath $script:FoBatchSettings.ReportPath
            }
        }
        if ($PassThru) {
            return @($script:FoBatchResults)
        }
    }
}