44# ===================================================================================================
55# Constants to support the script
66# ===================================================================================================
7+ # Non-US date formats for TryParseExact fallback (constant, created once)
8+ [string []]$script :DateFormats = @ (
9+ " d/M/yyyy h:mm:ss tt" ,
10+ " d/M/yyyy H:mm:ss" ,
11+ " d/M/yyyy H:mm" ,
12+ " d/M/yyyy h:mm tt" ,
13+ " dd/MM/yyyy h:mm:ss tt" ,
14+ " dd/MM/yyyy H:mm:ss" ,
15+ " dd/MM/yyyy HH:mm:ss" ,
16+ " dd/MM/yyyy H:mm" ,
17+ " dd/MM/yyyy h:mm tt" ,
18+ " d/M/yyyy" ,
19+ " dd/MM/yyyy"
20+ )
721
822$script :CalendarItemTypes = @ {
923 ' IPM.Schedule.Meeting.Request.AttendeeListReplication' = " AttendeeList"
1024 ' IPM.Schedule.Meeting.Canceled' = " Cancellation"
1125 ' IPM.OLE.CLASS.{00061055-0000-0000-C000-000000000046}' = " Exception"
1226 ' IPM.Schedule.Meeting.Notification.Forward' = " Forward.Notification"
1327 ' IPM.Appointment' = " Ipm.Appointment"
14- ' IPM.Appointment.MP' = " Ipm.Appointment"
1528 ' IPM.Schedule.Meeting.Request' = " Meeting.Request"
1629 ' IPM.CalendarSharing.EventUpdate' = " SharingCFM"
1730 ' IPM.CalendarSharing.EventDelete' = " SharingDelete"
@@ -22,6 +35,28 @@ $script:CalendarItemTypes = @{
2235 ' (Occurrence Deleted)' = " Exception.Deleted"
2336}
2437
38+ <#
39+ . SYNOPSIS
40+ Resolves the ItemClass to a friendly type name using the CalendarItemTypes table.
41+ If no exact match is found, progressively strips the last dot-segment and retries.
42+ e.g. IPM.Schedule.Meeting.Resp.Tent.Follow → IPM.Schedule.Meeting.Resp.Tent → "Resp.Tent"
43+ #>
44+ function GetItemType {
45+ param ([string ]$ItemClass )
46+ $lookup = $ItemClass
47+ while (-not [string ]::IsNullOrEmpty($lookup )) {
48+ $type = $script :CalendarItemTypes [$lookup ]
49+ if (-not [string ]::IsNullOrEmpty($type )) {
50+ return $type
51+ }
52+ # Strip the last segment and try the parent
53+ $lastDot = $lookup.LastIndexOf (' .' )
54+ if ($lastDot -le 0 ) { break }
55+ $lookup = $lookup.Substring (0 , $lastDot )
56+ }
57+ return $null
58+ }
59+
2560# ===================================================================================================
2661# Functions to support the script
2762# ===================================================================================================
@@ -37,7 +72,7 @@ function MapSharedFolder {
3772 if ($ExternalMasterID -eq " NotFound" ) {
3873 return " Not Shared"
3974 } else {
40- $SharedFolders [$ExternalMasterID ]
75+ $script : SharedFolders [$ExternalMasterID ]
4176 }
4277}
4378
@@ -63,6 +98,7 @@ Creates a Mapping of ExternalMasterID to FolderName
6398function CreateExternalMasterIDMap {
6499 # This function will create a Map of the log folder to ExternalMasterID
65100 $script :SharedFolders = [System.Collections.SortedList ]::new()
101+ $unknownCount = 0
66102 Write-Verbose " Starting CreateExternalMasterIDMap"
67103
68104 foreach ($ExternalID in $script :GCDO.ExternalSharingMasterId | Select-Object - Unique) {
@@ -72,39 +108,55 @@ function CreateExternalMasterIDMap {
72108
73109 $AllFolderNames = @ ($script :GCDO | Where-Object { $_.ExternalSharingMasterId -eq $ExternalID } | Select-Object - ExpandProperty OriginalParentDisplayName | Select-Object - Unique)
74110
111+ # Default calendar folder names across the top localized versions of Exchange
112+ # cSpell:ignore Kalender Calendario Calendrier Calendário Календарь
113+ $DefaultCalendarNames = [System.Collections.Generic.HashSet [string ]]::new([StringComparer ]::OrdinalIgnoreCase)
114+ [void ]$DefaultCalendarNames.Add (' Calendar' ) # English
115+ [void ]$DefaultCalendarNames.Add (' Kalender' ) # German, Dutch, Swedish, Norwegian, Danish
116+ [void ]$DefaultCalendarNames.Add (' Calendario' ) # Spanish, Italian
117+ [void ]$DefaultCalendarNames.Add (' Calendrier' ) # French
118+ [void ]$DefaultCalendarNames.Add (' Calendário' ) # Portuguese
119+ [void ]$DefaultCalendarNames.Add (' Календарь' ) # Russian
120+
121+ # Remove empty/null entries
122+ $AllFolderNames = @ ($AllFolderNames | Where-Object { -not [string ]::IsNullOrWhiteSpace($_ ) })
123+
75124 if ($AllFolderNames.count -gt 1 ) {
76- # We have 2+ FolderNames, Need to find the best one. Remove 'Calendar' from possible names
77- $AllFolderNames = $AllFolderNames | Where-Object { $_ -notmatch ' Calendar' } # Need a better way to do this for other languages...
125+ # We have 2+ FolderNames, remove default calendar folder names (localized) by exact match
126+ $Filtered = $AllFolderNames | Where-Object { -not $DefaultCalendarNames.Contains ($_ ) }
127+ if ($Filtered.Count -gt 0 ) {
128+ $AllFolderNames = @ ($Filtered )
129+ }
130+ # else keep the original list — all entries were default calendar names
78131 }
79132
80133 if ($AllFolderNames.Count -eq 0 ) {
81- $SharedFolders [$ExternalID ] = " UnknownSharedCalendarCopy"
82- Write-Host - ForegroundColor red " Found Zero to map to."
83- }
84-
85- if ($AllFolderNames.Count -eq 1 ) {
86- $SharedFolders [$ExternalID ] = $AllFolderNames
87- Write-Verbose " Found map: [$AllFolderNames ] is for $ExternalID "
134+ $unknownCount ++
135+ $script :SharedFolders [$ExternalID ] = " UnknownSharedCalendarCopy$unknownCount "
136+ Write-Host - ForegroundColor red " Found Zero folder names to map to for ExternalID [$ExternalID ]."
137+ } elseif ($AllFolderNames.Count -eq 1 ) {
138+ $script :SharedFolders [$ExternalID ] = $AllFolderNames [0 ]
139+ Write-Verbose " Found map: [$ ( $AllFolderNames [0 ]) ] is for $ExternalID "
88140 } else {
89141 # we still have multiple possible Folder Names, need to chose one or combine
90142 Write-Host - ForegroundColor Red " Unable to Get Exact Folder for $ExternalID "
91- Write-Host - ForegroundColor Red " Found $ ( $AllFolderNames.count ) possible folders"
143+ Write-Host - ForegroundColor Red " Found $ ( $AllFolderNames.count ) possible folders: $ ( $AllFolderNames -join ' , ' ) "
92144
93145 if ($AllFolderNames.Count -eq 2 ) {
94- $SharedFolders [$ExternalID ] = $AllFolderNames [0 ] + $AllFolderNames [1 ]
146+ $script : SharedFolders [$ExternalID ] = $AllFolderNames [0 ] + " + " + $AllFolderNames [1 ]
95147 } else {
96- $SharedFolders [$ExternalID ] = " UnknownSharedCalendarCopy"
148+ $unknownCount ++
149+ $script :SharedFolders [$ExternalID ] = " UnknownSharedCalendarCopy$unknownCount "
97150 }
98151 }
99152 }
100153
101154 Write-Host - ForegroundColor Green " Created the following Shared Calendar Mapping:"
102- foreach ($Key in $SharedFolders.Keys ) {
103- Write-Host - ForegroundColor Green " $Key : $ ( $SharedFolders [$Key ]) "
155+ foreach ($Key in $script : SharedFolders.Keys ) {
156+ Write-Host - ForegroundColor Green " $Key : $ ( $script : SharedFolders [$Key ]) "
104157 }
105- # ToDo: Need to check for multiple ExternalSharingMasterId pointing to the same FolderName
106158 Write-Verbose " Created the following Mapping :"
107- Write-Verbose $SharedFolders
159+ Write-Verbose $script : SharedFolders
108160}
109161
110162<#
@@ -130,18 +182,20 @@ Builds the CSV output from the Calendar Diagnostic Objects
130182function BuildCSV {
131183
132184 Write-Host " Starting to Process Calendar Logs..."
133- $GCDOResults = @ ()
134185 $script :MailboxList = @ {}
186+ # Initialize lookup caches to avoid redundant CN resolution across hundreds of log entries
187+ $script :SMTPAddressCache = @ {}
188+ $script :DisplayNameCache = @ {}
135189 Write-Host " Creating Map of Mailboxes to CNs..."
136190 CreateExternalMasterIDMap
137191 ConvertCNtoSMTP
138192 FixCalendarItemType($script :GCDO )
139193
140194 Write-Host " Making Calendar Logs more readable..."
141195 $Index = 0
142- foreach ($CalLog in $script :GCDO ) {
196+ $GCDOResults = foreach ($CalLog in $script :GCDO ) {
143197 $Index ++
144- $ItemType = $CalendarItemTypes .( $ CalLog.ItemClass )
198+ $ItemType = GetItemType $ CalLog.ItemClass
145199
146200 # CleanNotFounds
147201 $PropsToClean = " FreeBusyStatus" , " ClientIntent" , " AppointmentSequenceNumber" , " AppointmentLastSequenceNumber" , " RecurrencePattern" , " AppointmentAuxiliaryFlags" , " EventEmailReminderTimer" , " IsSeriesCancelled" , " AppointmentCounterProposal" , " MeetingRequestType" , " SendMeetingMessagesDiagnostics" , " AttendeeCollection"
@@ -152,8 +206,8 @@ function BuildCSV {
152206 }
153207 }
154208
155- # Record one row
156- $GCDOResults += [PSCustomObject ]@ {
209+ # Output one row (collected by the foreach assignment)
210+ [PSCustomObject ]@ {
157211 # 'LogRow' = $Index
158212 ' LogTimestamp' = ConvertDateTime($CalLog.LogTimestamp )
159213 ' LogRowType' = $CalLog.LogRowType.ToString ()
@@ -180,7 +234,7 @@ function BuildCSV {
180234 ' CalendarItemType' = $CalLog.CalendarItemType.ToString ()
181235 ' RecurrencePattern' = $CalLog.RecurrencePattern
182236 ' AppointmentAuxiliaryFlags' = $CalLog.AppointmentAuxiliaryFlags.ToString ()
183- ' DisplayAttendeesAll' = $CalLog.DisplayAttendeesAll
237+ ' DisplayAttendeesAll' = $ ( if ( $ CalLog.DisplayAttendeesAll -eq " NotFound " ) { " - " } else { $CalLog .DisplayAttendeesAll })
184238 ' AttendeeCount' = GetAttendeeCount($CalLog.DisplayAttendeesAll )
185239 ' AppointmentState' = $CalLog.AppointmentState.ToString ()
186240 ' ResponseType' = $CalLog.ResponseType.ToString ()
@@ -189,10 +243,12 @@ function BuildCSV {
189243 ' HasAttachment' = $CalLog.HasAttachment
190244 ' IsCancelled' = $CalLog.IsCancelled
191245 ' IsAllDayEvent' = $CalLog.IsAllDayEvent
246+ ' Sensitivity' = $CalLog.Sensitivity
192247 ' IsSeriesCancelled' = $CalLog.IsSeriesCancelled
193248 ' SendMeetingMessagesDiagnostics' = $CalLog.SendMeetingMessagesDiagnostics
194249 ' AttendeeCollection' = MultiLineFormat($CalLog.AttendeeCollection )
195250 ' CalendarLogRequestId' = $CalLog.CalendarLogRequestId.ToString () # Move to front.../ Format in groups???
251+ ' CleanGlobalObjectId' = $CalLog.CleanGlobalObjectId
196252 }
197253 }
198254 $script :EnhancedCalLogs = $GCDOResults
@@ -210,7 +266,31 @@ function ConvertDateTime {
210266 $DateTime -eq " NotFound" ) {
211267 return " "
212268 }
213- return [DateTime ]$DateTime
269+
270+ $InvariantCulture = [System.Globalization.CultureInfo ]::InvariantCulture
271+ $DateStyles = [System.Globalization.DateTimeStyles ]::None
272+ $Parsed = [DateTime ]::MinValue
273+
274+ # Priority 1 & 2: InvariantCulture TryParse
275+ # Handles ISO 8601 and M/d/yyyy (the default EXO output format)
276+ if ([DateTime ]::TryParse($DateTime , $InvariantCulture , $DateStyles , [ref ]$Parsed )) {
277+ return $Parsed
278+ }
279+
280+ # Priority 3: Explicit non-US formats via TryParseExact
281+ # Reached when day > 12 makes InvariantCulture fail (e.g., "22/07/2024")
282+ if ([DateTime ]::TryParseExact($DateTime , $script :DateFormats , $InvariantCulture , $DateStyles , [ref ]$Parsed )) {
283+ return $Parsed
284+ }
285+
286+ # Priority 4: Current culture (last resort for unexpected formats)
287+ if ([DateTime ]::TryParse($DateTime , [ref ]$Parsed )) {
288+ return $Parsed
289+ }
290+
291+ # Priority 5: Return MinValue so sorting is not broken by mixed types
292+ Write-Warning " Unable to parse date: [$DateTime ]"
293+ return [DateTime ]::MinValue
214294}
215295
216296function GetAttendeeCount {
0 commit comments