diff --git a/Calendar/CalLogHelpers/CalLogCSVFunctions.ps1 b/Calendar/CalLogHelpers/CalLogCSVFunctions.ps1 index d86ecb518a..541bb54816 100644 --- a/Calendar/CalLogHelpers/CalLogCSVFunctions.ps1 +++ b/Calendar/CalLogHelpers/CalLogCSVFunctions.ps1 @@ -25,6 +25,7 @@ $script:CalendarItemTypes = @{ 'IPM.OLE.CLASS.{00061055-0000-0000-C000-000000000046}' = "Exception" 'IPM.Schedule.Meeting.Notification.Forward' = "Forward.Notification" 'IPM.Appointment' = "Ipm.Appointment" + 'IPM.Appointment.Occurrence' = "Exception.Occurrence" 'IPM.Schedule.Meeting.Request' = "Meeting.Request" 'IPM.CalendarSharing.EventUpdate' = "SharingCFM" 'IPM.CalendarSharing.EventDelete' = "SharingDelete" @@ -57,6 +58,389 @@ function GetItemType { return $null } +function IsExceptionItemType { + param( + [string]$ItemType + ) + + return (-not [string]::IsNullOrEmpty($ItemType)) -and ($ItemType -like 'Exception*') +} + +function IsExceptionLog { + param( + $CalLog + ) + + if ($null -eq $CalLog) { + return $false + } + + if ($CalLog.IsException -eq $true) { + return $true + } + + if ($null -ne $CalLog.CalendarItemType -and ([string]$CalLog.CalendarItemType) -like 'Exception*') { + return $true + } + + return IsExceptionItemType (GetItemType $CalLog.ItemClass) +} + +function Get-CalendarLogNumericSortValue { + param( + $Value + ) + + if ($null -eq $Value) { + return -1 + } + + $textValue = [string]$Value + if ([string]::IsNullOrEmpty($textValue) -or $textValue -eq 'NotFound' -or $textValue -eq '-') { + return -1 + } + + $numericValue = 0 + if ([int]::TryParse($textValue, [ref]$numericValue)) { + return $numericValue + } + + return [int]::MaxValue +} + +function Get-CalendarLogOriginalStartDateSortRank { + param( + $Value + ) + + if ($null -eq $Value) { + return 0 + } + + $textValue = [string]$Value + if ([string]::IsNullOrEmpty($textValue) -or $textValue -eq 'NotFound') { + return 0 + } + + return 1 +} + +function Get-CalendarLogOriginalStartDateSortValue { + param( + $Value + ) + + if ((Get-CalendarLogOriginalStartDateSortRank $Value) -eq 0) { + return [datetime]::MinValue + } + + $parsedValue = ConvertDateTimeSilent $Value + if ($parsedValue -is [datetime]) { + return $parsedValue + } + + return [datetime]::MinValue +} + +function ConvertValueToString { + param( + $Value + ) + + if ($null -eq $Value) { + return "" + } + + return [string]$Value +} + +function ConvertDateTimeSilent { + param( + $Value + ) + + if ($Value -is [datetime]) { + return $Value + } + + $textValue = [string]$Value + if ([string]::IsNullOrEmpty($textValue) -or $textValue -eq 'N/A' -or $textValue -eq 'NotFound') { + return [datetime]::MinValue + } + + $InvariantCulture = [System.Globalization.CultureInfo]::InvariantCulture + $DateStyles = [System.Globalization.DateTimeStyles]::None + $Parsed = [DateTime]::MinValue + + if ([DateTime]::TryParse($textValue, $InvariantCulture, $DateStyles, [ref]$Parsed)) { + return $Parsed + } + + if ([DateTime]::TryParseExact($textValue, $script:DateFormats, $InvariantCulture, $DateStyles, [ref]$Parsed)) { + return $Parsed + } + + if ([DateTime]::TryParse($textValue, [ref]$Parsed)) { + return $Parsed + } + + return [datetime]::MinValue +} + +function Get-CalendarLogTimestampSortValue { + param( + $Value + ) + + $parsedValue = ConvertDateTimeSilent $Value + if ($parsedValue -is [datetime]) { + return $parsedValue + } + + return [datetime]::MinValue +} + +function Get-CalendarLogDateBucketSortValue { + param( + $Value + ) + + $timestamp = Get-CalendarLogTimestampSortValue $Value + if ($timestamp -eq [datetime]::MinValue) { + return [datetime]::MinValue + } + + return $timestamp.Date +} + +function Get-CalendarLogTimestampGroupKey { + param( + $Value + ) + + $timestamp = Get-CalendarLogTimestampSortValue $Value + if ($timestamp -eq [datetime]::MinValue) { + return 'Unknown' + } + + return $timestamp.ToString('yyyy-MM-dd HH:mm:ss') +} + +function Get-SortCheckFailureDetails { + param( + [datetime]$PreviousTimestamp, + [datetime]$CurrentTimestamp, + [int]$PreviousIndex, + [int]$CurrentIndex, + $PreviousEntry, + $CurrentEntry, + [string]$ValuePropertyName + ) + + $previousValue = if ($null -ne $PreviousEntry -and -not [string]::IsNullOrEmpty($ValuePropertyName)) { [string]$PreviousEntry.$ValuePropertyName } else { '' } + $currentValue = if ($null -ne $CurrentEntry -and -not [string]::IsNullOrEmpty($ValuePropertyName)) { [string]$CurrentEntry.$ValuePropertyName } else { '' } + return "previous[$PreviousIndex]=$PreviousTimestamp raw=[$previousValue]; current[$CurrentIndex]=$CurrentTimestamp raw=[$currentValue]" +} + +function Test-LogTimestampOrder { + param( + [array]$Entries, + [string]$Name, + [string]$ValuePropertyName + ) + + $previousTimestamp = $null + $previousEntry = $null + $previousIndex = -1 + $parsedCount = 0 + $skippedCount = 0 + $totalCount = @($Entries).Count + + for ($index = 0; $index -lt @($Entries).Count; $index++) { + $entry = $Entries[$index] + if ($null -eq $entry) { + $skippedCount++ + continue + } + + $timestamp = Get-CalendarLogTimestampSortValue $entry.$ValuePropertyName + if ($timestamp -eq [datetime]::MinValue) { + $skippedCount++ + continue + } + + $parsedCount++ + if ($null -ne $previousTimestamp -and $previousTimestamp -gt $timestamp) { + $details = Get-SortCheckFailureDetails -PreviousTimestamp $previousTimestamp -CurrentTimestamp $timestamp -PreviousIndex $previousIndex -CurrentIndex $index -PreviousEntry $previousEntry -CurrentEntry $entry -ValuePropertyName $ValuePropertyName + Write-Verbose "$Name is not sorted correctly. $details" + return $false + } + + $previousTimestamp = $timestamp + $previousEntry = $entry + $previousIndex = $index + } + + $suffix = if ($skippedCount -gt 0) { " Skipped [$skippedCount] row(s) with blank or non-date timestamps." } else { '' } + Write-Verbose "$Name looks to be sorted correctly. Total rows [$totalCount]. Checked [$parsedCount] timestamped row(s).$suffix" + return $true +} + +function Write-UnknownTimestampDiagnostics { + param( + [array]$Entries, + [string]$Name, + [string]$ValuePropertyName, + [int]$MaxItems = 20 + ) + + $unknownEntries = [System.Collections.Generic.List[object]]::new() + + for ($index = 0; $index -lt @($Entries).Count; $index++) { + $entry = $Entries[$index] + + if ($null -eq $entry) { + [void]$unknownEntries.Add([PSCustomObject]@{ + Index = $index + Reason = 'Null entry' + RawValue = '' + LogRowType = '' + TriggerAction = '' + ItemClass = '' + CalendarLogRequestId = '' + OriginalStartDate = '' + }) + continue + } + + $timestamp = Get-CalendarLogTimestampSortValue $entry.$ValuePropertyName + if ($timestamp -eq [datetime]::MinValue) { + [void]$unknownEntries.Add([PSCustomObject]@{ + Index = $index + Reason = 'Timestamp resolved to DateTime.MinValue' + RawValue = [string]$entry.$ValuePropertyName + LogRowType = [string]$entry.LogRowType + TriggerAction = [string]$entry.TriggerAction + ItemClass = [string]$entry.ItemClass + CalendarLogRequestId = [string]$entry.CalendarLogRequestId + OriginalStartDate = [string]$entry.OriginalStartDate + }) + } + } + + if ($unknownEntries.Count -eq 0) { + Write-Verbose "$Name has no Unknown LogTimestamp rows." + return + } + + Write-Verbose "$Name has [$($unknownEntries.Count)] Unknown LogTimestamp row(s) out of [$(@($Entries).Count)] total row(s)." + foreach ($unknownEntry in ($unknownEntries | Select-Object -First $MaxItems)) { + Write-Verbose (" Index [{0}] Reason [{1}] Raw [{2}] LogRowType [{3}] TriggerAction [{4}] ItemClass [{5}] OriginalStartDate [{6}] CalendarLogRequestId [{7}]" -f $unknownEntry.Index, $unknownEntry.Reason, $unknownEntry.RawValue, $unknownEntry.LogRowType, $unknownEntry.TriggerAction, $unknownEntry.ItemClass, $unknownEntry.OriginalStartDate, $unknownEntry.CalendarLogRequestId) + } + + if ($unknownEntries.Count -gt $MaxItems) { + Write-Verbose " ... truncated after [$MaxItems] Unknown rows." + } +} + +function SortCalendarDiagnosticObjectsByTimestamp { + param( + [array]$CalLogs + ) + + if ($null -eq $CalLogs -or $CalLogs.Count -le 1) { + return @($CalLogs) + } + + return @($CalLogs | Sort-Object @{ Expression = { Get-CalendarLogTimestampSortValue $_.LogTimestamp } }) +} + +function SortEnhancedCalendarLogsByTimestamp { + param( + [array]$CalLogs + ) + + if ($null -eq $CalLogs -or $CalLogs.Count -le 1) { + return @($CalLogs) + } + + return @($CalLogs | Sort-Object @{ Expression = { Get-CalendarLogTimestampSortValue $_.LogTimestamp } }) +} + +function SortCalendarDiagnosticObjects { + param( + [array]$CalLogs + ) + + if ($null -eq $CalLogs -or $CalLogs.Count -le 1) { + return @($CalLogs) + } + + try { + return @($CalLogs | Sort-Object + @{ Expression = { Get-CalendarLogTimestampSortValue $_.LogTimestamp } }, + @{ Expression = { Get-CalendarLogOriginalStartDateSortRank $_.OriginalStartDate } }, + @{ Expression = { Get-CalendarLogOriginalStartDateSortValue $_.OriginalStartDate } }) + } catch { + Write-Warning "Secondary raw log sort failed; falling back to LogTimestamp-only sorting. $_" + return SortCalendarDiagnosticObjectsByTimestamp -CalLogs $CalLogs + } +} + +function SortEnhancedCalendarLogs { + param( + [array]$CalLogs + ) + + if ($null -eq $CalLogs -or $CalLogs.Count -le 1) { + return @($CalLogs) + } + + try { + # The Enhanced tab is the only output that gets re-ordered. + # Sort by LogTimestamp first, then use OriginalStartDate only as a tie-breaker for matching timestamps. + # Rows with unknown LogTimestamp values are preserved and appended to the end. + $workingLogs = @($CalLogs | Where-Object { $null -ne $_ }) + $removedNullCount = $CalLogs.Count - $workingLogs.Count + if ($removedNullCount -gt 0) { + Write-Verbose "Removed [$removedNullCount] null enhanced log row(s) before sorting." + } + + Write-Verbose "Starting Sorting Date" + Write-Verbose "Sorting Enhanced logs by LogTimestamp, then OriginalStartDate for matching timestamps." + + $sortIndex = 0 + $sortRows = foreach ($workingLog in $workingLogs) { + $sortIndex++ + [PSCustomObject]@{ + SortIndex = $sortIndex + TimestampValue = Get-CalendarLogTimestampSortValue $workingLog.LogTimestamp + OriginalStartDateRank = Get-CalendarLogOriginalStartDateSortRank $workingLog.OriginalStartDate + OriginalStartDateValue = Get-CalendarLogOriginalStartDateSortValue $workingLog.OriginalStartDate + Log = $workingLog + } + } + + $knownTimestampRows = @($sortRows | Where-Object { $_.TimestampValue -ne [datetime]::MinValue } | Sort-Object TimestampValue, OriginalStartDateRank, OriginalStartDateValue, SortIndex) + $unknownTimestampRows = @($sortRows | Where-Object { $_.TimestampValue -eq [datetime]::MinValue } | Sort-Object SortIndex) + + if ($unknownTimestampRows.Count -gt 0) { + Write-Verbose "Keeping [$($unknownTimestampRows.Count)] Unknown LogTimestamp row(s) at the end of the Enhanced output." + } + + $sortedLogs = @($knownTimestampRows | ForEach-Object { $_.Log }) + @($unknownTimestampRows | ForEach-Object { $_.Log }) + + Write-Verbose "Validating Enhanced list order before sub filtering..." + [void](Test-LogTimestampOrder -Entries $sortedLogs -Name 'Enhanced Tab' -ValuePropertyName 'LogTimestamp') + Write-UnknownTimestampDiagnostics -Entries $sortedLogs -Name 'Enhanced Tab' -ValuePropertyName 'LogTimestamp' + + return @($sortedLogs) + } catch { + Write-Warning "Secondary enhanced log sort failed; falling back to LogTimestamp-only sorting. $_" + return SortEnhancedCalendarLogsByTimestamp -CalLogs $CalLogs + } +} + # =================================================================================================== # Functions to support the script # =================================================================================================== @@ -69,11 +453,15 @@ function MapSharedFolder { param( $ExternalMasterID ) - if ($ExternalMasterID -eq "NotFound") { + if ($null -eq $ExternalMasterID -or [string]::IsNullOrEmpty([string]$ExternalMasterID) -or $ExternalMasterID -eq "NotFound") { return "Not Shared" - } else { - $script:SharedFolders[$ExternalMasterID] } + + if ($null -eq $script:SharedFolders -or -not $script:SharedFolders.ContainsKey($ExternalMasterID)) { + return "UnknownSharedCalendarCopy" + } + + return $script:SharedFolders[$ExternalMasterID] } <# @@ -182,6 +570,26 @@ Builds the CSV output from the Calendar Diagnostic Objects function BuildCSV { Write-Host "Starting to Process Calendar Logs..." + # De-duplicate while preserving the original collection order for RAW output. + $rawNullCount = @($script:GCDO).Count - @($script:GCDO | Where-Object { $null -ne $_ }).Count + if ($rawNullCount -gt 0) { + Write-Host -ForegroundColor Yellow "Removed [$rawNullCount] null raw calendar log row(s) before processing." + } + $dedupedCalLogs = New-Object 'System.Collections.Generic.List[object]' + $seenCalLogKeys = New-Object 'System.Collections.Generic.HashSet[string]' + $duplicateCount = 0 + foreach ($calLog in @($script:GCDO | Where-Object { $null -ne $_ })) { + $calLogKey = Get-CalendarDiagnosticObjectDeduplicationKey -CalLog $calLog + if ($seenCalLogKeys.Add($calLogKey)) { + [void]$dedupedCalLogs.Add($calLog) + } else { + $duplicateCount++ + } + } + if ($duplicateCount -gt 0) { + Write-Host -ForegroundColor Yellow "Removed [$duplicateCount] duplicate Calendar Log entries before processing." + } + $script:GCDO = @($dedupedCalLogs) $script:MailboxList = @{} # Initialize lookup caches to avoid redundant CN resolution across hundreds of log entries $script:SMTPAddressCache = @{} @@ -192,6 +600,8 @@ function BuildCSV { FixCalendarItemType($script:GCDO) Write-Host "Making Calendar Logs more readable..." + [void](Test-LogTimestampOrder -Entries $script:GCDO -Name 'RAW Tab before sorting' -ValuePropertyName 'LogTimestamp') + Write-UnknownTimestampDiagnostics -Entries $script:GCDO -Name 'RAW Tab before sorting' -ValuePropertyName 'LogTimestamp' $Index = 0 $GCDOResults = foreach ($CalLog in $script:GCDO) { $Index++ @@ -210,7 +620,7 @@ function BuildCSV { [PSCustomObject]@{ #'LogRow' = $Index 'LogTimestamp' = ConvertDateTime($CalLog.LogTimestamp) - 'LogRowType' = $CalLog.LogRowType.ToString() + 'LogRowType' = ConvertValueToString($CalLog.LogRowType) 'SubjectProperty' = $CalLog.SubjectProperty 'Client' = $CalLog.ShortClientInfoString 'LogClientInfoString' = $CalLog.LogClientInfoString @@ -219,26 +629,26 @@ function BuildCSV { 'Seq:Exp:ItemVersion' = CompressVersionInfo($CalLog) 'Organizer' = GetDisplayName($CalLog.From) 'From' = GetSMTPAddress($CalLog.From) - 'FreeBusy' = $CalLog.FreeBusyStatus.ToString() + 'FreeBusy' = ConvertValueToString($CalLog.FreeBusyStatus) 'ResponsibleUser' = GetSMTPAddress($CalLog.ResponsibleUserName) 'Sender' = GetSMTPAddress($CalLog.Sender) 'LogFolder' = $CalLog.ParentDisplayName 'OriginalLogFolder' = $CalLog.OriginalParentDisplayName 'SharedFolderName' = MapSharedFolder($CalLog.ExternalSharingMasterId) 'ReceivedRepresenting' = GetSMTPAddress($CalLog.ReceivedRepresenting) - 'MeetingRequestType' = $CalLog.MeetingRequestType.ToString() + 'MeetingRequestType' = ConvertValueToString($CalLog.MeetingRequestType) 'StartTime' = ConvertDateTime($CalLog.StartTime) 'EndTime' = ConvertDateTime($CalLog.EndTime) 'OriginalStartDate' = ConvertDateTime($CalLog.OriginalStartDate) 'Location' = $CalLog.Location - 'CalendarItemType' = $CalLog.CalendarItemType.ToString() + 'CalendarItemType' = ConvertValueToString($CalLog.CalendarItemType) 'RecurrencePattern' = $CalLog.RecurrencePattern - 'AppointmentAuxiliaryFlags' = $CalLog.AppointmentAuxiliaryFlags.ToString() + 'AppointmentAuxiliaryFlags' = ConvertValueToString($CalLog.AppointmentAuxiliaryFlags) 'DisplayAttendeesAll' = $(if ($CalLog.DisplayAttendeesAll -eq "NotFound") { "-" } else { $CalLog.DisplayAttendeesAll }) 'AttendeeCount' = GetAttendeeCount($CalLog.DisplayAttendeesAll) - 'AppointmentState' = $CalLog.AppointmentState.ToString() - 'ResponseType' = $CalLog.ResponseType.ToString() - 'ClientIntent' = $CalLog.ClientIntent.ToString() + 'AppointmentState' = ConvertValueToString($CalLog.AppointmentState) + 'ResponseType' = ConvertValueToString($CalLog.ResponseType) + 'ClientIntent' = ConvertValueToString($CalLog.ClientIntent) 'AppointmentRecurring' = $CalLog.AppointmentRecurring 'HasAttachment' = $CalLog.HasAttachment 'IsCancelled' = $CalLog.IsCancelled @@ -247,16 +657,153 @@ function BuildCSV { 'IsSeriesCancelled' = $CalLog.IsSeriesCancelled 'SendMeetingMessagesDiagnostics' = $CalLog.SendMeetingMessagesDiagnostics 'AttendeeCollection' = MultiLineFormat($CalLog.AttendeeCollection) - 'CalendarLogRequestId' = $CalLog.CalendarLogRequestId.ToString() # Move to front.../ Format in groups??? + 'CalendarLogRequestId' = ConvertValueToString($CalLog.CalendarLogRequestId) # Move to front.../ Format in groups??? 'CleanGlobalObjectId' = $CalLog.CleanGlobalObjectId } } - $script:EnhancedCalLogs = $GCDOResults + [void](Test-LogTimestampOrder -Entries $GCDOResults -Name 'Enhanced Tab before sorting' -ValuePropertyName 'LogTimestamp') + Write-UnknownTimestampDiagnostics -Entries $GCDOResults -Name 'Enhanced Tab before sorting' -ValuePropertyName 'LogTimestamp' + # Keep RAW output in collected order; only the Enhanced projection is re-sorted for display and Timeline generation. + $script:EnhancedCalLogs = SortEnhancedCalendarLogs -CalLogs $GCDOResults + [void](Test-LogTimestampOrder -Entries $script:GCDO -Name 'RAW Tab' -ValuePropertyName 'LogTimestamp') Write-Host -ForegroundColor Green "Calendar Logs have been processed, Exporting logs to file..." + BuildOrganizerUserNameMap Export-CalLog } +<# +.SYNOPSIS + Builds a set of all known identifiers (display names and email addresses) for the Organizer. + This allows change-detection code to treat different representations of the same person as equal. + Sources: From, Sender, ReceivedBy, ReceivedRepresenting, and AttendeeListDetails from raw CalLogs. +#> +function BuildOrganizerUserNameMap { + $script:OrganizerIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Seed with the enhanced Organizer display name and From SMTP from the first qualifying log + $firstOrgLog = $script:EnhancedCalLogs | Where-Object { + ($_.ItemClass -eq 'Ipm.Appointment' -or $_.ItemClass -like 'Exception*') -and + $_.SharedFolderName -eq 'Not Shared' + } | Select-Object -First 1 + + if ($null -ne $firstOrgLog) { + if (-not [string]::IsNullOrEmpty($firstOrgLog.From) -and $firstOrgLog.From -ne '-') { + [void]$script:OrganizerIdentities.Add($firstOrgLog.From.Trim()) + } + if (-not [string]::IsNullOrEmpty($firstOrgLog.Organizer) -and $firstOrgLog.Organizer -ne '-') { + [void]$script:OrganizerIdentities.Add($firstOrgLog.Organizer.Trim()) + } + } + + # Walk raw CalLogs for additional representations on qualifying rows + foreach ($CalLog in $script:GCDO) { + if ($null -eq $CalLog.ItemClass -or + ((GetItemType $CalLog.ItemClass) -ne 'Ipm.Appointment' -and -not (IsExceptionItemType (GetItemType $CalLog.ItemClass)))) { + continue + } + + # From field — extract display name and email + AddIdentitiesFromCNField $CalLog.From + + # ReceivedBy and ReceivedRepresenting — "Display Name" + AddIdentitiesFromCNField $CalLog.ReceivedBy + AddIdentitiesFromCNField $CalLog.ReceivedRepresenting + + # AttendeeListDetails — JSON mapping; look for the organizer's entry by matching known identities + if ($null -ne $CalLog.AttendeeListDetails -and -not [string]::IsNullOrEmpty([string]$CalLog.AttendeeListDetails) -and [string]$CalLog.AttendeeListDetails -ne 'NotFound') { + try { + $attendeeMap = $CalLog.AttendeeListDetails | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($null -ne $attendeeMap) { + foreach ($prop in $attendeeMap.PSObject.Properties) { + $email = $prop.Name + $displayName = $prop.Value.DisplayName + # Only add if we already know this email belongs to the organizer + if ($script:OrganizerIdentities.Contains($email)) { + if (-not [string]::IsNullOrEmpty($displayName)) { + [void]$script:OrganizerIdentities.Add($displayName.Trim()) + } + } + } + } + } catch { + Write-Verbose "BuildOrganizerUserNameMap: Unable to parse AttendeeListDetails: $_" + } + } + } + + if ($script:OrganizerIdentities.Count -gt 0) { + Write-Verbose "OrganizerIdentities map contains $($script:OrganizerIdentities.Count) entries:" + foreach ($id in $script:OrganizerIdentities) { Write-Verbose "`t$id" } + } else { + Write-Verbose "OrganizerIdentities map is empty — organizer change detection may be less accurate." + } +} + +<# +.SYNOPSIS + Extracts display name and email address from a CN-format field value and adds them to the organizer identity set. +#> +function AddIdentitiesFromCNField { + param($FieldValue) + + if ($null -eq $FieldValue -or [string]::IsNullOrEmpty([string]$FieldValue) -or [string]$FieldValue -eq 'NotFound') { + return + } + + $text = [string]$FieldValue + + # Extract SMTP address from format + $smtpMatch = [regex]::Match($text, '<([^>]+@[^>]+)>') + if ($smtpMatch.Success) { + [void]$script:OrganizerIdentities.Add($smtpMatch.Groups[1].Value.Trim()) + } + + # Extract display name — text before < or quoted text + if ($text -match '<') { + $displayPart = ($text -split '<')[0].Trim().Trim('"').Trim() + if (-not [string]::IsNullOrEmpty($displayPart)) { + [void]$script:OrganizerIdentities.Add($displayPart) + } + } + + # Also resolve via existing helpers — the resolved SMTP and display name + $resolvedSmtp = GetSMTPAddress $FieldValue + if (-not [string]::IsNullOrEmpty($resolvedSmtp) -and $resolvedSmtp -ne '-' -and $resolvedSmtp -ne 'NotFound') { + [void]$script:OrganizerIdentities.Add($resolvedSmtp.Trim()) + } + $resolvedDisplay = GetDisplayName $FieldValue + if (-not [string]::IsNullOrEmpty($resolvedDisplay) -and $resolvedDisplay -ne '-' -and $resolvedDisplay -ne 'NotFound') { + [void]$script:OrganizerIdentities.Add($resolvedDisplay.Trim()) + } +} + +<# +.SYNOPSIS + Returns $true if two organizer identity values refer to the same person, + using the OrganizerIdentities map for case-insensitive lookup. +#> +function IsSameOrganizerIdentity { + param( + [string]$Value1, + [string]$Value2 + ) + + if ([string]::Equals($Value1, $Value2, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + if ($null -ne $script:OrganizerIdentities -and $script:OrganizerIdentities.Count -gt 0) { + $v1Known = (-not [string]::IsNullOrEmpty($Value1)) -and $script:OrganizerIdentities.Contains($Value1.Trim()) + $v2Known = (-not [string]::IsNullOrEmpty($Value2)) -and $script:OrganizerIdentities.Contains($Value2.Trim()) + if ($v1Known -and $v2Known) { + return $true + } + } + + return $false +} + function ConvertDateTime { param( [string] $DateTime diff --git a/Calendar/CalLogHelpers/CalLogExportFunctions.ps1 b/Calendar/CalLogHelpers/CalLogExportFunctions.ps1 index d87e3c7daa..d9c956b6a6 100644 --- a/Calendar/CalLogHelpers/CalLogExportFunctions.ps1 +++ b/Calendar/CalLogHelpers/CalLogExportFunctions.ps1 @@ -55,7 +55,7 @@ function Export-CalLog { } function Export-CalLogCSV { - $GCDOResults | Export-Csv -Path $Filename -NoTypeInformation -Encoding UTF8 + $script:EnhancedCalLogs | Export-Csv -Path $Filename -NoTypeInformation -Encoding UTF8 $script:GCDO | Export-Csv -Path $FilenameRaw -NoTypeInformation -Encoding UTF8 } diff --git a/Calendar/CalLogHelpers/CreateTimelineRow.ps1 b/Calendar/CalLogHelpers/CreateTimelineRow.ps1 index c0622f2128..dc617ddc5d 100644 --- a/Calendar/CalLogHelpers/CreateTimelineRow.ps1 +++ b/Calendar/CalLogHelpers/CreateTimelineRow.ps1 @@ -86,7 +86,7 @@ function CreateTimelineRow { } $Extra = "" - if ($CalLog.CalendarItemType -eq "Exception") { + if (IsExceptionLog $CalLog) { $Extra = " to the meeting starting $($CalLog.StartTime)" } elseif ($CalLog.AppointmentRecurring) { $Extra = " to the meeting series" @@ -112,7 +112,7 @@ function CreateTimelineRow { Forward.Notification { [array] $Output = "The meeting was FORWARDED by [$($CalLog.Organizer)]." } - Exception { + Exception* { if ($CalLog.ResponsibleUser -ne "Calendar Assistant") { [array] $Output = "[$($CalLog.ResponsibleUser)] $($CalLog.TriggerAction)d Exception starting $($CalLog.StartTime) to the meeting series with $($CalLog.Client)." } diff --git a/Calendar/CalLogHelpers/ExceptionCollectionFunctions.ps1 b/Calendar/CalLogHelpers/ExceptionCollectionFunctions.ps1 new file mode 100644 index 0000000000..72a73f751a --- /dev/null +++ b/Calendar/CalLogHelpers/ExceptionCollectionFunctions.ps1 @@ -0,0 +1,392 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Read-UInt16LE { + param( + [byte[]]$Bytes, + [ref]$Offset + ) + + if (($Offset.Value + 2) -gt $Bytes.Length) { + throw "Unexpected end of AppointmentRecurrenceBlob while reading UInt16 at offset [$($Offset.Value)]." + } + + $value = [BitConverter]::ToUInt16($Bytes, $Offset.Value) + $Offset.Value += 2 + return $value +} + +function Read-UInt32LE { + param( + [byte[]]$Bytes, + [ref]$Offset + ) + + if (($Offset.Value + 4) -gt $Bytes.Length) { + throw "Unexpected end of AppointmentRecurrenceBlob while reading UInt32 at offset [$($Offset.Value)]." + } + + $value = [BitConverter]::ToUInt32($Bytes, $Offset.Value) + $Offset.Value += 4 + return $value +} + +function Convert-RecurMinutesToDateTime { + param( + [UInt32]$Minutes + ) + + return ([datetime]'1601-01-01').AddMinutes([double]$Minutes) +} + +function Get-RecurrencePatternSpecificSize { + param( + [UInt16]$PatternType + ) + + switch ($PatternType) { + 0 { return 0 } + 1 { return 4 } + 2 { return 4 } + 3 { return 8 } + 4 { return 4 } + 10 { return 4 } + 11 { return 8 } + 12 { return 4 } + default { + throw "Unsupported recurrence PatternType [$PatternType] in AppointmentRecurrenceBlob." + } + } +} + +function Convert-AppointmentRecurrenceBlobToBytes { + param( + $AppointmentRecurrenceBlob + ) + + if ($AppointmentRecurrenceBlob -is [byte[]]) { + return $AppointmentRecurrenceBlob + } + + $blobText = [string]$AppointmentRecurrenceBlob + if ([string]::IsNullOrWhiteSpace($blobText)) { + throw "AppointmentRecurrenceBlob is empty." + } + + $blobText = ($blobText -replace '\s+', '').Trim() + if (($blobText.Length % 2) -ne 0) { + throw "AppointmentRecurrenceBlob length is invalid. Expected an even-length hex string." + } + + $bytes = [byte[]]::new($blobText.Length / 2) + for ($index = 0; $index -lt $blobText.Length; $index += 2) { + $bytes[$index / 2] = [Convert]::ToByte($blobText.Substring($index, 2), 16) + } + + return $bytes +} + +function Get-RecentExceptionCutoff { + if ($FastExceptions.IsPresent -and -not $AllExceptions.IsPresent) { + return (Get-Date).Date.AddMonths(-6) + } + + return $null +} + +function Get-CalendarDiagnosticObjectDeduplicationKey { + param( + $CalLog + ) + + $itemId = "" + if ($null -ne $CalLog.ItemId) { + if ($CalLog.ItemId.PSObject.Properties.Name -contains 'ObjectId') { + $itemId = [string]$CalLog.ItemId.ObjectId + } else { + $itemId = [string]$CalLog.ItemId + } + } + + $calendarLogRequestId = if ($null -ne $CalLog.CalendarLogRequestId) { [string]$CalLog.CalendarLogRequestId } else { "" } + $logTimestamp = if ($null -ne $CalLog.LogTimestamp) { [string]$CalLog.LogTimestamp } else { "" } + $originalStartDate = if ($null -ne $CalLog.OriginalStartDate) { [string]$CalLog.OriginalStartDate } else { "" } + $triggerAction = if ($null -ne $CalLog.CalendarLogTriggerAction) { [string]$CalLog.CalendarLogTriggerAction } else { "" } + $itemClass = if ($null -ne $CalLog.ItemClass) { [string]$CalLog.ItemClass } else { "" } + $itemVersion = if ($null -ne $CalLog.ItemVersion) { [string]$CalLog.ItemVersion } else { "" } + $logRowType = if ($null -ne $CalLog.LogRowType) { [string]$CalLog.LogRowType } else { "" } + $responsibleUserName = if ($null -ne $CalLog.ResponsibleUserName) { [string]$CalLog.ResponsibleUserName } else { "" } + $clientInfo = if ($null -ne $CalLog.LogClientInfoString) { [string]$CalLog.LogClientInfoString } else { "" } + + return "$itemId|$calendarLogRequestId|$logTimestamp|$originalStartDate|$triggerAction|$itemClass|$itemVersion|$logRowType|$responsibleUserName|$clientInfo" +} + +function RemoveDuplicateCalendarDiagnosticObjects { + param( + [array]$CalLogs, + [switch]$Quiet + ) + + if ($null -eq $CalLogs -or $CalLogs.Count -le 1) { + return @($CalLogs) + } + + $uniqueLogs = @($CalLogs | Group-Object -Property { Get-CalendarDiagnosticObjectDeduplicationKey -CalLog $_ } | ForEach-Object { + $_.Group | Select-Object -First 1 + } | Sort-Object { ConvertDateTime($_.LogTimestamp.ToString()) }) + + $duplicateCount = $CalLogs.Count - $uniqueLogs.Count + if (($duplicateCount -gt 0) -and (-not $Quiet.IsPresent)) { + Write-Host -ForegroundColor Yellow "Removed [$duplicateCount] duplicate Calendar Log entries before processing." + } + + return $uniqueLogs +} + +function Merge-CalendarDiagnosticObjects { + param( + [array]$BaseLogs, + [array]$AdditionalLogs + ) + + $combinedLogs = @($BaseLogs) + @($AdditionalLogs) + return RemoveDuplicateCalendarDiagnosticObjects -CalLogs $combinedLogs -Quiet +} + +function FilterExceptionLogsByRecency { + param( + [array]$ExceptionLogs + ) + + $cutoffDate = Get-RecentExceptionCutoff + if ($null -eq $cutoffDate) { + return @($ExceptionLogs) + } + + return @($ExceptionLogs | Where-Object { + if ($null -eq $_.OriginalStartDate -or [string]::IsNullOrEmpty([string]$_.OriginalStartDate) -or $_.OriginalStartDate -eq "NotFound") { + return $false + } + + $originalStartDate = ConvertDateTime($_.OriginalStartDate.ToString()) + return ($originalStartDate -is [datetime]) -and $originalStartDate -ne [datetime]::MinValue -and $originalStartDate -ge $cutoffDate + }) +} + +function Get-AppointmentExceptionDatesFromBlob { + param( + $AppointmentRecurrenceBlob + ) + + [byte[]]$bytes = Convert-AppointmentRecurrenceBlobToBytes -AppointmentRecurrenceBlob $AppointmentRecurrenceBlob + $offset = [ref]0 + + $readerVersion = Read-UInt16LE -Bytes $bytes -Offset $offset + $writerVersion = Read-UInt16LE -Bytes $bytes -Offset $offset + $null = Read-UInt16LE -Bytes $bytes -Offset $offset + $patternType = Read-UInt16LE -Bytes $bytes -Offset $offset + $null = Read-UInt16LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + + $patternSpecificSize = Get-RecurrencePatternSpecificSize -PatternType $patternType + if (($offset.Value + $patternSpecificSize) -gt $bytes.Length) { + throw "AppointmentRecurrenceBlob ended before PatternTypeSpecific data was fully read." + } + $offset.Value += $patternSpecificSize + + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + + $deletedInstanceCount = Read-UInt32LE -Bytes $bytes -Offset $offset + $deletedInstanceDates = [System.Collections.Generic.List[datetime]]::new() + for ($index = 0; $index -lt $deletedInstanceCount; $index++) { + $deletedInstanceDates.Add((Convert-RecurMinutesToDateTime -Minutes (Read-UInt32LE -Bytes $bytes -Offset $offset)).Date) + } + + $modifiedInstanceCount = Read-UInt32LE -Bytes $bytes -Offset $offset + $modifiedInstanceDates = [System.Collections.Generic.List[datetime]]::new() + for ($index = 0; $index -lt $modifiedInstanceCount; $index++) { + $modifiedInstanceDates.Add((Convert-RecurMinutesToDateTime -Minutes (Read-UInt32LE -Bytes $bytes -Offset $offset)).Date) + } + + $seriesStartDate = Convert-RecurMinutesToDateTime -Minutes (Read-UInt32LE -Bytes $bytes -Offset $offset) + $seriesEndDate = Convert-RecurMinutesToDateTime -Minutes (Read-UInt32LE -Bytes $bytes -Offset $offset) + $readerVersion2 = Read-UInt32LE -Bytes $bytes -Offset $offset + $writerVersion2 = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $null = Read-UInt32LE -Bytes $bytes -Offset $offset + $exceptionCount = Read-UInt16LE -Bytes $bytes -Offset $offset + + if ($readerVersion -ne 0x3004 -or $writerVersion -ne 0x3004) { + throw "Unexpected recurrence blob header versions [$readerVersion/$writerVersion]." + } + if ($readerVersion2 -lt 0x3006 -or $writerVersion2 -lt 0x3006) { + throw "Unexpected recurrence blob secondary versions [$readerVersion2/$writerVersion2]." + } + if ($modifiedInstanceCount -lt $exceptionCount) { + throw "ModifiedInstanceCount [$modifiedInstanceCount] is less than ExceptionCount [$exceptionCount]." + } + + return [PSCustomObject]@{ + DeletedInstanceCount = $deletedInstanceCount + ModifiedInstanceCount = $modifiedInstanceCount + ExceptionCount = $exceptionCount + DeletedInstanceDates = @($deletedInstanceDates) + ModifiedInstanceDates = @($modifiedInstanceDates) + Dates = @($deletedInstanceDates + $modifiedInstanceDates | Sort-Object -Unique) + SeriesStartDate = $seriesStartDate + SeriesEndDate = $seriesEndDate + PatternType = $patternType + } +} + +function Get-ExceptionDatesFromMostRecentAppointmentBlob { + param( + [array]$CalLogs + ) + + $recurringAppointments = @($CalLogs | Where-Object { + $_.ItemClass -eq 'IPM.Appointment' -and + $_.AppointmentRecurring -and + $null -ne $_.AppointmentRecurrenceBlob -and + -not [string]::IsNullOrWhiteSpace([string]$_.AppointmentRecurrenceBlob) + }) + + if ($recurringAppointments.Count -eq 0) { + throw "No recurring IPM.Appointment with an AppointmentRecurrenceBlob was found in the Calendar Logs." + } + + $blobSource = $recurringAppointments | Where-Object { $_.CalendarItemType.ToString() -eq 'RecurringMaster' } | Sort-Object @{ Expression = { $itemVersion = 0; [void][int]::TryParse([string]$_.ItemVersion, [ref]$itemVersion); $itemVersion } }, @{ Expression = { ConvertDateTime($_.LogTimestamp.ToString()) } } -Descending | Select-Object -First 1 + if ($null -eq $blobSource) { + $blobSource = $recurringAppointments | Sort-Object @{ Expression = { $itemVersion = 0; [void][int]::TryParse([string]$_.ItemVersion, [ref]$itemVersion); $itemVersion } }, @{ Expression = { ConvertDateTime($_.LogTimestamp.ToString()) } } -Descending | Select-Object -First 1 + } + + $parsedBlob = Get-AppointmentExceptionDatesFromBlob -AppointmentRecurrenceBlob $blobSource.AppointmentRecurrenceBlob + $exceptionDates = @($parsedBlob.Dates) + + $cutoffDate = Get-RecentExceptionCutoff + if ($null -ne $cutoffDate) { + $exceptionDates = @($exceptionDates | Where-Object { $_ -ge $cutoffDate }) + } + + return [PSCustomObject]@{ + SourceLog = $blobSource + ParsedBlob = $parsedBlob + ExceptionDates = $exceptionDates + } +} + +function CollectExceptionLogsLegacy { + param( + [string]$Identity, + [string]$MeetingID, + [switch]$UsedAsFallback + ) + + $ExceptionLogs = @() + $LogToExamine = @() + $LogToExamine = $script:GCDO | Where-Object { $_.ItemClass -like 'IPM.Appointment*' } | Sort-Object ItemVersion + + Write-Host -ForegroundColor Cyan "Found $($LogToExamine.count) CalLogs to examine for Exception Logs." + if ($LogToExamine.count -gt 100) { + Write-Host -ForegroundColor Cyan "`t This is a large number of logs to examine, this may take a while." + } + $logLeftCount = $LogToExamine.count + + $ExceptionLogs = $LogToExamine | ForEach-Object { + $logLeftCount -= 1 + Write-Verbose "Getting Exception Logs for [$($_.ItemId.ObjectId)]" + Get-CalendarDiagnosticObjects -Identity $Identity -ItemIds $_.ItemId.ObjectId -ShouldFetchRecurrenceExceptions $true -CustomPropertyNames $CustomPropertyNameList -ShouldBindToItem $true 3>$null + if (($logLeftCount % 10 -eq 0) -and ($logLeftCount -gt 0)) { + Write-Host -ForegroundColor Cyan "`t [$($logLeftCount)] logs left to examine..." + } + } + + $ExceptionLogs = $ExceptionLogs | Where-Object { $_.ItemClass -notlike "IPM.Appointment*" } + $cutoffDate = Get-RecentExceptionCutoff + if ($null -ne $cutoffDate) { + Write-Host -ForegroundColor Yellow "Filtering legacy Exception logs to only keep items with OriginalStartDate in the last 6 months." + $ExceptionLogs = FilterExceptionLogsByRecency -ExceptionLogs $ExceptionLogs + } + + Write-Host -ForegroundColor Cyan "Found $($ExceptionLogs.count) Exception Logs, adding them into the CalLogs." + $script:GCDO = Merge-CalendarDiagnosticObjects -BaseLogs $script:GCDO -AdditionalLogs $ExceptionLogs + if ($UsedAsFallback.IsPresent) { + $script:ExceptionCollectionStatus = "CollectedLegacyFallback" + } else { + $script:ExceptionCollectionStatus = "Collected" + } +} + +function CollectExceptionLogsFast { + param( + [string]$Identity, + [string]$MeetingID + ) + + $blobResult = Get-ExceptionDatesFromMostRecentAppointmentBlob -CalLogs $script:GCDO + $exceptionDates = @($blobResult.ExceptionDates | Sort-Object -Unique) + + Write-Host -ForegroundColor Cyan "Fast exception collection selected blob from ItemVersion [$($blobResult.SourceLog.ItemVersion)] with [$($blobResult.ParsedBlob.ExceptionCount)] exception entries." + Write-Host -ForegroundColor Cyan "Found [$($blobResult.ParsedBlob.DeletedInstanceCount)] deleted exception dates and [$($blobResult.ParsedBlob.ModifiedInstanceCount)] modified exception dates in the recurrence blob." + + $cutoffDate = Get-RecentExceptionCutoff + if ($null -ne $cutoffDate) { + Write-Host -ForegroundColor Cyan "Keeping only Exception dates from the last 6 months: [$($cutoffDate.ToString('yyyy-MM-dd'))] and newer." + Write-Host -ForegroundColor Cyan "[$($exceptionDates.Count)] Exception date(s) match the 6 month time frame." + } + + if ($exceptionDates.Count -eq 0) { + $script:ExceptionCollectionStatus = "NoExceptionDates" + Write-Host -ForegroundColor Cyan "No matching Exception dates were found in the AppointmentRecurrenceBlob." + return + } + + $collectedExceptionLogs = @( + foreach ($exceptionOriginalStartDate in $exceptionDates) { + GetCalendarDiagnosticObjects -Identity $Identity -MeetingID $MeetingID -ExceptionDateOverride $exceptionOriginalStartDate + } + ) + + $collectedExceptionLogs = @($collectedExceptionLogs | Where-Object { + $_.ItemClass -notlike "IPM.Appointment*" -or + ($null -ne $_.OriginalStartDate -and $_.OriginalStartDate -ne "NotFound" -and -not [string]::IsNullOrEmpty([string]$_.OriginalStartDate)) + }) + + Write-Host -ForegroundColor Cyan "Collected $($collectedExceptionLogs.count) Exception-related logs from [$($exceptionDates.count)] ExceptionDate queries." + $script:GCDO = Merge-CalendarDiagnosticObjects -BaseLogs $script:GCDO -AdditionalLogs $collectedExceptionLogs + $script:ExceptionCollectionStatus = "CollectedFast" +} + +function CollectExceptionLogs { + param( + [string]$Identity, + [string]$MeetingID + ) + + Write-Verbose "Looking for Exception Logs..." + $IsRecurring = SetIsRecurring -CalLogs $script:GCDO + Write-Verbose "Meeting IsRecurring: $IsRecurring" + + if ($IsRecurring) { + if ($FastExceptions.IsPresent -and [string]::IsNullOrEmpty($ExceptionDate)) { + try { + CollectExceptionLogsFast -Identity $Identity -MeetingID $MeetingID + } catch { + $script:ExceptionCollectionStatus = "CollectedLegacyFallback" + Write-DashLineBoxColor "FAST EXCEPTION COLLECTION FAILED", + "Error: $($_.Exception.Message)", + "Falling back to the legacy per-appointment Exception collector." -Color Red -DashChar "=" + CollectExceptionLogsLegacy -Identity $Identity -MeetingID $MeetingID -UsedAsFallback + } + } else { + CollectExceptionLogsLegacy -Identity $Identity -MeetingID $MeetingID + } + } else { + $script:ExceptionCollectionStatus = "NotRecurring" + Write-Host -ForegroundColor Cyan "No Recurring Meetings found, no Exception Logs to collect." + } +} diff --git a/Calendar/CalLogHelpers/ExportToExcelFunctions.ps1 b/Calendar/CalLogHelpers/ExportToExcelFunctions.ps1 index 6d51880f69..58ad2fe783 100644 --- a/Calendar/CalLogHelpers/ExportToExcelFunctions.ps1 +++ b/Calendar/CalLogHelpers/ExportToExcelFunctions.ps1 @@ -15,13 +15,12 @@ function Export-CalLogExcel { $savedErrorActionPreference = $ErrorActionPreference try { $ErrorActionPreference = 'SilentlyContinue' - $excel = $GCDOResults | Export-Excel @ExcelParamsArray -PassThru 3>$null + $excel = $script:EnhancedCalLogs | Export-Excel @ExcelParamsArray -PassThru 3>$null } finally { $ErrorActionPreference = $savedErrorActionPreference } FormatHeader ($excel) - SortByLogTimestamp ($excel) CheckRows ($excel) # Set tab color to match the role (Organizer=Orange, Room=Green, Attendee=Blue) @@ -32,11 +31,11 @@ function Export-CalLogExcel { # Log script info (will be positioned in the middle) LogScriptInfo - # Export Raw Logs for Developer Analysis + # Export Raw Logs for Developer Analysis in collected order; do not apply the Enhanced tab sort here. Write-Host -ForegroundColor Cyan "Exporting Raw CalLogs to Excel Tab [$($ShortId + "_Raw")]..." try { $ErrorActionPreference = 'SilentlyContinue' - $rawExcel = $script:GCDO | Export-Excel -Path $FileName -WorksheetName $($ShortId + "_Raw") -AutoFilter -FreezeTopRow -BoldTopRow -MoveToEnd -PassThru 3>$null + $rawExcel = $script:GCDO | Export-Excel -Path $FileName -WorksheetName $($ShortId + "_Raw") -AutoFilter -FreezeTopRow -BoldTopRow -MoveToEnd -ClearSheet -PassThru 3>$null } finally { $ErrorActionPreference = $savedErrorActionPreference } @@ -44,6 +43,139 @@ function Export-CalLogExcel { Export-Excel -ExcelPackage $rawExcel -WorksheetName $($ShortId + "_Raw") } +function Format-ElapsedTime { + param( + [TimeSpan]$Elapsed + ) + + if ($Elapsed.TotalHours -ge 1) { + return ('{0:00}:{1:00}:{2:00}' -f [int]$Elapsed.TotalHours, $Elapsed.Minutes, $Elapsed.Seconds) + } + + return ('{0:00}:{1:00}' -f [int]$Elapsed.TotalMinutes, $Elapsed.Seconds) +} + +function Get-ExceptionSummaryText { + $exceptionLogs = @($script:GCDO | Where-Object { + ($null -ne $_.OriginalStartDate -and $_.OriginalStartDate -ne 'NotFound' -and -not [string]::IsNullOrEmpty([string]$_.OriginalStartDate)) -or + (IsExceptionItemType (GetItemType $_.ItemClass)) + }) + $exceptionCount = $exceptionLogs.Count + + switch ($script:ExceptionCollectionStatus) { + 'CollectedFast' { + if ($AllExceptions.IsPresent) { + return "Yes - $exceptionCount exception log(s) collected with FastExceptions for all Exception dates." + } + return "Yes - $exceptionCount exception log(s) collected with FastExceptions for the last 6 months by default." + } + 'CollectedLegacyFallback' { + if ($AllExceptions.IsPresent) { + return "Yes - $exceptionCount exception log(s) collected after FastExceptions fell back to the legacy collector for all Exception dates." + } + return "Yes - $exceptionCount exception log(s) collected after FastExceptions fell back to the legacy collector for the last 6 months by default." + } + 'Collected' { + return "Yes - $exceptionCount exception log(s) collected." + } + 'NoExceptionDates' { + return "No - the AppointmentRecurrenceBlob did not expose matching Exception dates." + } + 'NotRecurring' { + return "No - the meeting is not recurring." + } + 'SkippedBySwitch' { + return "No - skipped because -NoExceptions was used." + } + 'SkippedUntilMeetingIdChosen' { + return "No - skipped because -ExceptionDate requires rerunning with -MeetingID." + } + 'SkippedMultipleMeetingIds' { + return "No - skipped because Subject search resolved to multiple MeetingIDs." + } + 'NoMeetingId' { + return "No - no MeetingID was resolved from the Subject search." + } + default { + if (-not $Exceptions.IsPresent) { + return "No - Exception collection was not requested." + } + if ($exceptionCount -gt 0) { + return "Yes - $exceptionCount exception log(s) present in the collected output." + } + return "No - no exception logs were found in the collected output." + } + } +} + +function Get-CollectionMethodDescription { + if ($script:SubjectSearch) { + if ($script:SubjectMeetingIdCount -eq 1) { + if ($script:ExceptionCollectionStatus -eq 'CollectedFast') { + if ($AllExceptions.IsPresent) { + return 'Subject search resolved to one MeetingID; Exception data collected with FastExceptions for all Exception dates. Tracking Logs still require -MeetingID.' + } + return 'Subject search resolved to one MeetingID; Exception data collected with FastExceptions for the last 6 months by default. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'CollectedLegacyFallback') { + if ($AllExceptions.IsPresent) { + return 'Subject search resolved to one MeetingID; FastExceptions failed and the script fell back to the legacy Exception collector for all Exception dates. Tracking Logs still require -MeetingID.' + } + return 'Subject search resolved to one MeetingID; FastExceptions failed and the script fell back to the legacy Exception collector for the last 6 months by default. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'Collected') { + return 'Subject search resolved to one MeetingID; Exception data collected by default. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'SkippedBySwitch') { + return 'Subject search resolved to one MeetingID; Exception collection was skipped because -NoExceptions was used. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'SkippedUntilMeetingIdChosen') { + return 'Subject search resolved to one MeetingID; targeted Exception collection was skipped because -ExceptionDate requires rerunning with -MeetingID. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'NoExceptionDates') { + return 'Subject search resolved to one MeetingID; the AppointmentRecurrenceBlob did not expose any matching Exception dates to collect. Tracking Logs still require -MeetingID.' + } + if ($script:ExceptionCollectionStatus -eq 'NotRecurring') { + return 'Subject search resolved to one MeetingID; Exception lookup ran, but the meeting is not recurring. Tracking Logs still require -MeetingID.' + } + return 'Subject search resolved to one MeetingID. Tracking Logs still require -MeetingID for full collection.' + } + if ($script:SubjectMeetingIdCount -gt 1) { + return 'Subject search resolved to multiple MeetingIDs; Exception collection was skipped. Re-run with a specific -MeetingID to collect meeting exceptions.' + } + return 'Subject search did not resolve to a MeetingID.' + } + + if (-not [string]::IsNullOrEmpty($ExceptionDate)) { + return 'MeetingID search with targeted -ExceptionDate collection.' + } + if ($NoExceptions.IsPresent) { + return 'MeetingID search with Exception collection disabled by -NoExceptions.' + } + if ($script:ExceptionCollectionStatus -eq 'CollectedFast') { + if ($AllExceptions.IsPresent) { + return 'MeetingID search with FastExceptions collection for all Exception dates.' + } + return 'MeetingID search with FastExceptions collection for the last 6 months by default.' + } + if ($script:ExceptionCollectionStatus -eq 'CollectedLegacyFallback') { + if ($AllExceptions.IsPresent) { + return 'MeetingID search where FastExceptions failed and the script fell back to the legacy Exception collector for all Exception dates.' + } + return 'MeetingID search where FastExceptions failed and the script fell back to the legacy Exception collector for the last 6 months by default.' + } + if ($script:ExceptionCollectionStatus -eq 'Collected') { + return 'MeetingID search with legacy Exception collection.' + } + if ($script:ExceptionCollectionStatus -eq 'NoExceptionDates') { + return 'MeetingID search where the AppointmentRecurrenceBlob did not expose any matching Exception dates to collect.' + } + if ($script:ExceptionCollectionStatus -eq 'NotRecurring') { + return 'MeetingID search where the meeting is not recurring, so no Exception logs were collected.' + } + return 'MeetingID' +} + function LogScriptInfo { # Increment run number only once per script invocation (not per identity) if (-not $script:RunNumberSet) { @@ -119,17 +251,10 @@ function LogScriptInfo { Value = ($ValidatedIdentities -join '; ') }) - if ($script:SubjectSearch) { - $RunInfo.Add([PSCustomObject]@{ - Key = "Collection Method" - Value = "Subject search (no Exception or Tracking Log data). Use -MeetingID for full collection." - }) - } else { - $RunInfo.Add([PSCustomObject]@{ - Key = "Collection Method" - Value = "MeetingID" - }) - } + $RunInfo.Add([PSCustomObject]@{ + Key = "Collection Method" + Value = (Get-CollectionMethodDescription) + }) # Only log environment info on the first run if ($script:RunNumber -eq 1) { @@ -156,9 +281,21 @@ function LogScriptInfo { # Per-identity line: show which identity was collected and log count $logCount = if ($null -ne $script:GCDO) { $script:GCDO.Count } else { 0 } + $elapsedText = 'Unknown' + if ($null -ne $script:CurrentIdentityRunStartTime) { + $elapsedText = Format-ElapsedTime -Elapsed ((Get-Date) - $script:CurrentIdentityRunStartTime) + } $RunInfo.Add([PSCustomObject]@{ Key = " $($script:Identity)" - Value = "$logCount logs collected at $(Get-Date -Format 'HH:mm:ss')" + Value = "$logCount logs collected in $elapsedText at $(Get-Date -Format 'HH:mm:ss')" + }) + $RunInfo.Add([PSCustomObject]@{ + Key = " Duration" + Value = $elapsedText + }) + $RunInfo.Add([PSCustomObject]@{ + Key = " Exceptions" + Value = (Get-ExceptionSummaryText) }) # Capture errors that occurred during this identity (new errors since last snapshot) @@ -237,7 +374,7 @@ function Export-TimelineExcel { $savedEAP = $ErrorActionPreference try { $ErrorActionPreference = 'SilentlyContinue' - $tlExcel = $script:TimeLineOutput | Export-Excel -Path $FileName -WorksheetName $($ShortId + "_TimeLine") -Title "Timeline for $Identity" -AutoSize -FreezeTopRow -BoldTopRow -PassThru 3>$null + $tlExcel = $script:TimeLineOutput | Export-Excel -Path $FileName -WorksheetName $($ShortId + "_TimeLine") -Title "Timeline for $Identity" -AutoSize -FreezeTopRow -BoldTopRow -ClearSheet -PassThru 3>$null } finally { $ErrorActionPreference = $savedEAP } @@ -267,7 +404,29 @@ function GetExcelParams($path, $tabName) { } if ($script:SubjectSearch) { - $TitleExtra += ", Collected with Subject search (no Exception info)" + if ($script:SubjectMeetingIdCount -eq 1) { + if ($script:ExceptionCollectionStatus -eq "CollectedFast") { + $TitleExtra += ", Collected with Subject search and FastExceptions" + } elseif ($script:ExceptionCollectionStatus -eq "CollectedLegacyFallback") { + $TitleExtra += ", Collected with Subject search (FastExceptions fell back to legacy)" + } elseif ($script:ExceptionCollectionStatus -eq "Collected") { + $TitleExtra += ", Collected with Subject search and Exception data" + } elseif ($script:ExceptionCollectionStatus -eq "SkippedBySwitch") { + $TitleExtra += ", Collected with Subject search (-NoExceptions used)" + } elseif ($script:ExceptionCollectionStatus -eq "SkippedUntilMeetingIdChosen") { + $TitleExtra += ", Collected with Subject search (-ExceptionDate requires rerun with -MeetingID)" + } elseif ($script:ExceptionCollectionStatus -eq "NoExceptionDates") { + $TitleExtra += ", Collected with Subject search (no matching Exception dates in AppointmentRecurrenceBlob)" + } elseif ($script:ExceptionCollectionStatus -eq "NotRecurring") { + $TitleExtra += ", Collected with Subject search (no recurring Exception data)" + } else { + $TitleExtra += ", Collected with Subject search" + } + } elseif ($script:SubjectMeetingIdCount -gt 1) { + $TitleExtra += ", Collected with Subject search (multiple MeetingIDs; rerun with -MeetingID for Exceptions)" + } else { + $TitleExtra += ", Collected with Subject search" + } } $script:lastRow = $script:GCDO.Count + $firstRow - 1 # Last row is the number of items in the GCDO array + 2 for the header and title rows. @@ -283,7 +442,7 @@ function GetExcelParams($path, $tabName) { TableName = $tabName FreezeTopRowFirstColumn = $true AutoFilter = $true - Append = $true + ClearSheet = $true Title = "Enhanced Calendar Logs for $Identity" + $TitleExtra + " for MeetingID [$($script:GCDO[0].CleanGlobalObjectId)]." TitleSize = 14 ConditionalText = $ConditionalFormatting @@ -551,7 +710,7 @@ function CheckOrganizerErrors { $sharedFolder = $sheet.Cells[$row, $sharedFolderCol].Text # Only check IPM.Appointment and Exception rows that are not shared - if (($itemClass -ne "Ipm.Appointment" -and $itemClass -ne "Exception") -or + if (($itemClass -ne "Ipm.Appointment" -and $itemClass -notlike "Exception*") -or $sharedFolder -ne "Not Shared") { continue } @@ -577,7 +736,7 @@ function CheckOrganizerErrors { if ($null -eq $firstOrganizer) { $firstOrganizer = $fromValue - } elseif ($fromValue -ne $firstOrganizer) { + } elseif (-not (IsSameOrganizerIdentity $fromValue $firstOrganizer)) { # Red text for the whole row $rowRange = $sheet.Cells[$row, 1, $row, $lastDataCol] $rowRange.Style.Font.Color.SetColor([System.Drawing.Color]::Red) diff --git a/Calendar/CalLogHelpers/FindChangedPropFunctions.ps1 b/Calendar/CalLogHelpers/FindChangedPropFunctions.ps1 index 8dac29da67..1d7da89aa0 100644 --- a/Calendar/CalLogHelpers/FindChangedPropFunctions.ps1 +++ b/Calendar/CalLogHelpers/FindChangedPropFunctions.ps1 @@ -20,7 +20,7 @@ function HasMeaningfulChange { $OldValue, $NewValue ) - if ($OldValue -eq $NewValue) { return $false } + if ([string]::Equals([string]$OldValue, [string]$NewValue, [System.StringComparison]::OrdinalIgnoreCase)) { return $false } if ((IsNotFound $OldValue) -or (IsNotFound $NewValue)) { return $false } return $true } @@ -133,13 +133,17 @@ function FindChangedProperties { } if (HasMeaningfulChange $script:PreviousCalLog.Sender $CalLog.Sender) { - [Array]$TimeLineText = "The Sender Email Address changed from [$($script:PreviousCalLog.Sender)] to: [$($CalLog.Sender)]" - CreateMeetingSummary -Time " " -MeetingChanges $TimeLineText + if (-not (IsSameOrganizerIdentity $script:PreviousCalLog.Sender $CalLog.Sender)) { + [Array]$TimeLineText = "The Sender Email Address changed from [$($script:PreviousCalLog.Sender)] to: [$($CalLog.Sender)]" + CreateMeetingSummary -Time " " -MeetingChanges $TimeLineText + } } if (HasMeaningfulChange $script:PreviousCalLog.From $CalLog.From) { - [Array]$TimeLineText = "The From changed from [$($script:PreviousCalLog.From)] to: [$($CalLog.From)]" - CreateMeetingSummary -Time " " -MeetingChanges $TimeLineText + if (-not (IsSameOrganizerIdentity $script:PreviousCalLog.From $CalLog.From)) { + [Array]$TimeLineText = "The From changed from [$($script:PreviousCalLog.From)] to: [$($CalLog.From)]" + CreateMeetingSummary -Time " " -MeetingChanges $TimeLineText + } } if (HasMeaningfulChange $script:PreviousCalLog.ReceivedRepresenting $CalLog.ReceivedRepresenting) { diff --git a/Calendar/CalLogHelpers/Invoke-GetCalLogs.ps1 b/Calendar/CalLogHelpers/Invoke-GetCalLogs.ps1 index 4ba18ca2d4..2766f64a90 100644 --- a/Calendar/CalLogHelpers/Invoke-GetCalLogs.ps1 +++ b/Calendar/CalLogHelpers/Invoke-GetCalLogs.ps1 @@ -72,7 +72,8 @@ function GetCalendarDiagnosticObjects { param( [string]$Identity, [string]$Subject, - [string]$MeetingID + [string]$MeetingID, + [datetime]$ExceptionDateOverride ) $params = @{ @@ -85,19 +86,26 @@ function GetCalendarDiagnosticObjects { ShouldDecodeEnums = $true } + $isExceptionOverrideCall = $PSBoundParameters.ContainsKey('ExceptionDateOverride') -and $null -ne $ExceptionDateOverride + if ($TrackingLogs -eq $true) { - Write-Host -ForegroundColor Yellow "Including Tracking Logs in the output." + if (-not $isExceptionOverrideCall) { + Write-Host -ForegroundColor Yellow "Including Tracking Logs in the output." + } $script:CustomPropertyNameList += "AttendeeListDetails", "AttendeeCollection" $params.Add("ShouldFetchAttendeeCollection", $true) $params.Remove("CustomPropertyName") $params.Add("CustomPropertyName", $script:CustomPropertyNameList) } - if (-not [string]::IsNullOrEmpty($ExceptionDate)) { + $effectiveExceptionDate = if ($PSBoundParameters.ContainsKey('ExceptionDateOverride') -and $null -ne $ExceptionDateOverride) { $ExceptionDateOverride } else { $ExceptionDate } + + if (-not [string]::IsNullOrEmpty($effectiveExceptionDate)) { Write-Host -ForegroundColor Green "---------------------------------------" - Write-Host -ForegroundColor Green "Pulling all the Exceptions for [$ExceptionDate] and adding them to the output." + $exceptionDateLabel = if ($effectiveExceptionDate -is [datetime]) { $effectiveExceptionDate.ToString('MM/dd/yyyy') } else { [string]$effectiveExceptionDate } + Write-Host -ForegroundColor Green "Pulling all the Exceptions for [$exceptionDateLabel] and adding them to the output." Write-Host -ForegroundColor Green "---------------------------------------" - $params.Add("AnalyzeExceptionWithOriginalStartDate", $ExceptionDate) + $params.Add("AnalyzeExceptionWithOriginalStartDate", $effectiveExceptionDate) } if ($MaxLogs.IsPresent) { @@ -131,7 +139,9 @@ function GetCalendarDiagnosticObjects { } } - Write-Host "Found $($CalLogs.count) Calendar Logs for [$Identity]" + if (-not $isExceptionOverrideCall) { + Write-Host "Found $($CalLogs.count) Calendar Logs for [$Identity]" + } return $CalLogs } @@ -149,6 +159,7 @@ function GetCalLogsWithSubject { [string] $Subject ) Write-Host "Getting CalLogs from [$Identity] with subject [$Subject]" + $script:CurrentIdentityRunStartTime = Get-Date $InitialCDOs = GetCalendarDiagnosticObjects -Identity $Identity -Subject $Subject @@ -159,16 +170,40 @@ function GetCalLogsWithSubject { $_ -ne "InvalidSchemaPropertyName" -and $_.Length -ge 90 } | Select-Object -Unique) + $script:SubjectMeetingIdCount = $GlobalObjectIds.Count + $script:SubjectResolvedMeetingId = $null + $script:SubjectCanCollectExceptions = $false + $script:SubjectSkippedExceptionCollection = $false Write-Host "Found $($GlobalObjectIds.count) unique GlobalObjectIds." Write-Host "Getting the set of CalLogs for each GlobalObjectID." if ($GlobalObjectIds.count -eq 1) { + $script:SubjectResolvedMeetingId = $GlobalObjectIds[0] + $script:SubjectCanCollectExceptions = $Exceptions.IsPresent -and [string]::IsNullOrEmpty($ExceptionDate) $script:GCDO = $InitialCDOs; # use the CalLogs that we already have, since there is only one. + if ($Exceptions.IsPresent -and [string]::IsNullOrEmpty($ExceptionDate)) { + Write-Host -ForegroundColor Yellow "Subject search resolved to one MeetingID [$($script:SubjectResolvedMeetingId)]. Collecting Exceptions by default." + CollectExceptionLogs -Identity $Identity -MeetingID $script:SubjectResolvedMeetingId + } elseif (-not [string]::IsNullOrEmpty($ExceptionDate)) { + $script:ExceptionCollectionStatus = "SkippedUntilMeetingIdChosen" + Write-Host -ForegroundColor Yellow "Subject search resolved to one MeetingID [$($script:SubjectResolvedMeetingId)], but -ExceptionDate requires rerunning with -MeetingID for targeted Exception collection." + } else { + $script:ExceptionCollectionStatus = "SkippedBySwitch" + Write-Host -ForegroundColor Green "Subject search resolved to one MeetingID [$($script:SubjectResolvedMeetingId)], but Exception collection was skipped." + } BuildCSV BuildTimeline } elseif ($GlobalObjectIds.count -gt 1) { + $script:SubjectSkippedExceptionCollection = $true + $script:ExceptionCollectionStatus = "SkippedMultipleMeetingIds" Write-Host "Found multiple GlobalObjectIds: $($GlobalObjectIds.Count)." + Write-Host -ForegroundColor Yellow "Exception collection is skipped when Subject search resolves to more than one MeetingID." + Write-Host -ForegroundColor Yellow "Re-run with one of these MeetingIDs to collect meeting exceptions:" + foreach ($MID in $GlobalObjectIds) { + Write-Host -ForegroundColor Yellow " -MeetingID $MID" + } foreach ($MID in $GlobalObjectIds) { + $script:CurrentIdentityRunStartTime = Get-Date Write-DashLineBoxColor "Processing MeetingID: [$MID]" $script:GCDO = GetCalendarDiagnosticObjects -Identity $Identity -MeetingID $MID Write-Verbose "Found $($GCDO.count) CalLogs with MeetingID[$MID] ." @@ -176,6 +211,7 @@ function GetCalLogsWithSubject { BuildTimeline } } else { + $script:ExceptionCollectionStatus = "NoMeetingId" Write-Warning "No CalLogs were found." } } diff --git a/Calendar/CalLogHelpers/TimelineFunctions.ps1 b/Calendar/CalLogHelpers/TimelineFunctions.ps1 index ec957c356d..8e9d53fef9 100644 --- a/Calendar/CalLogHelpers/TimelineFunctions.ps1 +++ b/Calendar/CalLogHelpers/TimelineFunctions.ps1 @@ -10,7 +10,7 @@ Tries to builds a timeline of the history of the meeting based on the diagnostic objects. .DESCRIPTION - By using the time sorted diagnostic objects for one user on one meeting, we try to give a high level + By using the Enhanced diagnostic objects, which are sorted before the Timeline is built, we try to give a high level overview of what happened to the meeting. This can be use to get a quick overview of the meeting and then you can look into the CalLog in Excel to get more details. @@ -73,7 +73,8 @@ function BuildTimeline { FindOrganizer($script:FirstLog) # Ignorable and items from Shared Calendars are not included in the TimeLine. - [array]$InterestingCalLogs = $script:EnhancedCalLogs | Where-Object { $_.LogRowType -eq "Interesting" -and $_.SharedFolderName -eq "Not Shared" } + [array]$InterestingCalLogs = $script:EnhancedCalLogs | + Where-Object { $_.LogRowType -eq "Interesting" -and $_.SharedFolderName -eq "Not Shared" } if ($InterestingCalLogs.count -eq 0) { Write-Host "All CalLogs are Ignorable, nothing to create a timeline with, displaying initial values." @@ -122,10 +123,12 @@ function BuildTimeline { # Setup Previous log (if current logs is an IPM.Appointment) if ($null -ne $CalLog.ItemClass -and - ((GetItemType $CalLog.ItemClass) -eq "Ipm.Appointment" -or (GetItemType $CalLog.ItemClass) -eq "Exception")) { + ((GetItemType $CalLog.ItemClass) -eq "Ipm.Appointment" -or (IsExceptionItemType (GetItemType $CalLog.ItemClass)))) { $script:PreviousCalLog = $CalLog } } + [void](Test-LogTimestampOrder -Entries $script:TimeLineOutput -Name 'Timeline' -ValuePropertyName 'Time') + Export-Timeline } diff --git a/Calendar/Get-CalendarDiagnosticObjectsSummary.ps1 b/Calendar/Get-CalendarDiagnosticObjectsSummary.ps1 index 3cfbd31215..566efa6223 100644 --- a/Calendar/Get-CalendarDiagnosticObjectsSummary.ps1 +++ b/Calendar/Get-CalendarDiagnosticObjectsSummary.ps1 @@ -22,7 +22,7 @@ Include specific tracking logs in the output. Only usable with the MeetingID par Do not collect Tracking Logs. .PARAMETER Exceptions -Include Exception objects in the output. Only usable with the MeetingID parameter. Collected by default; use -NoExceptions to skip. +Include Exception objects in the output. Collected by default for direct MeetingID searches and for Subject searches that resolve to exactly one MeetingID; use -NoExceptions to skip. .PARAMETER ExportToExcel Export the output to an Excel file with formatting. Running the script for multiple users will create multiple tabs in the Excel file. (Default) @@ -43,16 +43,24 @@ Increase log limit to 12,000 in case the default 2000 does not contain the neede Advanced users can add custom properties to the output in the RAW output. This is not recommended unless you know what you are doing. The properties must be in the format of "PropertyName1, PropertyName2, PropertyName3". The properties will only be added to the RAW output. .PARAMETER ExceptionDate -Date of the Exception Meeting to collect logs for. Fastest way to get Exceptions for a meeting. +Date of the Exception Meeting to collect logs for. Fastest way to get Exceptions for a meeting after you have identified the MeetingID. .PARAMETER NoExceptions Do not collect Exception Meetings. This was the default behavior of the script, now exceptions are collected by default. +.PARAMETER FastExceptions +Use the AppointmentRecurrenceBlob to find Exception dates, then collect them with -ExceptionDate. Fast exception collection uses the last 6 months by default. If parsing fails, the script falls back to the legacy per-appointment collector. + +.PARAMETER AllExceptions +When using -FastExceptions, collect all Exception dates instead of the default last 6 months. + .EXAMPLE Get-CalendarDiagnosticObjectsSummary.ps1 -Identity someuser@microsoft.com -MeetingID 040000008200E00074C5B7101A82E008000000008063B5677577D9010000000000000000100000002FCDF04279AF6940A5BFB94F9B9F73CD .EXAMPLE Get-CalendarDiagnosticObjectsSummary.ps1 -Identity someuser@microsoft.com -Subject "Test One Meeting Subject" .EXAMPLE +Get-CalendarDiagnosticObjectsSummary.ps1 -Identity someuser@microsoft.com -Subject "Test One Meeting Subject" -NoExceptions +.EXAMPLE Get-CalendarDiagnosticObjectsSummary.ps1 -Identity User1, User2, Delegate -MeetingID $MeetingID .EXAMPLE Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -NoExceptions @@ -62,6 +70,10 @@ Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -ExportToExcel -CaseNumber 123456 .EXAMPLE Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -ExceptionDate "01/28/2024" -CaseNumber 123456 +.EXAMPLE +Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -FastExceptions +.EXAMPLE +Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -FastExceptions -AllExceptions .SYNOPSIS Used to collect easy to read Calendar Logs. @@ -94,12 +106,16 @@ param ( [switch]$TrackingLogs, [Parameter(HelpMessage = "Do Not collect Tracking Logs.")] [switch]$NoTrackingLogs, - [Parameter(HelpMessage = "Include Exception objects in the output. Only usable with the MeetingID parameter.")] + [Parameter(HelpMessage = "Include Exception objects in the output. Subject searches also collect them when exactly one MeetingID is found.")] [switch]$Exceptions, - [Parameter(HelpMessage = "Date of the Exception to collect the logs for.")] + [Parameter(HelpMessage = "Date of the Exception to collect the logs for after identifying the MeetingID.")] [DateTime]$ExceptionDate, [Parameter(HelpMessage = "Do Not collect Exception Meetings.")] [switch]$NoExceptions, + [Parameter(HelpMessage = "Use AppointmentRecurrenceBlob to collect exceptions by ExceptionDate first, then fall back to the legacy path if parsing fails.")] + [switch]$FastExceptions, + [Parameter(HelpMessage = "When using FastExceptions, collect all Exception dates instead of the default last 6 months.")] + [switch]$AllExceptions, [Parameter(Mandatory, ParameterSetName = 'Subject', Position = 1, HelpMessage = "Enter the Subject of the meeting. Do not include the RE:, FW:, etc., No wild cards (* or ?)")] [string]$Subject @@ -124,6 +140,12 @@ Write-Verbose "Name: $($script:command.MyCommand.name)" Write-Verbose "Command Line: $($script:command.line)" Write-Verbose "Script Version: $BuildVersion" $script:BuildVersion = $BuildVersion +$script:SubjectSearch = $false +$script:SubjectMeetingIdCount = 0 +$script:SubjectResolvedMeetingId = $null +$script:SubjectCanCollectExceptions = $false +$script:SubjectSkippedExceptionCollection = $false +$script:ExceptionCollectionStatus = $null # =================================================================================================== # Support scripts @@ -134,6 +156,7 @@ $script:BuildVersion = $BuildVersion . $PSScriptRoot\CalLogHelpers\Invoke-GetMailbox.ps1 . $PSScriptRoot\CalLogHelpers\Invoke-GetCalLogs.ps1 . $PSScriptRoot\CalLogHelpers\CalLogInfoFunctions.ps1 +. $PSScriptRoot\CalLogHelpers\ExceptionCollectionFunctions.ps1 . $PSScriptRoot\CalLogHelpers\CalLogExportFunctions.ps1 . $PSScriptRoot\CalLogHelpers\CreateTimelineRow.ps1 . $PSScriptRoot\CalLogHelpers\FindChangedPropFunctions.ps1 @@ -149,6 +172,10 @@ if (!$ExportToCSV.IsPresent) { . $PSScriptRoot\CalLogHelpers\ExportToExcelFunctions.ps1 } +if ($AllExceptions.IsPresent -and -not $FastExceptions.IsPresent) { + Write-Warning "-AllExceptions only applies when -FastExceptions is used. The switch will be ignored." +} + # Default to Collecting Tracking Logs (MeetingID only) if (-not ([string]::IsNullOrEmpty($MeetingID))) { if (!$NoTrackingLogs.IsPresent) { @@ -159,11 +186,19 @@ if (-not ([string]::IsNullOrEmpty($MeetingID))) { Write-Host -ForegroundColor Green "Not Collecting Tracking Logs." } - # Default to Collecting Exceptions (MeetingID only) + # Default to Collecting Exceptions if ((!$NoExceptions.IsPresent) -and ([string]::IsNullOrEmpty($ExceptionDate))) { - $Exceptions=$true + $Exceptions = $true Write-Host -ForegroundColor Yellow "Collecting Exceptions." Write-Host -ForegroundColor Yellow "`tTo skip collecting Exceptions, use the -NoExceptions switch." + if ($FastExceptions.IsPresent) { + Write-Host -ForegroundColor Yellow "`tFast exception collection is enabled and will parse AppointmentRecurrenceBlob first." + if ($AllExceptions.IsPresent) { + Write-Host -ForegroundColor Yellow "`tAll Exception dates will be collected." + } else { + Write-Host -ForegroundColor Yellow "`tOnly Exception dates from the last 6 months will be collected by default." + } + } } else { Write-Host -ForegroundColor Green "---------------------------------------" if ($NoExceptions.IsPresent) { @@ -176,8 +211,27 @@ if (-not ([string]::IsNullOrEmpty($MeetingID))) { } else { # Subject-based search $script:SubjectSearch = $true - Write-Host -ForegroundColor Yellow "Using Subject search. Tracking Logs and Exception collection are only available with -MeetingID." - Write-Host -ForegroundColor Yellow "`tTip: Use the MeetingID from this output to recollect with full details." + if ((!$NoExceptions.IsPresent) -and ([string]::IsNullOrEmpty($ExceptionDate))) { + $Exceptions = $true + Write-Host -ForegroundColor Yellow "Using Subject search. Exception collection will run if the search resolves to exactly one MeetingID." + Write-Host -ForegroundColor Yellow "`tTo skip collecting Exceptions for a single resolved MeetingID, use the -NoExceptions switch." + if ($FastExceptions.IsPresent) { + Write-Host -ForegroundColor Yellow "`tFast exception collection is enabled for single-MeetingID Subject results." + if ($AllExceptions.IsPresent) { + Write-Host -ForegroundColor Yellow "`tAll Exception dates will be collected." + } else { + Write-Host -ForegroundColor Yellow "`tOnly Exception dates from the last 6 months will be collected by default." + } + } + } else { + Write-Host -ForegroundColor Green "Using Subject search without automatic Exception collection." + if ($NoExceptions.IsPresent) { + Write-Host -ForegroundColor Green "`t-NoExceptions was specified, so Exception collection will be skipped." + } elseif (-not ([string]::IsNullOrEmpty($ExceptionDate))) { + Write-Host -ForegroundColor Green "`t-ExceptionDate applies only after a specific MeetingID is selected." + } + } + Write-Host -ForegroundColor Yellow "`tTracking Logs still require running the script with -MeetingID." } # =================================================================================================== @@ -200,6 +254,7 @@ if (-not ([string]::IsNullOrEmpty($Subject)) ) { exit } $script:Identity = $ValidatedIdentities[0] + $script:CurrentIdentityRunStartTime = Get-Date GetCalLogsWithSubject -Identity $ValidatedIdentities -Subject $Subject } elseif (-not ([string]::IsNullOrEmpty($MeetingID))) { #Validate MeetingID is good @@ -212,6 +267,7 @@ if (-not ([string]::IsNullOrEmpty($Subject)) ) { } # Process Logs based off Passed in MeetingID foreach ($ID in $ValidatedIdentities) { + $script:CurrentIdentityRunStartTime = Get-Date Write-DashLineBoxColor "Looking for CalLogs from [$ID] with passed in MeetingID." Write-Verbose "Running: Get-CalendarDiagnosticObjects -Identity [$ID] -MeetingID [$MeetingID] -CustomPropertyNames $CustomPropertyNameList -WarningAction Ignore -MaxResults $LogLimit -ResultSize $LogLimit -ShouldBindToItem $true;" [array] $script:GCDO = GetCalendarDiagnosticObjects -Identity $ID -MeetingID $MeetingID @@ -231,40 +287,7 @@ if (-not ([string]::IsNullOrEmpty($Subject)) ) { } if ($Exceptions.IsPresent) { - Write-Verbose "Looking for Exception Logs..." - $IsRecurring = SetIsRecurring -CalLogs $script:GCDO - Write-Verbose "Meeting IsRecurring: $IsRecurring" - - if ($IsRecurring) { - #collect Exception Logs - $ExceptionLogs = @() - $LogToExamine = @() - $LogToExamine = $script:GCDO | Where-Object { $_.ItemClass -like 'IPM.Appointment*' } | Sort-Object ItemVersion - - Write-Host -ForegroundColor Cyan "Found $($LogToExamine.count) CalLogs to examine for Exception Logs." - if ($LogToExamine.count -gt 100) { - Write-Host -ForegroundColor Cyan "`t This is a large number of logs to examine, this may take a while." - } - $logLeftCount = $LogToExamine.count - - $ExceptionLogs = $LogToExamine | ForEach-Object { - $logLeftCount -= 1 - Write-Verbose "Getting Exception Logs for [$($_.ItemId.ObjectId)]" - Get-CalendarDiagnosticObjects -Identity $ID -ItemIds $_.ItemId.ObjectId -ShouldFetchRecurrenceExceptions $true -CustomPropertyNames $CustomPropertyNameList -ShouldBindToItem $true 3>$null - if (($logLeftCount % 10 -eq 0) -and ($logLeftCount -gt 0)) { - Write-Host -ForegroundColor Cyan "`t [$($logLeftCount)] logs left to examine..." - } - } - # Remove the IPM.Appointment logs as they are already in the CalLogs. - $ExceptionLogs = $ExceptionLogs | Where-Object { $_.ItemClass -notlike "IPM.Appointment*" } - Write-Host -ForegroundColor Cyan "Found $($ExceptionLogs.count) Exception Logs, adding them into the CalLogs." - - $script:GCDO = $script:GCDO + $ExceptionLogs | Select-Object *, @{n='OrgTime'; e= { ConvertDateTime($_.LogTimestamp.ToString()) } } | Sort-Object OrgTime - $LogToExamine = $null - $ExceptionLogs = $null - } else { - Write-Host -ForegroundColor Cyan "No Recurring Meetings found, no Exception Logs to collect." - } + CollectExceptionLogs -Identity $ID -MeetingID $MeetingID } BuildCSV diff --git a/docs/Calendar/Get-CalendarDiagnosticObjectsSummary.md b/docs/Calendar/Get-CalendarDiagnosticObjectsSummary.md index 014a46062b..6ed250ef9d 100644 --- a/docs/Calendar/Get-CalendarDiagnosticObjectsSummary.md +++ b/docs/Calendar/Get-CalendarDiagnosticObjectsSummary.md @@ -4,6 +4,8 @@ Download the latest release: [Get-CalendarDiagnosticObjectsSummary.ps1](https:// This script runs the Get-CalendarDiagnosticObjects cmdlet and returns a summarized timeline of actions in clear English as well as the Calendar Diagnostic Objects in Excel. +> **New feature:** `-FastExceptions` uses the recurring meeting's `AppointmentRecurrenceBlob` to identify exception dates first, then collects those occurrences directly. This is the fastest option when investigating recurring meeting exceptions, and it now collects the last 6 months by default. Add `-AllExceptions` to collect every exception date instead. + ## Prerequisites 1. Install the [ImportExcel](https://github.com/dfinke/ImportExcel) module (required for the default Excel export): @@ -39,7 +41,7 @@ Collect from **all key participants** upfront. Partial collections produce incom Using **-MeetingID** (the `CleanGlobalObjectId`) is the preferred collection method. It produces more detailed logs than a subject search and allows collecting for multiple participants in a single run. -Using **-Subject** performs a case-insensitive substring match. Only a single `-Identity` can be used with `-Subject`. If multiple meetings match, the script creates a separate file for each match. +Using **-Subject** performs a case-insensitive substring match. Only a single `-Identity` can be used with `-Subject`. If multiple meetings match, the script creates a separate file for each match. Exception collection still runs by default only when the subject search resolves to exactly one `MeetingID`. Tracking logs still require **-MeetingID**. | Parameter | Explanation | |:--- |:---| @@ -51,6 +53,8 @@ Using **-Subject** performs a case-insensitive substring match. Only a single `- | **-Exceptions** | Exceptions are collected by default for recurring meetings. This switch is kept for backward compatibility but is no longer required. Use `-NoExceptions` to skip or `-ExceptionDate` to collect a single occurrence. | | **-NoExceptions** | Do not collect Exception Meetings. | | **-ExceptionDate** | Date of a specific Exception Meeting to collect logs for.
- Fastest way to get logs for a single occurrence of a recurring meeting. | +| **-FastExceptions** | **New feature.** Parse the meeting's `AppointmentRecurrenceBlob` to find exception dates first, then collect those occurrences directly. This fast path collects the last 6 months by default. If blob parsing fails, the script falls back to the legacy per-appointment exception collector. | +| **-AllExceptions** | When used with `-FastExceptions`, collect all exception dates instead of the default last 6 months. | | **-ExportToExcel** | Export the output to an Excel file with formatting (Default).
- Creates three tabs per user (Enhanced, Raw, Timeline) plus a shared Script Info tab.
- To add more users later, close the file and rerun with the new user only. | | **-ExportToCSV** | Export the output to 3 CSV files per user instead of Excel. | | **-CaseNumber** | Case Number to include in the Filename of the output.
- Prepend `_` to filename. | @@ -120,6 +124,16 @@ Collect logs for a specific Exception date: Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -ExceptionDate "01/28/2024" -CaseNumber 123456 ``` +Collect recurring meeting exceptions with the new fast path: +```PowerShell +Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -FastExceptions +``` + +Collect all recurring meeting exceptions with the new fast path: +```PowerShell +Get-CalendarDiagnosticObjectsSummary.ps1 -Identity $Users -MeetingID $MeetingID -FastExceptions -AllExceptions +``` + ## Validate your collection Before analyzing, verify: