Private/ODBLogProcessor.ps1

class ODBLogProcessor {

    # ── Properties ──────────────────────────────────────────────────────────────
    [System.IO.FileInfo] $SourceFile      # validated FileInfo for the input .cab/.zip
    [string]             $DateFilter      # e.g. "2023-10-31"
    [string]             $Platform        # "Windows" | "Mac"
    [string]             $WorkingDir      # parent directory of the source file
    [string]             $ExtractedDir    # full path to the extracted subfolder

    # ── Constructor ──────────────────────────────────────────────────────────────
    ODBLogProcessor([string]$Path, [string]$Date, [string]$Platform) {
        try {
            $this.SourceFile = Get-Item -LiteralPath $Path -ErrorAction Stop
        }
        catch {
            throw "Source file not found at '$Path': $_"
        }

        $this.DateFilter   = $Date
        $this.Platform     = $Platform
        $this.WorkingDir   = $this.SourceFile.DirectoryName
        $this.ExtractedDir = Join-Path $this.WorkingDir $this.SourceFile.BaseName
    }

    # ── Methods ──────────────────────────────────────────────────────────────────

    # Step 1 — Extract the source file.
    # Windows .cab → extrac32.exe /E /L (built-in Cabinet tool, window hidden)
    # Mac .zip → ZipFile API (entry-by-entry, skips MAX_PATH violations)
    [void] ExtractSource() {
        if ($this.Platform -eq 'Windows') {
            # extrac32.exe /E expands all files preserving the full folder structure within the CAB.
            # expand.exe was considered but rejected: it does not auto-create subdirectories, causing
            # multi-level CABs to fail. Full path avoids PATH ambiguity.
            # The extrac32.exe window is intentionally left visible — it serves as the extraction
            # progress indicator since no in-terminal progress bar is available.
            $proc = Start-Process -FilePath "$env:SystemRoot\System32\extrac32.exe" `
                                  -ArgumentList "`"$($this.SourceFile.FullName)`"", '/E', '/L', "`"$($this.ExtractedDir)`"" `
                                  -Wait `
                                  -PassThru
            if ($proc.ExitCode -ne 0) {
                throw "extrac32.exe exited with code $($proc.ExitCode). Extraction may have failed."
            }
        }
        else {
            # Pre-clear the destination to avoid stale files from a previous interrupted run.
            if (Test-Path -LiteralPath $this.ExtractedDir) {
                Remove-Item -LiteralPath $this.ExtractedDir -Recurse -Force -ErrorAction SilentlyContinue
            }
            # Use ZipFile API directly instead of Expand-Archive.
            # Expand-Archive has no per-entry error handling: if any single entry fails
            # (e.g. a .temp file whose full extracted path exceeds Windows MAX_PATH of 260 chars),
            # it triggers a destructive rollback that removes all previously extracted items,
            # producing thousands of "Cannot find path" errors.
            # Extracting entry-by-entry lets us silently skip oversized paths and continue.
            # (System.IO.Compression.FileSystem is loaded by the module before this class is parsed.)
            $zip = [System.IO.Compression.ZipFile]::OpenRead($this.SourceFile.FullName)
            try {
                foreach ($entry in $zip.Entries) {
                    # Skip directory entries (no filename after the last slash).
                    if ($entry.FullName.EndsWith('/') -or $entry.FullName.EndsWith('\')) { continue }

                    $destPath = [System.IO.Path]::Combine($this.ExtractedDir, $entry.FullName.Replace('/', '\'))

                    # Skip entries whose full Windows path would exceed MAX_PATH (260 chars).
                    if ($destPath.Length -ge 260) { continue }

                    $destDir = [System.IO.Path]::GetDirectoryName($destPath)
                    if (-not [System.IO.Directory]::Exists($destDir)) {
                        [void][System.IO.Directory]::CreateDirectory($destDir)
                    }
                    [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true)
                }
            }
            finally {
                $zip.Dispose()
            }
        }
    }

    # Step 2 — Collect ODL log files (files to KEEP).
    # Windows adds *.odlsent; Mac does not (Mac ODB does not generate them).
    [System.IO.FileInfo[]] CollectOdlFiles() {
        $extensions = @('*.odl', '*.odlgz', '*.etlgz')
        if ($this.Platform -eq 'Windows') {
            $extensions += '*.odlsent'
        }

        $files = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
        foreach ($ext in $extensions) {
            foreach ($f in (Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter $ext)) {
                $files.Add($f)
            }
        }
        # Deduplicate: on Windows, Get-ChildItem -Filter '*.odl' also matches '*.odlgz' files
        # via legacy 8.3 short-name wildcard matching (FindFirstFile API), causing each .odlgz
        # to appear twice — once via *.odl and once via *.odlgz.
        return ($files.ToArray() | Sort-Object FullName -Unique)
    }

    # Step 3 — Collect the four excluded file categories.
    # Returns a hashtable keyed by the zip-suffix label used in CompressExcluded.
    # Where-Object extension check guards against Windows 8.3 short-name false positives:
    # e.g. Get-ChildItem -Filter '*.log' also matches '*.loggz' (8.3 extension = .LOG).
    [hashtable] CollectExcludedFiles() {
        return @{
            'EventLogsFiles' = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.evtx'   |
                                 Where-Object { $_.Extension -eq '.evtx'   })
            'DbFiles'        = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.db'     |
                                 Where-Object { $_.Extension -eq '.db'     })
            'DbWalFiles'     = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.db-wal' |
                                 Where-Object { $_.Extension -eq '.db-wal' })
            'SetupLogFiles'  = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.log'    |
                                 Where-Object { $_.Extension -eq '.log'    })
        }
    }

    # Step 4 — Rename each excluded file with its immediate parent folder as a prefix
    # to avoid collisions when files from different subdirs are compressed together.
    # Returns a hashtable of the same keys, now holding [string[]] of new absolute paths.
    [hashtable] RenameExcludedFiles([hashtable]$ExcludedGroups) {
        $renamed = @{}
        foreach ($key in $ExcludedGroups.Keys) {
            $group = $ExcludedGroups[$key]
            if ($group.Count -gt 0) {
                $group | Rename-Item -NewName {
                    $parentLeaf = Split-Path $_.DirectoryName -Leaf
                    "${parentLeaf}_$($_.Name)"
                }
                $renamed[$key] = $group | ForEach-Object {
                    $parentLeaf = Split-Path $_.DirectoryName -Leaf
                    Join-Path $_.DirectoryName "${parentLeaf}_$($_.Name)"
                }
            }
            else {
                $renamed[$key] = @()
            }
        }
        return $renamed
    }

    # Step 5 — Compress each excluded category into its own .zip side archive.
    [void] CompressExcluded([hashtable]$RenamedGroups) {
        foreach ($key in $RenamedGroups.Keys) {
            $paths = $RenamedGroups[$key]
            if ($paths.Count -gt 0) {
                $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-${key}.zip"
                $paths | Compress-Archive -DestinationPath $destination -CompressionLevel Optimal -Force
            }
        }
    }

    # Step 6a — Delete excluded files after they have been archived.
    # Both platforms suppress deletion errors; explicit -ErrorAction is used because
    # class methods cannot assign to scope-based preference variables.
    [void] DeleteExcluded([hashtable]$RenamedGroups) {
        $errorAction = 'SilentlyContinue'
        foreach ($paths in $RenamedGroups.Values) {
            if ($paths.Count -gt 0) {
                $paths | Remove-Item -Force -ErrorAction $errorAction
            }
        }
    }

    # Step 6b — Delete ODL files whose BaseName does not match the date filter.
    # Windows suppresses deletion errors;
    [void] DeleteNonMatchingOdl([System.IO.FileInfo[]]$OdlFiles) {
        $toDelete = $OdlFiles | Where-Object { $_.BaseName -notmatch $this.DateFilter }
        if ($toDelete) {
            $toDelete | Remove-Item -Force -ErrorAction "SilentlyContinue"
        }
    }

    # Step 8 — Remove the extracted working directory and its contents.
    [void] Cleanup() {
        if (Test-Path -LiteralPath $this.ExtractedDir) {
            Remove-Item -LiteralPath $this.ExtractedDir -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    # Step 7 — Compress remaining logs into the final Parsed archive.
    # Windows → {basename}-Parsed.cab via makecab.exe (preserves folder structure via DDF).
    # Mac → {basename}-Parsed.zip via Compress-Archive.
    [string] CompressParsed() {
        if ($this.Platform -eq 'Windows') {
            $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-Parsed.cab"
            return New-CabinetFile -SourceDirectory $this.ExtractedDir -Destination $destination
        }
        else {
            $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-Parsed.zip"
            Compress-Archive -Path "$($this.ExtractedDir)\*" -DestinationPath $destination -CompressionLevel Optimal -Force
            return $destination
        }
    }
}