Public/UnifiedLogs/Search-UnifiedAuditLogCustom.ps1

<#
.SYNOPSIS
    Search-UnifiedAuditLogCustom is an enhanced wrapper around the native Search-UnifiedAuditLog cmdlet, providing additional features such as a user-friendly GUI for constructing search queries, simplified output formatting, and integration with the Microsoft 365 audit operations catalog.
 
    .DESCRIPTION
    This function allows administrators and security professionals to perform more efficient and targeted searches of the Microsoft 365 Unified Audit Log. It includes a helper GUI that enables users to easily select operations from the official Microsoft Learn catalog, specify date ranges, user filters, and other parameters without needing to remember complex cmdlet syntax.
    The output can be returned in a simplified format that flattens nested JSON structures for easier analysis and export. This is particularly useful for security investigations, compliance audits, and general monitoring of activities across Microsoft 365 services.
 
    .PARAMETER StartDate
    The start date and time for the audit log search. If not specified, defaults to 24 hours ago.
 
    .PARAMETER EndDate
    The end date and time for the audit log search. If not specified, defaults to the current date and time.
 
    .PARAMETER Operations
    An array of operation names to filter the search. These can be selected from the helper GUI, which loads the catalog of operations from Microsoft Learn. Users can also enter raw cmdlet names (e.g., New-TransportRule) to filter by specific operations.
 
    .PARAMETER UserIds
    An array of user identifiers (e.g., email addresses) to filter the search results by specific users.
 
    .PARAMETER FreeText
    A free text string to search for within the audit log records.
 
    .PARAMETER ResultSize
    The maximum number of results to return from the search. Defaults to 5000.
 
    .PARAMETER SimpleView
    When specified, the output will be processed to flatten nested JSON structures into a simpler format. This is ideal for exporting to CSV or performing quick analysis without dealing with complex nested properties.
 
    .PARAMETER HelperGUI
    When specified, opens a graphical user interface to assist in constructing the search query with user-friendly controls and operation selection.
    The operations list is populated from the Microsoft Learn catalog of audit log activities, allowing users to easily find and select relevant operations for their search.
    Make sure to have access to the Microsoft Learn page for audit log activities to load the operations catalog successfully (https://learn.microsoft.com/en-us/purview/audit-log-activities).
 
    .EXAMPLE
    Search-UnifiedAuditLogCustom -StartDate (Get-Date).AddDays(-7) -EndDate (Get-Date) -Operations "UserLoggedIn", "FileAccessed" -SimpleView
 
    This example searches the Unified Audit Log for "UserLoggedIn" and "FileAccessed" operations that occurred in the last 7 days, and returns the results in a simplified format.
 
    .EXAMPLE
 
#>


function Search-UnifiedAuditLogCustom {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [datetime]$StartDate,

        [Parameter(Mandatory = $false)]
        [datetime]$EndDate,

        [Parameter(Mandatory = $false)]
        [string[]]$Operations,

        [Parameter(Mandatory = $false)]
        [string[]]$UserIds,

        [Parameter(Mandatory = $false)]
        [string]$FreeText,

        [Parameter(Mandatory = $false)]
        [int]$ResultSize = 5000,

        [Parameter(Mandatory = $false)]
        [Alias('Simple')]
        [switch]$SimpleView,

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

    # function from HAWK module
    function Get-SimpleUnifiedAuditLog {
        <#
    .SYNOPSIS
        Flattens nested Microsoft 365 Unified Audit Log records into a simplified format.
 
    .DESCRIPTION
        This function processes Microsoft 365 Unified Audit Log records by converting nested JSON data
        (stored in the AuditData property) into a flat structure suitable for analysis and export.
        It handles complex nested objects, arrays, and special cases like parameter collections.
 
        The function:
        - Preserves base record properties
        - Flattens nested JSON structures
        - Provides special handling for Parameters collections
        - Creates human-readable command reconstructions
        - Supports type preservation for data analysis
 
    .PARAMETER Record
        A PowerShell object representing a unified audit log record. Typically, this is the output
        from Search-UnifiedAuditLog and should contain both base properties and an AuditData
        property containing a JSON string of additional audit information.
 
    .PARAMETER PreserveTypes
        When specified, maintains the original data types of values instead of converting them
        to strings. This is useful when the output will be used for further PowerShell processing
        rather than export to CSV/JSON.
 
    .EXAMPLE
        $auditLogs = Search-UnifiedAuditLog -StartDate $startDate -EndDate $endDate -RecordType ExchangeAdmin
        $auditLogs | Get-SimpleUnifiedAuditLog | Export-Csv -Path "AuditLogs.csv" -NoTypeInformation
 
        Processes Exchange admin audit logs and exports them to CSV with all nested properties flattened.
 
    .EXAMPLE
        $userChanges = Search-UnifiedAuditLog -UserIds user@domain.com -Operations "Add-*"
        $userChanges | Get-SimpleUnifiedAuditLog -PreserveTypes |
            Where-Object { $_.ResultStatus -eq $true } |
            Select-Object CreationTime, Operation, FullCommand
 
        Gets all "Add" operations for a specific user, preserves data types, filters for successful operations,
        and selects specific columns.
 
    .OUTPUTS
        Collection of PSCustomObjects with flattened properties from both the base record and AuditData.
        Properties include:
        - All base record properties (RecordType, CreationDate, etc.)
        - Flattened nested objects with property names using dot notation
        - Individual parameters as Param_* properties
        - ParameterString containing all parameters in a readable format
        - FullCommand showing reconstructed PowerShell command (when applicable)
 
    .NOTES
        Author: Jonathan Butler
        Version: 2.0
        Development Date: December 2024
 
        The function is designed to handle any RecordType from the Unified Audit Log and will
        automatically adapt to changes in the audit log schema. Special handling is implemented
        for common patterns like Parameters collections while maintaining flexibility for
        other nested structures.
    #>

        [CmdletBinding()]
        param (
            [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
            [PSObject]$Record,

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

        begin {
            [System.Collections.Generic.List[PSCustomObject]]$resultsArray = @()

            function ConvertTo-FlatObject {
                param (
                    [Parameter(Mandatory = $true)]
                    [PSObject]$InputObject,

                    [Parameter(Mandatory = $false)]
                    [string]$Prefix = '',

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

                $flatProperties = @{}

                foreach ($property in $InputObject.PSObject.Properties) {
                    $key = if ($Prefix) { "${Prefix}_$($property.Name)" } else { $property.Name }

                    if ($property.Name -eq 'Parameters' -and $property.Value -is [Array]) {
                        $parameterStrings = foreach ($parameter in $property.Value) {
                            "$($parameter.Name)=$($parameter.Value)"
                        }
                        $flatProperties['ParameterString'] = $parameterStrings -join ' | '

                        foreach ($parameter in $property.Value) {
                            $parameterKey = "Param_$($parameter.Name)"
                            $flatProperties[$parameterKey] = $parameter.Value
                        }

                        if ($InputObject.Operation) {
                            $parameterStrings = foreach ($parameter in $property.Value) {
                                $parameterValue = switch -Regex ($parameter.Value) {
                                    '\s' { "'$($parameter.Value)'" }
                                    '^True$|^False$' { "`$$($parameter.Value.ToLower())" }
                                    ';' { "'$($parameter.Value)'" }
                                    default { $parameter.Value }
                                }
                                "-$($parameter.Name) $parameterValue"
                            }
                            $flatProperties['FullCommand'] = "$($InputObject.Operation) $($parameterStrings -join ' ')"
                        }

                        continue
                    }

                    switch ($property.Value) {
                        { $_ -is [System.Collections.IDictionary] } {
                            $nestedProperties = ConvertTo-FlatObject -InputObject $_ -Prefix $key -PreserveTypes:$PreserveTypes
                            foreach ($nestedKey in $nestedProperties.Keys) {
                                $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) {
                                    $counter = 1
                                    while ($flatProperties.ContainsKey("${nestedKey}_$counter")) {
                                        $counter++
                                    }
                                    "${nestedKey}_$counter"
                                }
                                else {
                                    $nestedKey
                                }
                                $flatProperties[$uniqueKey] = $nestedProperties[$nestedKey]
                            }
                        }
                        { $_ -is [System.Collections.IList] -and $property.Name -ne 'Parameters' } {
                            if ($_.Count -gt 0) {
                                if ($_[0] -is [PSObject]) {
                                    for ($i = 0; $i -lt $_.Count; $i++) {
                                        $nestedProperties = ConvertTo-FlatObject -InputObject $_[$i] -Prefix "${key}_${i}" -PreserveTypes:$PreserveTypes
                                        foreach ($nestedKey in $nestedProperties.Keys) {
                                            $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) {
                                                $counter = 1
                                                while ($flatProperties.ContainsKey("${nestedKey}_$counter")) {
                                                    $counter++
                                                }
                                                "${nestedKey}_$counter"
                                            }
                                            else {
                                                $nestedKey
                                            }
                                            $flatProperties[$uniqueKey] = $nestedProperties[$nestedKey]
                                        }
                                    }
                                }
                                else {
                                    $flatProperties[$key] = $_ -join '|'
                                }
                            }
                            else {
                                $flatProperties[$key] = [string]::Empty
                            }
                        }
                        { $_ -is [PSObject] } {
                            $nestedProperties = ConvertTo-FlatObject -InputObject $_ -Prefix $key -PreserveTypes:$PreserveTypes
                            foreach ($nestedKey in $nestedProperties.Keys) {
                                $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) {
                                    $counter = 1
                                    while ($flatProperties.ContainsKey("${nestedKey}_$counter")) {
                                        $counter++
                                    }
                                    "${nestedKey}_$counter"
                                }
                                else {
                                    $nestedKey
                                }
                                $flatProperties[$uniqueKey] = $nestedProperties[$nestedKey]
                            }
                        }
                        default {
                            if ($PreserveTypes) {
                                $flatProperties[$key] = $_
                            }
                            else {
                                $flatProperties[$key] = switch ($_) {
                                    { $_ -is [datetime] } { $_ }
                                    { $_ -is [bool] } { $_ }
                                    { $_ -is [int] } { $_ }
                                    { $_ -is [long] } { $_ }
                                    { $_ -is [decimal] } { $_ }
                                    { $_ -is [double] } { $_ }
                                    default { [string]$_ }
                                }
                            }
                        }
                    }
                }

                return $flatProperties
            }
        }

        process {
            try {
                $baseProperties = $Record | Select-Object -Property * -ExcludeProperty AuditData

                $auditData = $Record.AuditData | ConvertFrom-Json
                if ($auditData) {
                    $flatAuditData = ConvertTo-FlatObject -InputObject $auditData -PreserveTypes:$PreserveTypes

                    $combinedProperties = @{}
                    $baseProperties.PSObject.Properties | ForEach-Object { $combinedProperties[$_.Name] = $_.Value }
                    $flatAuditData.GetEnumerator() | ForEach-Object { $combinedProperties[$_.Key] = $_.Value }

                    $null = $resultsArray.Add([PSCustomObject]$combinedProperties)
                }
            }
            catch {
                Write-Warning "Error processing record: $_"
                $errorProperties = @{
                    RecordType   = $Record.RecordType
                    CreationDate = Get-Date
                    Error        = $_.Exception.Message
                    Record       = $Record
                }
                $null = $resultsArray.Add([PSCustomObject]$errorProperties)
            }
        }

        end {
            $orderedProperties = @(
                'CreationTime',
                'Workload',
                'RecordType',
                'Operation',
                'ResultStatus',
                'ClientIP',
                'UserId',
                'Id',
                'OrganizationId',
                'UserType',
                'UserKey',
                'ObjectId',
                'Scope',
                'AppAccessContext'
            )

            $orderedResults = $resultsArray | ForEach-Object {
                $orderedObject = [ordered]@{}

                foreach ($prop in $orderedProperties) {
                    if ($_.PSObject.Properties.Name -contains $prop) {
                        $orderedObject[$prop] = $_.$prop
                    }
                }

                if ($_.PSObject.Properties.Name -contains 'ParameterString') {
                    $orderedObject['ParameterString'] = $_.ParameterString

                    $_.PSObject.Properties |
                    Where-Object { $_.Name -like 'Param_*' } |
                    Sort-Object -Property Name |
                    ForEach-Object {
                        $orderedObject[$_.Name] = $_.Value
                    }
                }

                $_.PSObject.Properties |
                Where-Object {
                    $_.Name -notin $orderedProperties -and
                    $_.Name -ne 'ParameterString' -and
                    $_.Name -notlike 'Param_*'
                } |
                ForEach-Object {
                    $orderedObject[$_.Name] = $_.Value
                }

                [PSCustomObject]$orderedObject
            }

            return $orderedResults
        }
    }

    if ($HelperGUI) {
        Invoke-SearchUnifiedAuditLogCustomHelperGUI
        return
    }

    if (-not $StartDate) {
        $StartDate = (Get-Date).AddDays(-1)
    }

    if (-not $EndDate) {
        $EndDate = Get-Date
    }

    $searchParams = @{
        StartDate  = $StartDate
        EndDate    = $EndDate
        ResultSize = $ResultSize
    }

    if ($Operations) {
        $searchParams['Operations'] = $Operations
    }

    if ($UserIds) {
        $searchParams['UserIds'] = $UserIds
    }

    if ($FreeText) {
        $searchParams['FreeText'] = $FreeText
    }

    [array]$auditLogs = Search-UnifiedAuditLog @searchParams

    if ($SimpleView) {
        return $auditLogs | Get-SimpleUnifiedAuditLog
    }

    return $auditLogs
}

function Invoke-SearchUnifiedAuditLogCustomHelperGUI {
    Add-Type -AssemblyName PresentationFramework
    Add-Type -AssemblyName PresentationCore
    Add-Type -AssemblyName WindowsBase

    $moduleVersion = $null
    $loadedModule = Get-Module -Name 'PS365' -ErrorAction SilentlyContinue
    if ($loadedModule -and $loadedModule.Version) {
        $moduleVersion = "v$($loadedModule.Version)"
    }
    else {
        try {
            $manifestPath = Join-Path $PSScriptRoot '..\..\PS365.psd1'
            $manifest = Import-PowerShellDataFile -Path $manifestPath -ErrorAction Stop
            if ($manifest.ModuleVersion) { $moduleVersion = "v$($manifest.ModuleVersion)" }
        }
        catch {
            Write-Verbose "Could not read module version: $($_.Exception.Message)"
        }
    }

    $splashLogoPath = Join-Path $PSScriptRoot '..\..\Private\Assets\Search-UnifiedAuditLogCustom.png'
    $splash = Show-Splash `
        -Title 'Search-UnifiedAuditLogCustom' `
        -Subtitle 'Audit log search helper' `
        -InitialMessage 'Initializing...' `
        -Version $moduleVersion `
        -LogoPath $splashLogoPath

    [System.Collections.Generic.List[PSCustomObject]]$operationChoices = @()
    $operationLookupByDisplay = @{}

    try {
        $splash.Update('Loading audit operations catalog from Microsoft Learn...')
        $tableRows = Get-HTMLTables -URL 'https://learn.microsoft.com/en-us/purview/audit-log-activities'
        if ($tableRows) {
            $rowsWithOperation = $tableRows | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Operation) }
            foreach ($row in $rowsWithOperation) {
                $operationName = [string]$row.Operation
                $operationName = $operationName.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('.')

                $exists = $operationChoices | Where-Object { $_.Operation -eq $operationName }
                if (-not $exists) {
                    $operationChoices.Add([PSCustomObject]@{
                            FriendlyName = $friendlyName
                            Operation    = $operationName
                        })
                }
            }
        }
    }
    catch {
        Write-Verbose "Failed to load operation catalog from Purview page: $($_.Exception.Message)"
    }

    $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Search-UnifiedAuditLogCustom Helper"
    Width="1200" Height="860"
    MinWidth="1200" MinHeight="860"
        WindowStartupLocation="CenterScreen"
        Background="#F3F5F8"
        FontFamily="Segoe UI">
    <Window.Resources>
        <Style TargetType="TextBox">
            <Setter Property="Margin" Value="0,4,0,0"/>
            <Setter Property="Padding" Value="8,6"/>
            <Setter Property="BorderBrush" Value="#CBD5E1"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Background" Value="White"/>
        </Style>
        <Style TargetType="Button">
            <Setter Property="Margin" Value="0,6,6,0"/>
            <Setter Property="Padding" Value="10,5"/>
            <Setter Property="MinHeight" Value="30"/>
            <Setter Property="MinWidth" Value="76"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="BorderBrush" Value="#CBD5E1"/>
            <Setter Property="Foreground" Value="#0F172A"/>
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="FontWeight" Value="Normal"/>
        </Style>
        <Style x:Key="GhostButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Foreground" Value="#0F172A"/>
            <Setter Property="Background" Value="#F8FAFC"/>
        </Style>
        <Style x:Key="SuccessButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="Foreground" Value="#0F172A"/>
        </Style>
        <Style x:Key="NeutralButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="Foreground" Value="#0F172A"/>
        </Style>
        <Style x:Key="PrimaryButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="BorderBrush" Value="#CBD5E1"/>
            <Setter Property="Foreground" Value="#0F172A"/>
            <Setter Property="FontWeight" Value="Normal"/>
        </Style>
    </Window.Resources>
 
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
 
        <Border Grid.Row="0" Background="White" CornerRadius="10" Padding="16" BorderBrush="#E2E8F0" BorderThickness="1">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="180"/>
                </Grid.ColumnDefinitions>
 
                <StackPanel Grid.Column="0" Margin="0,0,12,0">
                    <TextBlock Text="StartDate" FontWeight="SemiBold"/>
                    <DatePicker x:Name="StartDatePicker" Margin="0,4,0,0"/>
                    <TextBox x:Name="StartDateBox" ToolTip="Format: yyyy-MM-dd HH:mm"/>
                    <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                        <Button x:Name="StartYesterdayButton" Content="Yesterday 00:00" Style="{StaticResource GhostButtonStyle}"/>
                        <Button x:Name="StartNowButton" Content="Now" Style="{StaticResource GhostButtonStyle}"/>
                    </StackPanel>
                </StackPanel>
 
                <StackPanel Grid.Column="1" Margin="0,0,12,0">
                    <TextBlock Text="EndDate" FontWeight="SemiBold"/>
                    <DatePicker x:Name="EndDatePicker" Margin="0,4,0,0"/>
                    <TextBox x:Name="EndDateBox" ToolTip="Format: yyyy-MM-dd HH:mm"/>
                    <StackPanel Orientation="Horizontal" Margin="0,4,0,0">
                        <Button x:Name="EndTodayButton" Content="Today 23:59" Style="{StaticResource GhostButtonStyle}"/>
                        <Button x:Name="EndNowButton" Content="Now" Style="{StaticResource GhostButtonStyle}"/>
                    </StackPanel>
                </StackPanel>
 
                <StackPanel Grid.Column="2">
                    <TextBlock Text="ResultSize" FontWeight="SemiBold"/>
                    <TextBox x:Name="ResultSizeBox" Text="5000"/>
                    <CheckBox x:Name="SimpleViewCheckBox" Content="Simple view" Margin="0,10,0,0" VerticalAlignment="Center"/>
                    <Button x:Name="RetentionInfoButton" Content="How retention works?" Margin="0,10,0,0" HorizontalAlignment="Left" Style="{StaticResource GhostButtonStyle}"/>
                </StackPanel>
            </Grid>
        </Border>
 
        <Border Grid.Row="1" Margin="0,10,0,0" Background="White" CornerRadius="10" Padding="12" BorderBrush="#E2E8F0" BorderThickness="1">
            <StackPanel>
                <TextBlock Text="Presets" FontWeight="SemiBold" Margin="0,0,0,6"/>
                <StackPanel Orientation="Horizontal">
                    <Button x:Name="LoadSharingEventsButton" Content="Sharing activity (SPO/OneDrive)" Style="{StaticResource PrimaryButtonStyle}"/>
                </StackPanel>
            </StackPanel>
        </Border>
 
        <Border Grid.Row="2" Margin="0,14,0,0" Background="White" CornerRadius="10" Padding="16" BorderBrush="#E2E8F0" BorderThickness="1">
            <StackPanel>
                <TextBlock Text="Operations (search friendly name OR type raw cmdlets like New-TransportRule, comma/semicolon-separated)" FontWeight="SemiBold" TextWrapping="Wrap"/>
                <Grid Margin="0,6,0,0">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="Auto"/>
                    </Grid.ColumnDefinitions>
                    <TextBox x:Name="OperationsSearchBox" Grid.Column="0" ToolTip="Type to filter the list below, OR type a raw cmdlet (e.g. New-TransportRule) and press Enter / click 'Add as raw'"/>
                    <Button x:Name="AddCustomOperationButton" Grid.Column="1" Content="Add as raw" Margin="6,4,0,0" Style="{StaticResource PrimaryButtonStyle}"/>
                </Grid>
            </StackPanel>
        </Border>
 
        <Border Grid.Row="3" Margin="0,10,0,0" Background="White" CornerRadius="10" Padding="12" BorderBrush="#E2E8F0" BorderThickness="1">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="1.3*"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="1.3*"/>
                </Grid.ColumnDefinitions>
 
                <StackPanel Grid.Column="0">
                    <TextBlock Text="Available operations" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ListBox x:Name="AvailableOperationsListBox" Height="190" SelectionMode="Extended" BorderThickness="1" BorderBrush="#CBD5E1"
                             ScrollViewer.HorizontalScrollBarVisibility="Auto"
                             ScrollViewer.CanContentScroll="True"
                             VirtualizingStackPanel.IsVirtualizing="True"
                             VirtualizingStackPanel.VirtualizationMode="Recycling"/>
                </StackPanel>
 
                <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,0">
                    <Button x:Name="AddOperationButton" Content="+ Add" Width="72" Style="{StaticResource PrimaryButtonStyle}"/>
                    <Button x:Name="RemoveOperationButton" Content="- Remove" Width="72" Style="{StaticResource NeutralButtonStyle}"/>
                    <Button x:Name="ClearOperationsButton" Content="Clear" Width="72" Style="{StaticResource GhostButtonStyle}"/>
                </StackPanel>
 
                <StackPanel Grid.Column="2">
                    <TextBlock Text="Selected operations" FontWeight="SemiBold" Margin="0,0,0,6"/>
                    <ListBox x:Name="SelectedOperationsListBox" Height="190" SelectionMode="Extended" BorderThickness="1" BorderBrush="#CBD5E1"
                             ScrollViewer.HorizontalScrollBarVisibility="Auto"
                             ScrollViewer.CanContentScroll="True"
                             VirtualizingStackPanel.IsVirtualizing="True"
                             VirtualizingStackPanel.VirtualizationMode="Recycling"/>
                </StackPanel>
            </Grid>
        </Border>
 
        <Border Grid.Row="4" Margin="0,10,0,0" Background="White" CornerRadius="10" Padding="16" BorderBrush="#E2E8F0" BorderThickness="1">
            <StackPanel>
                <TextBlock Text="Generated command" FontWeight="SemiBold"/>
                <TextBox x:Name="CommandBox" Margin="0,6,0,0" Height="120" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AcceptsReturn="True"/>
            </StackPanel>
        </Border>
 
        <Grid Grid.Row="5" Margin="0,14,0,0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Left">
                <Button x:Name="CopyButton" Content="Copy" Style="{StaticResource SuccessButtonStyle}"
                        ToolTip="Copies the generated command to the clipboard. Rarely needed here - use 'Run now' to execute the query and export the results directly."/>
                <Button x:Name="RunButton" Content="Run now" Style="{StaticResource PrimaryButtonStyle}"
                        ToolTip="Runs the query against the Unified Audit Log. A file dialog will open first to choose where to save the results - in this mode, results are always exported to an Excel file."/>
                <Button x:Name="CloseButton" Content="Close" Style="{StaticResource NeutralButtonStyle}"/>
            </StackPanel>
            <TextBlock x:Name="FooterText" Grid.Column="1" Foreground="#64748B" FontSize="11"
                       VerticalAlignment="Center" HorizontalAlignment="Right"
                       Text="by Clidsys - Bastien Perez"/>
        </Grid>
    </Grid>
</Window>
'@


    if ($splash) { $splash.Update('Building interface...') }
    $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml)
    $window = [Windows.Markup.XamlReader]::Load($reader)

    $iconPath = Join-Path $PSScriptRoot '..\..\Private\Assets\Search-UnifiedAuditLogCustom.png'
    if (Test-Path -LiteralPath $iconPath) {
        try {
            $iconBitmap = New-Object System.Windows.Media.Imaging.BitmapImage
            $iconBitmap.BeginInit()
            $iconBitmap.UriSource = [Uri]$iconPath
            $iconBitmap.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
            $iconBitmap.EndInit()
            $iconBitmap.Freeze()
            $window.Icon = $iconBitmap
        }
        catch {
            Write-Verbose "Could not load window icon: $($_.Exception.Message)"
        }
    }

    $footerText = $window.FindName('FooterText')
    if ($footerText) {
        $footerText.Text = if ($moduleVersion) {
            "by Clidsys - Bastien Perez $moduleVersion"
        }
        else {
            'by Clidsys - Bastien Perez'
        }
    }

    $startDateBox = $window.FindName('StartDateBox')
    $endDateBox = $window.FindName('EndDateBox')
    $startDatePicker = $window.FindName('StartDatePicker')
    $endDatePicker = $window.FindName('EndDatePicker')
    $startYesterdayButton = $window.FindName('StartYesterdayButton')
    $startNowButton = $window.FindName('StartNowButton')
    $endTodayButton = $window.FindName('EndTodayButton')
    $endNowButton = $window.FindName('EndNowButton')
    $resultSizeBox = $window.FindName('ResultSizeBox')
    $simpleViewCheckBox = $window.FindName('SimpleViewCheckBox')
    $loadSharingEventsButton = $window.FindName('LoadSharingEventsButton')
    $operationsSearchBox = $window.FindName('OperationsSearchBox')
    $addCustomOperationButton = $window.FindName('AddCustomOperationButton')
    $availableOperationsListBox = $window.FindName('AvailableOperationsListBox')
    $selectedOperationsListBox = $window.FindName('SelectedOperationsListBox')
    $addOperationButton = $window.FindName('AddOperationButton')
    $removeOperationButton = $window.FindName('RemoveOperationButton')
    $clearOperationsButton = $window.FindName('ClearOperationsButton')
    $commandBox = $window.FindName('CommandBox')
    $copyButton = $window.FindName('CopyButton')
    $runButton = $window.FindName('RunButton')
    $closeButton = $window.FindName('CloseButton')
    $retentionInfoButton = $window.FindName('RetentionInfoButton')

    $startDateBox.Text = (Get-Date).AddDays(-1).Date.ToString('yyyy-MM-dd 00:00')
    $endDateBox.Text = (Get-Date).Date.ToString('yyyy-MM-dd 23:59')
    $startDatePicker.SelectedDate = (Get-Date).AddDays(-1).Date
    $endDatePicker.SelectedDate = (Get-Date).Date

    foreach ($entry in $operationChoices) {
        if ([string]::IsNullOrWhiteSpace($entry.Operation)) {
            continue
        }

        $display = "$($entry.FriendlyName) [$($entry.Operation)]"
        $operationLookupByDisplay[$display] = $entry.Operation
    }

    $refreshOperationsList = {
        $availableOperationsListBox.Items.Clear()

        $searchValue = $operationsSearchBox.Text
        $filteredOperations = $operationChoices
        if (-not [string]::IsNullOrWhiteSpace($searchValue)) {
            $filteredOperations = $operationChoices | Where-Object {
                $_.FriendlyName -like "*$searchValue*" -or $_.Operation -like "*$searchValue*"
            }
        }

        foreach ($entry in $filteredOperations) {
            if ([string]::IsNullOrWhiteSpace($entry.Operation)) {
                continue
            }

            $display = "$($entry.FriendlyName) [$($entry.Operation)]"
            $null = $availableOperationsListBox.Items.Add($display)
        }
    }

    $parseDateInput = {
        param(
            [string]$InputText,
            [datetime]$Fallback
        )

        try {
            return [datetime]::ParseExact($InputText, 'yyyy-MM-dd HH:mm', [System.Globalization.CultureInfo]::InvariantCulture)
        }
        catch {
            return $Fallback
        }
    }

    $buildCommand = {
        $startParsed = & $parseDateInput -InputText $startDateBox.Text -Fallback ((Get-Date).AddDays(-1).Date)
        $endParsed = & $parseDateInput -InputText $endDateBox.Text -Fallback ((Get-Date).Date.AddHours(23).AddMinutes(59))

        $startValue = $startParsed.ToString('yyyy-MM-dd HH:mm')
        $endValue = $endParsed.ToString('yyyy-MM-dd HH:mm')

        $sizeValue = 5000
        [void][int]::TryParse($resultSizeBox.Text, [ref]$sizeValue)
        if ($sizeValue -lt 1) { $sizeValue = 1 }

        $selectedOperations = @()
        foreach ($selectedDisplay in $selectedOperationsListBox.Items) {
            if ($operationLookupByDisplay.ContainsKey([string]$selectedDisplay)) {
                $selectedOperations += $operationLookupByDisplay[[string]$selectedDisplay]
            }
        }

        $command = "Search-UnifiedAuditLogCustom -StartDate '$startValue' -EndDate '$endValue' -ResultSize $sizeValue"
        if ($selectedOperations.Count -gt 0) {
            $operationsString = '"' + ($selectedOperations -join '","') + '"'
            $command += " -Operations @($operationsString)"
        }

        if ($simpleViewCheckBox.IsChecked -eq $true) {
            $command += ' -SimpleView'
        }

        $commandBox.Text = $command
    }

    $operationsSearchBox.Add_TextChanged({
            & $refreshOperationsList
            & $buildCommand
        })

    $availableOperationsListBox.Add_MouseDoubleClick({
            if ($availableOperationsListBox.SelectedItem -and -not $selectedOperationsListBox.Items.Contains($availableOperationsListBox.SelectedItem)) {
                $null = $selectedOperationsListBox.Items.Add($availableOperationsListBox.SelectedItem)
                & $buildCommand
            }
        })

    $availableOperationsListBox.Add_SelectionChanged({
            & $buildCommand
        })

    $selectedOperationsListBox.Add_MouseDoubleClick({
            if ($selectedOperationsListBox.SelectedItem) {
                $selectedOperationsListBox.Items.Remove($selectedOperationsListBox.SelectedItem)
                & $buildCommand
            }
        })

    $addOperationButton.Add_Click({
            if ($availableOperationsListBox.SelectedItems.Count -gt 0) {
                foreach ($item in @($availableOperationsListBox.SelectedItems)) {
                    if (-not $selectedOperationsListBox.Items.Contains($item)) {
                        $null = $selectedOperationsListBox.Items.Add($item)
                    }
                }
                & $buildCommand
            }
        })

    $removeOperationButton.Add_Click({
            if ($selectedOperationsListBox.SelectedItems.Count -gt 0) {
                foreach ($item in @($selectedOperationsListBox.SelectedItems)) {
                    $selectedOperationsListBox.Items.Remove($item)
                }
                & $buildCommand
            }
        })

    $clearOperationsButton.Add_Click({
            $selectedOperationsListBox.Items.Clear()
            & $buildCommand
        })

    $addCustomOperation = {
        $raw = $operationsSearchBox.Text
        if ([string]::IsNullOrWhiteSpace($raw)) {
            return
        }
        foreach ($entry in ($raw -split '[,;]')) {
            $opName = $entry.Trim()
            if ([string]::IsNullOrWhiteSpace($opName)) {
                continue
            }
            $display = "$opName [custom]"
            $operationLookupByDisplay[$display] = $opName
            if (-not $selectedOperationsListBox.Items.Contains($display)) {
                $null = $selectedOperationsListBox.Items.Add($display)
            }
        }
        $operationsSearchBox.Text = ''
        & $buildCommand
    }

    $addCustomOperationButton.Add_Click({ & $addCustomOperation })

    $operationsSearchBox.Add_KeyDown({
            param($source, $e)
            if ($e.Key -eq 'Return') {
                & $addCustomOperation
                $e.Handled = $true
            }
        })

    $selectedOperationsListBox.Add_SelectionChanged({
            & $buildCommand
        })

    $startDateBox.Add_TextChanged({
            & $buildCommand
        })

    $endDateBox.Add_TextChanged({
            & $buildCommand
        })

    $startDatePicker.Add_SelectedDateChanged({
            if ($startDatePicker.SelectedDate) {
                $existingTime = '00:00'
                if ($startDateBox.Text -match '^\d{4}-\d{2}-\d{2}\s(\d{2}:\d{2})$') {
                    $existingTime = $Matches[1]
                }
                $startDateBox.Text = ([datetime]$startDatePicker.SelectedDate).ToString("yyyy-MM-dd $existingTime")
            }
        })

    $endDatePicker.Add_SelectedDateChanged({
            if ($endDatePicker.SelectedDate) {
                $existingTime = '23:59'
                if ($endDateBox.Text -match '^\d{4}-\d{2}-\d{2}\s(\d{2}:\d{2})$') {
                    $existingTime = $Matches[1]
                }
                $endDateBox.Text = ([datetime]$endDatePicker.SelectedDate).ToString("yyyy-MM-dd $existingTime")
            }
        })

    $startYesterdayButton.Add_Click({
            $startDateBox.Text = (Get-Date).AddDays(-1).Date.ToString('yyyy-MM-dd 00:00')
            $startDatePicker.SelectedDate = (Get-Date).AddDays(-1).Date
        })

    $startNowButton.Add_Click({
            $now = Get-Date
            $startDateBox.Text = $now.ToString('yyyy-MM-dd HH:mm')
            $startDatePicker.SelectedDate = $now.Date
        })

    $endTodayButton.Add_Click({
            $endDateBox.Text = (Get-Date).Date.ToString('yyyy-MM-dd 23:59')
            $endDatePicker.SelectedDate = (Get-Date).Date
        })

    $endNowButton.Add_Click({
            $now = Get-Date
            $endDateBox.Text = $now.ToString('yyyy-MM-dd HH:mm')
            $endDatePicker.SelectedDate = $now.Date
        })

    $resultSizeBox.Add_TextChanged({
            & $buildCommand
        })

    $simpleViewCheckBox.Add_Click({
            & $buildCommand
        })

    $loadSharingEventsButton.Add_Click({
            # Load preset: Sharing & SharePoint/OneDrive Events from 2026-01-01 to today
            $startDateBox.Text = '2026-01-01 00:00'
            $endDateBox.Text = (Get-Date).ToString('yyyy-MM-dd 23:59')
            $startDatePicker.SelectedDate = [datetime]'2026-01-01'
            $endDatePicker.SelectedDate = (Get-Date).Date
            $resultSizeBox.Text = '5000'
            $simpleViewCheckBox.IsChecked = $true

            # Sharing operations documented in MS Learn (audit-log-sharing)
            $sharingOperations = @(
                'SharingInvitationCreated',
                'SharingInvitationAccepted',
                'AnonymousLinkCreated',
                'AnonymousLinkUsed',
                'SecureLinkCreated',
                'AddedToSecureLink',
                'SharingSet',
                'AddedToGroup'
            )

            $selectedOperationsListBox.Items.Clear()
            foreach ($op in $sharingOperations) {
                $matchedDisplay = $null
                foreach ($display in $availableOperationsListBox.Items) {
                    if ($display.Contains("[$op]")) {
                        $matchedDisplay = $display
                        break
                    }
                }
                if ($matchedDisplay) {
                    $null = $selectedOperationsListBox.Items.Add($matchedDisplay)
                }
                else {
                    $customDisplay = "$op [custom]"
                    $operationLookupByDisplay[$customDisplay] = $op
                    $null = $selectedOperationsListBox.Items.Add($customDisplay)
                }
            }

            & $buildCommand
        })

    $copyButton.Add_Click({
            if (-not [string]::IsNullOrWhiteSpace($commandBox.Text)) {
                [System.Windows.Clipboard]::SetText($commandBox.Text)
            }
        })

    $runButton.Add_Click({
            $exoConnected = $false
            if (Get-Command -Name Get-ConnectionInformation -ErrorAction SilentlyContinue) {
                $exoConnected = [bool](Get-ConnectionInformation -ErrorAction SilentlyContinue |
                    Where-Object { $_.State -eq 'Connected' -and $_.TokenStatus -eq 'Active' })
            }
            if (-not $exoConnected) {
                [System.Windows.MessageBox]::Show(
                    'Not connected to Exchange Online. Run Connect-ExchangeOnline first, then retry.',
                    'Exchange Online connection required',
                    'OK',
                    'Error') | Out-Null
                return
            }

            $startDate = & $parseDateInput -InputText $startDateBox.Text -Fallback ((Get-Date).AddDays(-1).Date)
            $endDate = & $parseDateInput -InputText $endDateBox.Text -Fallback ((Get-Date).Date.AddHours(23).AddMinutes(59))

            $sizeValue = 5000
            [void][int]::TryParse($resultSizeBox.Text, [ref]$sizeValue)
            if ($sizeValue -lt 1) { $sizeValue = 1 }

            $selectedOperations = @()
            foreach ($selectedDisplay in $selectedOperationsListBox.Items) {
                if ($operationLookupByDisplay.ContainsKey([string]$selectedDisplay)) {
                    $selectedOperations += $operationLookupByDisplay[[string]$selectedDisplay]
                }
            }

            $runParams = @{
                StartDate  = $startDate
                EndDate    = $endDate
                ResultSize = $sizeValue
            }

            if ($selectedOperations.Count -gt 0) {
                $runParams['Operations'] = $selectedOperations
            }

            if ($simpleViewCheckBox.IsChecked -eq $true) {
                $runParams['SimpleView'] = $true
            }

            $defaultFileName = "UnifiedAuditLog_$($startDate.ToString('yyyyMMdd-HHmm'))_to_$($endDate.ToString('yyyyMMdd-HHmm')).xlsx"
            $saveDialog = New-Object Microsoft.Win32.SaveFileDialog
            $saveDialog.Title = 'Save audit log results as Excel'
            $saveDialog.Filter = 'Excel workbook (*.xlsx)|*.xlsx'
            $saveDialog.FileName = $defaultFileName
            $saveDialog.InitialDirectory = [Environment]::GetFolderPath('UserProfile')
            if (-not $saveDialog.ShowDialog()) {
                return
            }
            $excelPath = $saveDialog.FileName

            $window.Cursor = [System.Windows.Input.Cursors]::Wait
            try {
                $results = Search-UnifiedAuditLogCustom @runParams

                if (-not $results -or @($results).Count -eq 0) {
                    [System.Windows.MessageBox]::Show('No audit log entries returned for the selected filters.', 'No results', 'OK', 'Warning') | Out-Null
                    return
                }

                $rawCount = @($results).Count
                $dedupKey = $null
                foreach ($candidate in @('Identity', 'Id')) {
                    if ($results[0].PSObject.Properties.Name -contains $candidate) {
                        $dedupKey = $candidate
                        break
                    }
                }
                if ($dedupKey) {
                    $results = $results | Sort-Object -Property $dedupKey -Unique
                }
                $finalCount = @($results).Count
                $duplicatesRemoved = $rawCount - $finalCount

                if (-not (Get-Module -ListAvailable -Name ImportExcel)) {
                    [System.Windows.MessageBox]::Show('ImportExcel module is not installed. Run: Install-Module ImportExcel', 'Missing module', 'OK', 'Error') | Out-Null
                    return
                }

                $excelSplat = @{
                    Path          = $excelPath
                    WorksheetName = 'AuditLog'
                    AutoSize      = $true
                    AutoFilter    = $true
                    FreezeTopRow  = $true
                    TableName     = 'AuditLog'
                    ClearSheet    = $true
                }
                $results | Export-Excel @excelSplat

                $dedupMessage = if ($duplicatesRemoved -gt 0) { "`n($duplicatesRemoved duplicate row(s) removed)" } else { '' }
                $answer = [System.Windows.MessageBox]::Show("$finalCount row(s) exported to:`n$excelPath$dedupMessage`n`nOpen the file now?", 'Export complete', 'YesNo', 'Information')
                if ($answer -eq [System.Windows.MessageBoxResult]::Yes) {
                    Start-Process -FilePath $excelPath
                }
            }
            catch {
                [System.Windows.MessageBox]::Show("Error during export:`n$($_.Exception.Message)", 'Error', 'OK', 'Error') | Out-Null
            }
            finally {
                $window.Cursor = [System.Windows.Input.Cursors]::Arrow
            }
        })

    $closeButton.Add_Click({
            $window.Close()
        })

    $retentionInfoButton.Add_Click({
            $retentionXaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="How audit log retention works"
        Width="680" Height="500"
        WindowStartupLocation="CenterOwner"
        ResizeMode="NoResize"
        Background="#F3F5F8"
        FontFamily="Segoe UI">
    <Window.Resources>
        <Style TargetType="Button">
            <Setter Property="Margin" Value="0,6,8,0"/>
            <Setter Property="Padding" Value="12,6"/>
            <Setter Property="MinHeight" Value="30"/>
            <Setter Property="MinWidth" Value="120"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="BorderBrush" Value="#CBD5E1"/>
            <Setter Property="Foreground" Value="#0F172A"/>
            <Setter Property="Background" Value="#F8FAFC"/>
            <Setter Property="Cursor" Value="Hand"/>
        </Style>
    </Window.Resources>
 
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
 
        <Border Grid.Row="0" Background="#FDEFD0" CornerRadius="10" Padding="14" BorderBrush="#F1C97A" BorderThickness="1">
            <StackPanel>
                <TextBlock Text="Audit log retention - what Microsoft says vs. what actually works"
                           FontWeight="SemiBold" FontSize="14" Foreground="#7A3A00" TextWrapping="Wrap"/>
            </StackPanel>
        </Border>
 
        <Border Grid.Row="1" Margin="0,12,0,0" Background="White" CornerRadius="10" Padding="16"
                BorderBrush="#E2E8F0" BorderThickness="1">
            <StackPanel>
                <TextBlock FontWeight="SemiBold" Foreground="#0F172A" Margin="0,0,0,4"
                           Text="Microsoft's official retention tiers"/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- 180 days: default retention since October 2023 (no specific license required)."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- Up to 1 year: requires Office 365 E5 / Microsoft 365 E5 / Microsoft 365 E7, Microsoft Purview Suite (formerly Microsoft 365 E5 Compliance), or the E5 eDiscovery and Audit add-on."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="- Up to 10 years: requires the above plus the 10-year audit log retention add-on."/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155" Margin="0,4,0,0"
                           Text="Get-AdminAuditLogConfig nonetheless returns AdminAuditLogAgeLimit = 90 days."/>
 
                <TextBlock FontWeight="SemiBold" Foreground="#0F172A" Margin="0,12,0,4"
                           Text="In practice: up to 365 days on all tenants"/>
                <TextBlock TextWrapping="Wrap" Foreground="#334155"
                           Text="Search-UnifiedAuditLog accepts queries up to 365 days back even on non-E5 tenants, regardless of the values advertised by the documentation and the PowerShell configuration."/>
 
                <TextBlock Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#64748B" FontStyle="Italic"
                           Text="Click 'Open article' to read the full write-up with PowerShell examples."/>
            </StackPanel>
        </Border>
 
        <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
            <Button x:Name="OpenArticleButton" Content="Open article"/>
            <Button x:Name="CloseRetentionButton" Content="Close"/>
        </StackPanel>
    </Grid>
</Window>
'@


            $retentionReader = New-Object System.Xml.XmlNodeReader ([xml]$retentionXaml)
            $retentionWindow = [Windows.Markup.XamlReader]::Load($retentionReader)
            $retentionWindow.Owner = $window

            $openArticleButton = $retentionWindow.FindName('OpenArticleButton')
            $closeRetentionButton = $retentionWindow.FindName('CloseRetentionButton')

            $openArticleButton.Add_Click({
                    Start-Process 'https://itpro-tips.com/microsoft-365-audit-logs-are-now-retained-for-365-days-for-all-tenants-with-powershell/'
                    $retentionWindow.Close()
                })

            $closeRetentionButton.Add_Click({ $retentionWindow.Close() })

            [void]$retentionWindow.ShowDialog()
        })

    & $refreshOperationsList
    & $buildCommand

    $window.Add_Closed({
            try {
                $availableOperationsListBox.ItemsSource = $null
                $availableOperationsListBox.Items.Clear()
                $selectedOperationsListBox.Items.Clear()
            }
            catch { }
        })

    try {
        if ($splash) { $splash.Close(); $splash = $null }
        [void]$window.ShowDialog()
    }
    finally {
        if ($splash) { try { $splash.Close() } catch { } }
        $window = $null
        $availableOperationsListBox = $null
        $selectedOperationsListBox = $null
        [System.GC]::Collect()
        [System.GC]::WaitForPendingFinalizers()
        [System.GC]::Collect()
    }
}