Private/Get-UnifiedAuditLogOperationCatalog.ps1

function Get-UnifiedAuditLogOperationCatalog {
    <#
    .SYNOPSIS
        Returns the catalog of Unified Audit Log operations used by the Search-UnifiedAuditLogCustom helper GUI.
 
    .DESCRIPTION
        Loads the list of audit log operations (Operation + friendly name) from the Microsoft Learn
        audit-log-activities page, with a resilient caching strategy:
 
        1. If a local cache exists and is younger than MaxCacheAgeDays, it is used directly (no network call).
        2. Otherwise the page is fetched from Microsoft Learn and the parsed list is written to the cache.
        3. If the page is unreachable, the (possibly stale) local cache is used as a fallback.
        4. If no cache exists, the bundled seed shipped with the module is used as a last resort.
 
        The cache lives in the per-user local application data folder (LOCALAPPDATA on Windows,
        ~/Library/Application Support on macOS, ~/.local/share on Linux) under a PS365 subfolder, so it
        is writable per-user and works with PowerShell 7 on any platform. The seed lives in
        Private\Assets and ships with the module, so the helper still works fully offline on first run.
 
    .PARAMETER Url
        The Microsoft Learn page to parse. Defaults to the audit-log-activities catalog.
 
    .PARAMETER MaxCacheAgeDays
        Maximum age (in days) of the local cache before a refresh from Microsoft Learn is attempted. Default is 1.
 
    .PARAMETER ForceRefresh
        Ignore the cache freshness check and always attempt to refresh from Microsoft Learn first.
 
    .OUTPUTS
        PSCustomObject with:
        - Operations : a List[PSCustomObject] of { FriendlyName, Operation }
        - Source : 'Live', 'Cache', 'Seed' or 'None'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Url = 'https://learn.microsoft.com/en-us/purview/audit-log-activities',

        [Parameter(Mandatory = $false)]
        [int]$MaxCacheAgeDays = 1,

        [Parameter(Mandatory = $false)]
        [switch]$ForceRefresh
    )

    # Resolve a writable per-user cache folder cross-platform: LOCALAPPDATA on Windows,
    # ~/Library/Application Support on macOS, ~/.local/share on Linux, temp as a last resort.
    $localAppData = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)
    if ([string]::IsNullOrWhiteSpace($localAppData)) {
        $localAppData = [System.IO.Path]::GetTempPath()
    }
    $cacheDirectory = Join-Path $localAppData 'PS365'
    $cachePath = Join-Path $cacheDirectory 'Search-UnifiedAuditLogCustom-operations.json'
    $seedPath = Join-Path $PSScriptRoot 'Assets\Search-UnifiedAuditLogCustom-operations.json'

    # Normalize any collection of objects into a clean List[PSCustomObject] of { FriendlyName, Operation }.
    $normalize = {
        param($Items)

        [System.Collections.Generic.List[PSCustomObject]]$list = @()
        foreach ($item in @($Items)) {
            if ($null -eq $item) {
                continue
            }
            $operation = [string]$item.Operation
            if ([string]::IsNullOrWhiteSpace($operation)) {
                continue
            }
            $friendly = [string]$item.FriendlyName
            if ([string]::IsNullOrWhiteSpace($friendly)) {
                $friendly = $operation
            }
            $list.Add([PSCustomObject]@{
                    FriendlyName = $friendly
                    Operation    = $operation
                })
        }

        return , $list
    }

    # Build the catalog from raw HTML table rows returned by Get-HTMLTables.
    $buildFromRows = {
        param($Rows)

        $seen = @{}
        [System.Collections.Generic.List[PSCustomObject]]$list = @()

        foreach ($row in ($Rows | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Operation) })) {
            $operationName = ([string]$row.Operation).Trim().TrimEnd('.')
            if ([string]::IsNullOrWhiteSpace($operationName)) {
                continue
            }

            $friendlyName = $null
            if ($row.PSObject.Properties.Match('Friendly name').Count -gt 0) {
                $friendlyName = $row.'Friendly name'
            }
            elseif ($row.PSObject.Properties.Match('FriendlyName').Count -gt 0) {
                $friendlyName = $row.FriendlyName
            }

            if ([string]::IsNullOrWhiteSpace($friendlyName)) {
                $friendlyName = $operationName
            }
            $friendlyName = ([string]$friendlyName).Trim().TrimEnd('.')

            if (-not $seen.ContainsKey($operationName)) {
                $seen[$operationName] = $true
                $list.Add([PSCustomObject]@{
                        FriendlyName = $friendlyName
                        Operation    = $operationName
                    })
            }
        }

        return , $list
    }

    $loadFromJsonFile = {
        param($Path)

        if (-not (Test-Path -LiteralPath $Path)) {
            return $null
        }

        try {
            $content = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
            if ([string]::IsNullOrWhiteSpace($content)) {
                return $null
            }
            $parsed = $content | ConvertFrom-Json -ErrorAction Stop
            return (& $normalize $parsed)
        }
        catch {
            Write-Verbose "Unable to read operations catalog from '$Path'. $($_.Exception.Message)"
            return $null
        }
    }

    # 1. Fresh cache - use it directly, no network call.
    if (-not $ForceRefresh.IsPresent -and (Test-Path -LiteralPath $cachePath)) {
        try {
            $cacheAgeDays = ((Get-Date) - (Get-Item -LiteralPath $cachePath).LastWriteTime).TotalDays
            if ($cacheAgeDays -le $MaxCacheAgeDays) {
                $cachedOperations = & $loadFromJsonFile $cachePath
                if ($cachedOperations -and $cachedOperations.Count -gt 0) {
                    Write-Verbose "Loaded $($cachedOperations.Count) operations from fresh cache ($([math]::Round($cacheAgeDays, 1)) days old)."
                    return [PSCustomObject]@{ Operations = $cachedOperations; Source = 'Cache' }
                }
            }
        }
        catch {
            Write-Verbose "Cache freshness check failed. $($_.Exception.Message)"
        }
    }

    # 2. Refresh from Microsoft Learn and update the cache.
    try {
        $tableRows = Get-HTMLTables -URL $Url
        if ($tableRows) {
            $liveOperations = & $buildFromRows $tableRows
            if ($liveOperations -and $liveOperations.Count -gt 0) {
                try {
                    if (-not (Test-Path -LiteralPath $cacheDirectory)) {
                        $null = New-Item -ItemType Directory -Path $cacheDirectory -Force -ErrorAction Stop
                    }
                    $liveOperations | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $cachePath -Encoding UTF8 -ErrorAction Stop
                    Write-Verbose "Cached $($liveOperations.Count) operations to '$cachePath'."
                }
                catch {
                    Write-Verbose "Unable to write operations cache. $($_.Exception.Message)"
                }

                return [PSCustomObject]@{ Operations = $liveOperations; Source = 'Live' }
            }
        }
    }
    catch {
        Write-Verbose "Failed to load operation catalog from Microsoft Learn. $($_.Exception.Message)"
    }

    # 3. Fallback - stale cache.
    $staleOperations = & $loadFromJsonFile $cachePath
    if ($staleOperations -and $staleOperations.Count -gt 0) {
        Write-Verbose "Microsoft Learn unreachable. Using stale cache ($($staleOperations.Count) operations)."
        return [PSCustomObject]@{ Operations = $staleOperations; Source = 'Cache' }
    }

    # 4. Fallback - bundled seed.
    $seedOperations = & $loadFromJsonFile $seedPath
    if ($seedOperations -and $seedOperations.Count -gt 0) {
        Write-Verbose "Microsoft Learn and cache unavailable. Using bundled seed ($($seedOperations.Count) operations)."
        return [PSCustomObject]@{ Operations = $seedOperations; Source = 'Seed' }
    }

    Write-Verbose 'No operations catalog could be loaded (live, cache and seed all unavailable).'
    return [PSCustomObject]@{ Operations = ([System.Collections.Generic.List[PSCustomObject]]@()); Source = 'None' }
}