SKYAPI.psm1
# Configure script to use TLS 1.2 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # Set Global User Data Path Variable New-Variable -Name 'sky_api_user_data_path' -Value "$([Environment]::GetEnvironmentVariable('LOCALAPPDATA'))\SKYAPI PowerShell" -Scope Global -Force # Aliases Set-Alias -Name Get-SchoolLegacyList -Value Get-SchoolList Set-Alias -Name Get-SchoolSchedulesMeetings -Value Get-SchoolScheduleMeeting Set-Alias -Name Get-SchoolActivityListBySchoolLevel -Value Get-SchoolActivityBySchoolLevel Set-Alias -Name Get-SchoolAdvisoryListBySchoolLevel -Value Get-SchoolAdvisoryBySchoolLevel Set-Alias -Name Get-SchoolCourseList -Value Get-SchoolCourse Set-Alias -Name Get-SchoolDepartmentList -Value Get-SchoolDepartment Set-Alias -Name Get-SchoolEducationList -Value Get-SchoolUserEducation Set-Alias -Name Get-SchoolGradeLevelList -Value Get-SchoolGradeLevel Set-Alias -Name Get-SchoolLevelList -Value Get-SchoolLevel Set-Alias -Name Get-SchoolNewsCategories -Value Get-SchoolNewsCategory Set-Alias -Name Get-SchoolNewsItems -Value Get-SchoolNewsItem Set-Alias -Name Get-SchoolOfferingTypeList -Value Get-SchoolOfferingType Set-Alias -Name Get-SchoolRoleList -Value Get-SchoolRole Set-Alias -Name Get-SchoolSectionListBySchoolLevel -Value Get-SchoolSectionBySchoolLevel Set-Alias -Name Get-SchoolSectionListByStudent -Value Get-SchoolSectionByStudent Set-Alias -Name Get-SchoolSectionListByTeacher -Value Get-SchoolSectionByTeacher Set-Alias -Name Get-SchoolStudentEnrollmentList -Value Get-SchoolStudentEnrollment Set-Alias -Name Get-SchoolStudentListBySection -Value Get-SchoolStudentBySection Set-Alias -Name Get-SchoolTermList -Value Get-SchoolTerm Set-Alias -Name Get-SchoolUserExtendedList -Value Get-SchoolUserExtendedByBaseRole Set-Alias -Name Get-SchoolUserList -Value Get-SchoolUserByRole Set-Alias -Name Get-SchoolUserPhoneList -Value Get-SchoolUserPhone Set-Alias -Name Get-SchoolUserPhoneTypeList -Value Get-SchoolUserPhoneType Set-Alias -Name Get-SchoolYearList -Value Get-SchoolYear Set-Alias -Name New-SchoolEventsCategory -Value New-SchoolEventCategory # Type Definitions # Public Enum # Name: MarkerType # Value: NEXT_RECORD_NUMBER - Use the record number as the marker value to return the next set of results. For example: marker=101 will return the second set of results. # Value: OFFSET - The record to start the next collection on. # Value: LAST_USER_ID - Use the last user's ID as the marker value to return the next set of results. # Value: NEXT_PAGE - Use the page number as the marker value to return the next set of results. For example: page=2 will return the second set of results. # Check to see if the MarkerType Type is already loading to prevent the "Cannot add type. The type name 'MarkerType' already exists." error message. if ("MarkerType" -as [type]) {} else { Add-Type -TypeDefinition @" public enum MarkerType { NEXT_RECORD_NUMBER, OFFSET, LAST_USER_ID, NEXT_PAGE } "@ } # Functions function Set-SKYAPIConfigFilePath { param ( [Parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$Path ) New-Variable -Name 'sky_api_config_file_path' -Value $Path -Scope Global -Force } function Set-SKYAPITokensFilePath { param ( [Parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$Path ) New-Variable -Name 'sky_api_tokens_file_path' -Value $Path -Scope Global -Force } Function Get-SKYAPIAuthToken { [CmdletBinding()] Param($grant_type,$client_id,$redirect_uri,$client_secret,$authCode,$token_uri) #Build token request $AuthorizationPostRequest = 'grant_type=' + $grant_type + '&' + 'redirect_uri=' + [System.Web.HttpUtility]::UrlEncode($redirect_uri) + '&' + 'client_id=' + $client_id + '&' + 'client_secret=' + [System.Web.HttpUtility]::UrlEncode($client_secret) + '&' + 'code=' + $authCode $Authorization = Invoke-RestMethod -Method Post ` -ContentType application/x-www-form-urlencoded ` -Uri $token_uri ` -Body $AuthorizationPostRequest $Authorization } <# Get-SKYAPIAccessToken: Uses the long life (365 days) refresh_token to get a new access_token. When you use a refresh token, you'll receive a new short-lived access token (60 minutes) that you can use when making subsequent calls to the SKY API. Using a refresh token also exchanges the current refresh token for a new one to reset the token life. #> Function Get-SKYAPIAccessToken { [CmdletBinding()] Param($grant_type,$client_id,$redirect_uri,$client_secret,$authCode,$token_uri) #Build token request $AuthorizationPostRequest = 'grant_type=' + $grant_type + '&' + 'redirect_uri=' + [System.Web.HttpUtility]::UrlEncode($redirect_uri) + '&' + 'client_id=' + $client_id + '&' + 'client_secret=' + [System.Web.HttpUtility]::UrlEncode($client_secret) + '&' + 'refresh_token=' + $authCode $Authorization = Invoke-RestMethod -Method Post ` -ContentType application/x-www-form-urlencoded ` -Uri $token_uri ` -Body $AuthorizationPostRequest # Add in creation timestamps for the tokens (NOTE THIS IS UTC). $Timestamp = $((Get-Date).ToUniversalTime().ToString("o")) $Authorization | Add-Member -MemberType NoteProperty -Name "refresh_token_creation" -Value $Timestamp -Force $Authorization | Add-Member -MemberType NoteProperty -Name "access_token_creation" -Value $Timestamp -Force $Authorization } # Helper function to get a specified nested member property of an object. # From: https://stackoverflow.com/questions/69368564/powershell-get-value-from-json-using-string-from-array # This will take an array with each item as the next property in the path, or you can use a string with a delimiter (e.g., "results.rows") function Resolve-SKYAPIMemberChain { param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [psobject[]]$InputObject, [Parameter(Mandatory = $true, Position = 0)] [string[]]$MemberPath, [Parameter(Mandatory = $false)] [string]$Delimiter ) begin { if($PSBoundParameters.ContainsKey('Delimiter')) { $MemberPath = $MemberPath.Split([string[]]@($Delimiter)) } } process { foreach($obj in $InputObject) { $cursor = $obj foreach($member in $MemberPath) { $cursor = $cursor.$member } $cursor } } } # Helper to make sure Browser Emulation/Compatibility Mode is Off When Using the WebBrowser Control. # This function will set the Internet Explorer emulation mode for the running executable. This allows the WebBrowser control to support newer html features and improves compatibility with modern websites. # Modified from https://www.sapien.com/blog/2020/11/05/a-simple-fix-for-problems-with-windows-forms-webbrowser/ (see also https://bchallis.wordpress.com/2020/10/17/problems-with-the-windows-forms-webbrowser-control-and-a-simple-way-to-fix-it/) function Set-SKYAPIWebBrowserEmulation { param ( [ValidateNotNullOrEmpty()] [string] $ExecutableName = [System.IO.Path]::GetFileName([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) ) #region Get IE Version $valueNames = 'svcVersion', 'svcUpdateVersion', 'Version', 'W2kVersion' $version = 0; for ($i = 0; $i -lt $valueNames.Length; $i++) { $objVal = [Microsoft.Win32.Registry]::GetValue('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Internet Explorer', $valueNames[$i], '0') $strVal = [System.Convert]::ToString($objVal) if ($strVal) { $iPos = $strVal.IndexOf('.') if ($iPos -gt 0) { $strVal = $strVal.Substring(0, $iPos) } $res = 0; if ([int]::TryParse($strVal, [ref]$res)) { $version = [Math]::Max($version, $res) } } } if ($version -lt 7) { $version = 7000 } else { $version = $version * 1000 } #endregion [Microsoft.Win32.Registry]::SetValue('HKEY_CURRENT_USER\SOFTWARE\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BROWSER_EMULATION', $ExecutableName, $version) } Function Show-SKYAPIOAuthWindow { Param( [parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [System.Uri]$Url, [parameter( Position=1, Mandatory=$false, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [ValidateSet('','EdgeWebView2','MiniHTTPServer','LegacyIEControl')] # Allows null to be passed [string]$AuthenticationMethod, [parameter( Position=2, Mandatory=$false, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [switch]$ClearBrowserControlCache ) # If Edge WebView 2 is the Authentication Method & the runtime not installed - https://developer.microsoft.com/en-us/microsoft-edge/webview2/ # If you run the following command from an elevated process or command prompt, it triggers a per-machine install. # If you don't run the command from an elevated process or command prompt, a per-user install will take place. #However, a per-user install is automatically replaced by a per-machine install, if a per-machine Microsoft Edge Updater is in place. #A per-machine Microsoft Edge Updater is provided as part of Microsoft Edge, except for the Canary preview channel of Microsoft Edge. #For more information, see https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#installing-the-runtime-as-per-machine-or-per-user. if ($null -eq $AuthenticationMethod -or "" -eq $AuthenticationMethod -or $AuthenticationMethod -eq "EdgeWebView2") { # Check if WebView2 is installed $SourceProductName = 'Microsoft Edge WebView2 Runtime' # Partial Name is Fine as Long as it is Unique enough for a match # Get a Listing of Installed Applications From the Registry $InstalledApplicationsFromRegistry = @() $InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" # HKLM Apps $InstalledApplicationsFromRegistry += Get-ItemProperty "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" #HKCU Apps if ([System.Environment]::Is64BitProcess) { $InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" # x86 Apps when on 64-bit } # Get EdgeWebView2 Installed Version (only pull the 1st entry in case more than one comes up) $EdgeWebViewVersionInstalled = $InstalledApplicationsFromRegistry | Where-Object {$_.DisplayName -match $SourceProductName} if ([string]::IsNullOrEmpty($EdgeWebViewVersionInstalled)) { $EdgeWebViewVersionInstalled = "0.0.0.0" # Good idea to set something in case it's not installed due to casting later on. } else { $EdgeWebViewVersionInstalled = $([array]($InstalledApplicationsFromRegistry | Where-Object {$_.DisplayName -match $SourceProductName})[0]).Version } while ((-not ($InstalledApplicationsFromRegistry | Where-Object {$_.DisplayName -match $SourceProductName})) -and ($null -eq $AuthenticationMethod -or "" -eq $AuthenticationMethod -or $AuthenticationMethod -eq "EdgeWebView2") ) { Write-Warning "Microsoft Edge WebView2 Runtime is not installed and is required for browser-based authentication. Please install the runtime and try again." $PromptNoWebView2Runtime_Title = "Options" $PromptNoWebView2Runtime_Message = "Enter your choice:" $PromptNoWebView2Runtime_Choices = [System.Management.Automation.Host.ChoiceDescription[]]@("&Download & install the Edge WebView2 runtime", "&Try alternative method (beta)", "&Cancel & exit") $PromptNoWebView2Runtime_Default = 0 $PromptNoWebView2Runtime_Selection = $host.UI.PromptForChoice($PromptNoWebView2Runtime_Title,$PromptNoWebView2Runtime_Message,$PromptNoWebView2Runtime_Choices,$PromptNoWebView2Runtime_Default) switch($PromptNoWebView2Runtime_Selection) { 0 { Write-Host "Attempting to download & install the Microsoft Edge WebView2 runtime" # Create Download Folder If It Doesn't Already Exist $DownloadPath = "$sky_api_user_data_path\Downloads" $null = New-Item -ItemType Directory -Path $DownloadPath -Force # Download WebView2 Evergreen Bootstrapper $DownloadURL = "https://go.microsoft.com/fwlink/p/?LinkId=2124703" $DownloadContent = Invoke-WebRequest -Uri $DownloadURL $DownloadFileName = "Microsoft Edge WebView2 Runtime Installer.exe" # Create the file (this will overwrite any existing file with the same name) $WebView2Installer = [System.IO.FileStream]::new("$DownloadPath\$DownloadFileName", [System.IO.FileMode]::Create) $WebView2Installer.Write($DownloadContent.Content, 0, $DownloadContent.RawContentLength) $WebView2Installer.Close() # Install Write-Host "File Downloaded. Attempting to run installer." Start-Process -Filepath "$DownloadPath\$DownloadFileName" -Wait # Get a Listing of Installed Applications From the Registry $InstalledApplicationsFromRegistry = @() $InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" # HKLM Apps $InstalledApplicationsFromRegistry += Get-ItemProperty "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" #HKCU Apps if ([System.Environment]::Is64BitProcess) { $InstalledApplicationsFromRegistry += Get-ItemProperty "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" # x86 Apps when on 64-bit } # Retry Opening Authentication Window Write-Host "Retrying Authentication...`n" } 1 { $AuthenticationMethod = "MiniHTTPServer" } 2 { Write-Host "Exiting..." Exit } } } } switch ($AuthenticationMethod) { MiniHTTPServer # TODO { Write-Host "`nUsing this option will attempt to authenticate using an alternate method by building a mini webserver in PowerShell. Continue?" $PromptMiniWebserver_Title = "Options" $PromptMiniWebserver_Message = "Enter your choice:" $PromptMiniWebserver_Choices = [System.Management.Automation.Host.ChoiceDescription[]]@("&Load temporary HTTP server", "&Cancel & exit") $PromptMiniWebserver_Default = 0 $PromptMiniWebserver_Selection = $host.UI.PromptForChoice($PromptMiniWebserver_Title,$PromptMiniWebserver_Message,$PromptMiniWebserver_Choices,$PromptMiniWebserver_Default) switch($PromptMiniWebserver_Selection) { 0 { Write-Warning "Sorry. The mini webserver authentication feature is not yet implemented." Write-Host "Exiting..." Exit } 1 { Write-Host "Exiting..." Exit } } } LegacyIEControl { Set-SKYAPIWebBrowserEmulation if ($ClearBrowserControlCache) { # Try to clear IE cache # More info: https://superuser.com/questions/450014/clearmytracksbyprocess-all-options # Using 4351 (0x10FF) to clear all + files and settings stored by add-ons. Convert Hex to Decimal. # // This magic value is the combination of the following bitflags: # // #define CLEAR_HISTORY 0x0001 // Clears history # // #define CLEAR_COOKIES 0x0002 // Clears cookies # // #define CLEAR_CACHE 0x0004 // Clears Temporary Internet Files folder # // #define CLEAR_CACHE_ALL 0x0008 // Clears offline favorites and download history # // #define CLEAR_FORM_DATA 0x0010 // Clears saved form data for form auto-fill-in # // #define CLEAR_PASSWORDS 0x0020 // Clears passwords saved for websites # // #define CLEAR_PHISHING_FILTER 0x0040 // Clears phishing filter data # // #define CLEAR_RECOVERY_DATA 0x0080 // Clears webpage recovery data # // #define CLEAR_PRIVACY_ADVISOR 0x0800 // Clears tracking data # // #define CLEAR_SHOW_NO_GUI 0x0100 // Do not show a GUI when running the cache clearing # // # // Bitflags available but not used in this magic value are as follows: # // #define CLEAR_USE_NO_THREAD 0x0200 // Do not use multithreading for deletion # // #define CLEAR_PRIVATE_CACHE 0x0400 // Valid only when browser is in private browsing mode # // #define CLEAR_DELETE_ALL 0x1000 // Deletes data stored by add-ons # // #define CLEAR_PRESERVE_FAVORITES 0x2000 // Preserves cached data for "favorite" websites Write-Warning "Note: You may have to close PowerShell and start a new session for clearing the IE cache to take effect." Start-Process -FilePath 'RunDll32.exe' -ArgumentList 'InetCpl.cpl, ClearMyTracksByProcess 4351' -Wait $ClearBrowserControlCache = $false } Add-Type -AssemblyName System.Windows.Forms $form = New-Object -TypeName System.Windows.Forms.Form -Property @{Width=600;Height=800} $web = New-Object -TypeName System.Windows.Forms.WebBrowser -Property @{Width=584;Height=760;Url=($url)} $DocComp = { $Global:uri = $web.Url.AbsoluteUri if ($Global:Uri -match "error=[^&]*|code=[^&]*") {$form.Close() } } $web.ScriptErrorsSuppressed = $true $web.Add_DocumentCompleted($DocComp) $form.Controls.Add($web) $form.Add_Shown({$form.Activate()}) $form.ShowDialog() | Out-Null # Parse Return URL $queryOutput = [System.Web.HttpUtility]::ParseQueryString($web.Url.Query) $output = @{} foreach($key in $queryOutput.Keys){ $output["$key"] = $queryOutput[$key] } # Dispose Form & IE WebBrowser Control $web.Dispose() $form.Dispose() } default # EdgeWebView2 { # Set EdgeWebView2 Control Version to Use $EdgeWebView2Control_VersionNumber = '1.0.1518.46' switch ($PSVersionTable.PSEdition) { Desktop {$EdgeWebView2Control_DotNETVersion = "net45"} Core {$EdgeWebView2Control_DotNETVersion = "netcoreapp3.0"} Default {$EdgeWebView2Control_DotNETVersion = "netcoreapp3.0"} } switch ([System.Environment]::Is64BitProcess) { $true {$EdgeWebView2Control_OSArchitecture = "win-x64"} $false {$EdgeWebView2Control_OSArchitecture = "win-x86"} Default {$EdgeWebView2Control_OSArchitecture = "win-x64"} } # Update $AuthenticationMethod Variable (not currently needed but is useful to have in a variable) $AuthenticationMethod = "EdgeWebView2" # Load Assemblies Add-Type -AssemblyName System.Windows.Forms # Unpack the nupkg and grab the following two DLLs out of the /lib folder. # - Microsoft.Web.WebView2.WinForms.dll (there's a different version for each .NET type, but the same file for x86 & x64) # - Microsoft.Web.WebView2.Core.dll (while there's a copy for each .NET type, so far they have been the same exact file; same file for x86 & x64 too) # In addition, get the following file from the /runtimes folder and put it in the same locations. # - WebView2Loader.dll (different for x86 & x64, but same for .NET Core & .NET 45) Add-Type -Path "$PSScriptRoot\Dependencies\Microsoft.Web.WebView2\$EdgeWebView2Control_VersionNumber\$EdgeWebView2Control_DotNETVersion\$EdgeWebView2Control_OSArchitecture\Microsoft.Web.WebView2.WinForms.dll" $form = New-Object -TypeName System.Windows.Forms.Form -Property @{Width=600;Height=800} $WebView2 = New-Object -TypeName Microsoft.Web.WebView2.WinForms.WebView2 $WebView2.CreationProperties = New-Object -TypeName 'Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties' $WebView2.CreationProperties.UserDataFolder = $sky_api_user_data_path # Clear WebView2 cache in the previously specified UserDataFolder, if requested. # Using the WebView2 SDK to clear the browsing data is best, but wasn't released until version 1.0.1245.22 of the control. # This version of the SDK requires EdgeWebView2 version 102.0.1245.22 to be installed for full API compatibility. # So, we only clear the cache using the SDK if this version or higher of the WebView2 runtime is installed. # Otherwise, we just hardcode deleting the folder. # Note that we have to delete the folder before the control is loaded, # but we can't call the clear until it is initialized (so that code is further down). if ($ClearBrowserControlCache -and [System.Version]$EdgeWebViewVersionInstalled -lt [System.Version]'102.0.1245.22') { Remove-Item "$($WebView2.CreationProperties.UserDataFolder)\EBWebView\Default" -Force -Recurse -ErrorAction Ignore $ClearBrowserControlCache = $false } $WebView2.Source = $Url $WebView2.Size = New-Object System.Drawing.Size(584, 760) # Set Event Handlers. See APIs here: https://github.com/MicrosoftEdge/WebView2Browser#webview2-apis $WebView2_NavigationCompleted = { # Write-Host $($WebView2.Source.AbsoluteUri) # DEBUG LINE if ($WebView2.Source.AbsoluteUri -match "error=[^&]*|$([regex]::escape($redirect_uri))*") { $form.Close() } } $WebView2.add_NavigationCompleted($WebView2_NavigationCompleted) # Set Event Handler for Clearing the Browser Data, if requested. # We can't actually clear the browser data until the CoreWebView2 property is created, so that's why it's down here as an event action. # More info: https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.winforms.webview2 # This event is triggered when the control's CoreWebView2 has finished being initialized # (regardless of how initialization was triggered) but before it is used for anything. # More info: https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.wpf.webview2.corewebview2initializationcompleted if ($ClearBrowserControlCache -and [System.Version]$EdgeWebViewVersionInstalled -ge [System.Version]'102.0.1245.22') { $WebView2_CoreWebView2InitializationCompleted = { $WebView2.CoreWebView2.Profile.ClearBrowsingDataAsync() } $WebView2.add_CoreWebView2InitializationCompleted($WebView2_CoreWebView2InitializationCompleted) $ClearBrowserControlCache = $false } # Add WebView2 Control to the Form and Show It $form.Controls.Add($WebView2) $form.Add_Shown({$form.Activate()}) $form.TopMost = $true # Make's the dialog coming up above the PowerShell console more consistent (though not 100% it seems). $form.ShowDialog() | Out-Null # Parse Return URL $queryOutput = [System.Web.HttpUtility]::ParseQueryString($WebView2.Source.Query) $output = @{} foreach($key in $queryOutput.Keys){ $output["$key"] = $queryOutput[$key] } # Dispose Form & Webview2 Control $WebView2.Dispose() $form.Dispose() } } # Validate the $output variable before returning if ($null -eq $output["code"]) { Write-Warning "Authentication or authorization failed. Try again?" $PromptNoAuthCode_Title = "Options" $PromptNoAuthCode_Message = "Enter your choice:" $PromptNoAuthCode_Choices = [System.Management.Automation.Host.ChoiceDescription[]]@("&Yes", "&No; exit the script") $PromptNoAuthCode_Default = 0 $PromptNoAuthCode_Selection = $host.UI.PromptForChoice($PromptNoAuthCode_Title,$PromptNoAuthCode_Message,$PromptNoAuthCode_Choices,$PromptNoAuthCode_Default) switch($PromptNoAuthCode_Selection) { 0 { # Retry authenticating & authorizing $authOutput = Show-SKYAPIOAuthWindow -url $Url -AuthenticationMethod $AuthenticationMethod -ClearBrowserControlCache:$ClearBrowserControlCache return $authOutput } 1 { throw "Authentication or authorization failed. Exiting..." } } } Return $output } Function Get-SKYAPINewTokens { [CmdletBinding()] Param( [parameter( Position=0, Mandatory=$false, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$sky_api_tokens_file_path, [parameter( Position=1, Mandatory=$false, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [ValidateSet('','EdgeWebView2','MiniHTTPServer','LegacyIEControl')] # Allows null to be passed [string]$AuthenticationMethod, [parameter( Position=2, Mandatory=$false, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [switch]$ClearBrowserControlCache ) # Set the Necessary Config Variables $sky_api_config = Get-SKYAPIConfig -ConfigPath $sky_api_config_file_path $client_id = $sky_api_config.client_id $client_secret = $sky_api_config.client_secret $redirect_uri = $sky_api_config.redirect_uri $authorize_uri = $sky_api_config.authorize_uri $token_uri = $sky_api_config.token_uri # Load Web assembly [Reflection.Assembly]::LoadWithPartialName("System.Web") | Out-Null # Build authorisation URI $strUri = $authorize_uri + "?client_id=$client_id" + "&redirect_uri=" + [System.Web.HttpUtility]::UrlEncode($redirect_uri) + '&response_type=code&state=state' $authOutput = Show-SKYAPIOAuthWindow -Url $strUri -AuthenticationMethod $AuthenticationMethod -ClearBrowserControlCache:$ClearBrowserControlCache # Get auth token $Authorization = Get-SKYAPIAuthToken -grant_type 'authorization_code' -client_id $client_id -redirect_uri $redirect_uri -client_secret $client_secret -authCode $authOutput["code"] -token_uri $token_uri # Swap Refresh token for an Access token (which when requested returns both refresh and access tokens) $Authorization = Get-SKYAPIAccessToken -grant_type 'refresh_token' -client_id $client_id -redirect_uri $redirect_uri -client_secret $client_secret -authCode $authorization.refresh_token -token_uri $token_uri # Make sure path to credentials file parent folder exists and if it doesn't, create it $sky_api_tokens_file_path_ParentDir = Split-Path -Path $sky_api_tokens_file_path If(-not (Test-Path $sky_api_tokens_file_path_ParentDir)) { $null = New-Item -ItemType Directory -Force -Path $sky_api_tokens_file_path_ParentDir } # Save credentials to file $Authorization | ConvertTo-Json ` | ConvertTo-SecureString -AsPlainText -Force ` | ConvertFrom-SecureString ` | Out-File -FilePath $sky_api_tokens_file_path -Force } # Handle Common Errors > https://developer.blackbaud.com/skyapi/docs/resources/in-depth-topics/handle-common-errors function SKYAPICatchInvokeErrors($InvokeErrorMessageRaw) { # Convert From JSON try { $InvokeErrorMessage = $InvokeErrorMessageRaw.ErrorDetails.Message | ConvertFrom-Json } catch { throw $InvokeErrorMessageRaw } # Get Status Code, or Error if Code is blank. Blackbaud sends error messages at least 3 different ways so we need to account for that. Yay for no consistency. If ($InvokeErrorMessage.statusCode) { $StatusCodeorError = $InvokeErrorMessage.statusCode } elseif ($InvokeErrorMessage.error) { $StatusCodeorError = If($InvokeErrorMessage.statusCode) {$InvokeErrorMessage.statusCode} else {$InvokeErrorMessage.error} } elseif ($InvokeErrorMessage.errors) { $StatusCodeorError = If($InvokeErrorMessage.errors.error_code) {$InvokeErrorMessage.errors.error_code} else {$InvokeErrorMessage.errors} } else { # If it's not in a format the module recognizes, then just throw the raw message. throw $InvokeErrorMessageRaw } # Try and handle the error message. Switch ($StatusCodeorError) { invalid_client # You usually see this error when providing an invalid . { # We will display the error, try again and handle the issue later. Write-Warning $InvokeErrorMessageRaw 'retry' } invalid_grant # You usually, but not always, see this error when providing an invalid, expired, or previously used authorization code. { # We will display the error, try again and handle the issue later. Write-Warning $InvokeErrorMessageRaw 'retry' } 400 # Bad request. Usually means that data in the initial request is invalid or improperly formatted. { throw $InvokeErrorMessageRaw } 401 # Unauthorized Request. Could mean that the authenticated user does not have rights to access the requested data or does not have permission to edit a given record or record type. An unauthorized request also occurs if the authorization token expires or if the authorization header is not supplied. { # This can happens if the token has expired so we will try to refresh and then run the invoke again. Connect-SKYAPI -ForceRefresh 'retry' } 429 # Rate limit is exceeded. Try again in 1 seconds. Technically, the number of seconds is returned in the 'Retry-After' header, but I think it's best not to wait longer. { # Sleep for 1 second and return the try command. Start-Sleep -Seconds 1 'retry' } 500 # Internal Server Error. { # Sleep for 5 seconds and return the try command. I don't know if this is a good length, but it seems reasonable since we try 5 times before failing. # The other option would be to use the exponential backoff method where You can periodically retry a failed request over an increasing amount of time to handle errors # related to rate limits, network volume, or response time. For example, you might retry a failed request after one second, then after two seconds, and then after four seconds. Start-Sleep -Seconds 5 'retry' } 503 # The service is currently unavailable. { # Sleep for 5 seconds and return the try command. I don't know if this is a good length, but it seems reasonable since we try 5 times before failing. # The other option would be to use the exponential backoff method where You can periodically retry a failed request over an increasing amount of time to handle errors # related to rate limits, network volume, or response time. For example, you might retry a failed request after one second, then after two seconds, and then after four seconds. Start-Sleep -Seconds 5 'retry' } default { throw $InvokeErrorMessageRaw } } } Function Get-SKYAPIUnpagedEntity { [CmdletBinding()] Param( $uid, $url, $endUrl, $api_key, $authorisation, $params, $response_field, [switch]$ReturnRaw) # Reconnect If the Access Token is Expired if (-NOT (Confirm-SKYAPITokenIsFresh -TokenCreation $authorisation.access_token_creation -TokenType Access)) { Connect-SKYAPI -ForceRefresh $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } # Create Request Uri $uid = [uri]::EscapeDataString($uid) $fullUri = $url + $uid + $endUrl $Request = [System.UriBuilder]$fullUri if ($null -ne $params -and $params -ne '') { $Request.Query = $params.ToString() } # Run Invoke Command and Catch Responses [int]$InvokeCount = 0 [int]$MaxInvokeCount = 5 do { $InvokeCount += 1 $NextAction = $null try { if ($ReturnRaw) { $apiCallResult = Invoke-WebRequest -Method Get ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $($authorisation.access_token)) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) return $apiCallResult.Content } else { $apiCallResult = Invoke-RestMethod -Method Get ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $($authorisation.access_token)) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) # If there is a response field set for the endpoint cmdlet, return that. if ($null -ne $response_field -and "" -ne $response_field) { # return $apiCallResult.$response_field return Resolve-SKYAPIMemberChain -InputObject $apiCallResult -MemberPath $response_field -Delimiter "." } else # else return the entire API call result { return $apiCallResult } } } catch { # Process Invoke Error $LastCaughtError = ($_) $NextAction = SKYAPICatchInvokeErrors($_) # Just in case the token was refreshed by the error catcher, update these $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } }while ($NextAction -eq 'retry' -and $InvokeCount -lt $MaxInvokeCount) if ($InvokeCount -ge $MaxInvokeCount) { throw $LastCaughtError } } Function Get-SKYAPIPagedEntity { [CmdletBinding()] Param( $uid, $url, $endUrl, $api_key, $authorisation, $params, $response_field, $response_limit, $page_limit, [MarkerType]$marker_type) # Reconnect If the Access Token is Expired if (-NOT (Confirm-SKYAPITokenIsFresh -TokenCreation $authorisation.access_token_creation -TokenType Access)) { Connect-SKYAPI -ForceRefresh $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } # Create Request Uri $uid = [uri]::EscapeDataString($uid) $fullUri = $url + $uid + $endUrl $Request = [System.UriBuilder]$fullUri $Request.Query = $params.ToString() # Create records array $allRecords = @() # Run Invoke Command and Catch Responses [int]$InvokeCount = 0 [int]$MaxInvokeCount = 5 do { $InvokeCount += 1 $NextAction = $null try { # Call to the API and loop unless the $page record count is reached. do { $apiItems = Invoke-RestMethod -Method Get ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $authorisation.access_token) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) # If there is a response field set for the endpoint cmdlet, return that. if ($null -ne $response_field -and "" -ne $response_field) { $recordsThisIteration = Resolve-SKYAPIMemberChain -InputObject $apiItems -MemberPath $response_field -Delimiter "." $allRecords += $recordsThisIteration $pageRecordCount = $recordsThisIteration.count } else # No response field { $allRecords += $apiItems $pageRecordCount = $apiItems.count } $totalRecordCount = $allRecords.count # Update marker location for next page switch ($marker_type) { NEXT_RECORD_NUMBER { [int]$params['marker'] += $page_limit $Request.Query = $params.ToString() } OFFSET { [int]$params['offset'] += $page_limit $Request.Query = $params.ToString() } LAST_USER_ID { [int]$params['marker'] = $allRecords[-1].id $Request.Query = $params.ToString() } NEXT_PAGE { [int]$params['page'] += 1 $Request.Query = $params.ToString() } } # If the user supplied a limit, then respect it and don't get subsequent pages if (($null -ne $response_limit -and $response_limit -ne 0 -and $response_limit -ne "") -and $response_limit -le $totalRecordCount) { # If we have too many records, remove the extra ones if ($totalRecordCount -gt $response_limit) { $allRecords = $allRecords[0..($response_limit - 1)] } return $allRecords } } while ($pageRecordCount -eq $page_limit) # Loop to the next page if the current page is full $allRecords } catch { # Process Invoke Error $LastCaughtError = ($_) $NextAction = SKYAPICatchInvokeErrors($_) # Just in case the token was refreshed by the error catcher, update these $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } }while ($NextAction -eq 'retry' -and $InvokeCount -lt $MaxInvokeCount) if ($InvokeCount -ge $MaxInvokeCount) { throw $LastCaughtError } } Function Remove-SKYAPIEntity { [CmdletBinding()] Param($uid, $url, $endUrl, $api_key, $authorisation, $params, $response_field) # Reconnect If the Access Token is Expired if (-NOT (Confirm-SKYAPITokenIsFresh -TokenCreation $authorisation.access_token_creation -TokenType Access)) { Connect-SKYAPI -ForceRefresh $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } # Create Request Uri $uid = [uri]::EscapeDataString($uid) $fullUri = $url + $uid + $endUrl $Request = [System.UriBuilder]$fullUri if ($null -ne $params -and $params -ne '') { $Request.Query = $params.ToString() } # Run Invoke Command and Catch Responses [int]$InvokeCount = 0 [int]$MaxInvokeCount = 5 do { $InvokeCount += 1 $NextAction = $null try { $apiCallResult = Invoke-RestMethod -Method Delete ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $($authorisation.access_token)) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) # If there is a response field set for the endpoint cmdlet, return that. if ($null -ne $response_field -and "" -ne $response_field) { # return $apiCallResult.$response_field return Resolve-SKYAPIMemberChain -InputObject $apiCallResult -MemberPath $response_field -Delimiter "." } else # else return the entire API call result { return $apiCallResult } } catch { # Process Invoke Error $LastCaughtError = ($_) $NextAction = SKYAPICatchInvokeErrors($_) # Just in case the token was refreshed by the error catcher, update these $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } }while ($NextAction -eq 'retry' -and $InvokeCount -lt $MaxInvokeCount) if ($InvokeCount -ge $MaxInvokeCount) { throw $LastCaughtError } } function Submit-SKYAPIEntity { [CmdletBinding()] Param($uid, $url, $endUrl, $api_key, $authorisation, $params, $response_field) # Reconnect If the Access Token is Expired if (-NOT (Confirm-SKYAPITokenIsFresh -TokenCreation $authorisation.access_token_creation -TokenType Access)) { Connect-SKYAPI -ForceRefresh $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } # Create Request Uri $uid = [uri]::EscapeDataString($uid) $fullUri = $url + $uid + $endUrl $Request = [System.UriBuilder]$fullUri # Build Body $PostRequest = $params | ConvertTo-Json # Run Invoke Command and Catch Responses [int]$InvokeCount = 0 [int]$MaxInvokeCount = 5 do { $InvokeCount += 1 $NextAction = $null try { $apiCallResult = Invoke-RestMethod -Method Post ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $($authorisation.access_token)) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) ` -Body $PostRequest # If there is a response field set for the endpoint cmdlet, return that. if ($null -ne $response_field -and "" -ne $response_field) { # return $apiCallResult.$response_field return Resolve-SKYAPIMemberChain -InputObject $apiCallResult -MemberPath $response_field -Delimiter "." } else # else return the entire API call result { return $apiCallResult } } catch { # Process Invoke Error $LastCaughtError = ($_) $NextAction = SKYAPICatchInvokeErrors($_) # Just in case the token was refreshed by the error catcher, update these $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } }while ($NextAction -eq 'retry' -and $InvokeCount -lt $MaxInvokeCount) if ($InvokeCount -ge $MaxInvokeCount) { throw $LastCaughtError } } function Update-SKYAPIEntity { [CmdletBinding()] Param($uid, $url, $endUrl, $api_key, $authorisation, $params, $response_field) # Reconnect If the Access Token is Expired if (-NOT (Confirm-SKYAPITokenIsFresh -TokenCreation $authorisation.access_token_creation -TokenType Access)) { Connect-SKYAPI -ForceRefresh $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } # Create Request Uri $uid = [uri]::EscapeDataString($uid) $fullUri = $url + $uid + $endUrl $Request = [System.UriBuilder]$fullUri # Build Body $PatchRequest = $params | ConvertTo-Json # Run Invoke Command and Catch Responses [int]$InvokeCount = 0 [int]$MaxInvokeCount = 5 do { $InvokeCount += 1 $NextAction = $null try { $apiCallResult = Invoke-RestMethod -Method Patch ` -ContentType application/json ` -Headers @{ 'Authorization' = ("Bearer "+ $($authorisation.access_token)) 'bb-api-subscription-key' = ($api_key)} ` -Uri $($Request.Uri.AbsoluteUri) ` -Body $PatchRequest # If there is a response field set for the endpoint cmdlet, return that. if ($null -ne $response_field -and "" -ne $response_field) { # return $apiCallResult.$response_field return Resolve-SKYAPIMemberChain -InputObject $apiCallResult -MemberPath $response_field -Delimiter "." } else # else return the entire API call result { return $apiCallResult } } catch { # Process Invoke Error $LastCaughtError = ($_) $NextAction = SKYAPICatchInvokeErrors($_) # Just in case the token was refreshed by the error catcher, update these $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile $authorisation.access_token = $($AuthTokensFromFile.access_token) $authorisation.refresh_token = $($AuthTokensFromFile.refresh_token) $authorisation.refresh_token_creation = $($AuthTokensFromFile.refresh_token_creation) $authorisation.access_token_creation = $($AuthTokensFromFile.access_token_creation) } }while ($NextAction -eq 'retry' -and $InvokeCount -lt $MaxInvokeCount) if ($InvokeCount -ge $MaxInvokeCount) { throw $LastCaughtError } } # Check to See if Refresh Token or Access Token is Expired function Confirm-SKYAPITokenIsFresh { param ( [parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [datetime]$TokenCreation, [parameter( Position=1, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [ValidateSet('Refresh','Access')] [string]$TokenType ) # For security purposes, access tokens will expire after 60 minutes. # Refresh tokens will also expire after a much longer period of time (currently 365 days). # More information available at https://developer.blackbaud.com/skyapi/docs/authorization#token-expiration $maxRefreshTokenTimespan = new-timespan -days 364 $maxAccessTokenTimespan = new-timespan -minutes 59 switch ($TokenType) { Refresh {$MaxTokenTimespan = $maxRefreshTokenTimespan} Access {$MaxTokenTimespan = $maxAccessTokenTimespan} } if (((get-date) - $TokenCreation) -lt $MaxTokenTimespan) { $true } else { $false } } function Get-SKYAPIAuthTokensFromFile { param ( ) # Make Sure Requested Path Isn't Null or Empty if ([string]::IsNullOrEmpty($sky_api_tokens_file_path)) { throw "`'`$sky_api_tokens_file_path`' is not specified. Don't forget to first use the `'Set-SKYAPIConfigFilePath`' & `'Set-SKYAPITokensFilePath`' cmdlets!" } try { $apiTokens = Get-Content $sky_api_tokens_file_path -ErrorAction Stop $SecureString = $apiTokens | ConvertTo-SecureString -ErrorAction Stop $AuthTokensFromFile = ((New-Object PSCredential "user",$SecureString).GetNetworkCredential().Password) | ConvertFrom-Json } catch { throw "Key JSON tokens file is missing, corrupted or invalid. Please run Connect-SKYAPI with the -ForceReauthentication parameter to recreate it." } $AuthTokensFromFile } # Fix date-only fields since the API returns dates with improper time values (sends it as -05:00 or sometimes -04:00). # Converting to UTC should resolve the issue (though it makes the unused time portion 5 AM or 4AM, the date is accurate). function Repair-SkyApiDate { param ([DateTime]$Date) $Date = (($(Get-Date($Date).ToUniversalTime()).ToString('o')) -split "T")[0] # Can't use -AsUTC since that's PS Core only (not Windows PS 5.1). # Alternative way to do the same thing? # $Date = $Date.ToUniversalTime() -Format "yyyy-MM-dd" $Date } # Iterates through an object replacing all or part of matching string values # with the specified value using regular expressions. function Set-PSObjectText { param ( [Parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [AllowNull()] $InputObject, [Parameter( Position=1, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$OldValue, # Regex [Parameter( Position=2, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$NewValue # Regex ) if ($null -eq $InputObject) { return } switch ($InputObject.GetType().Name) { String { $InputObject = $InputObject -replace $OldValue, $NewValue } PSCustomObject { foreach ($item in $InputObject.PSObject.Properties | Where-Object -Property MemberType -EQ 'NoteProperty') { $ItemName = $item.Name $InputObject.$ItemName = Set-PSObjectText -InputObject $($InputObject.$ItemName) -OldValue $OldValue -NewValue $NewValue } } 'Object[]' # Array { $InputObject = foreach ($item in $InputObject) { Set-PSObjectText -InputObject $item -OldValue $OldValue -NewValue $NewValue } } Int64 { # Do nothing to the Object. } Boolean { # Do nothing to the Object. } Default { # Do nothing to the Object. } } return $InputObject } # Converts From JSON Without Deserializing DateTime Strings # Dates must be in the roundtrip format and specify the offset (DateTimeKind.Local or DateTimeKind.Utc). # Examples: # - 2009-06-15T13:45:30.0000000Z # - 2009-06-15T13:45:30.0000000-07:00 # - 2009-06-15T13:45:00-07:00 # More Information: Since PowerShell v6, ConvertTo-Json automatically deserializes strings that contain # an "o"-formatted (roundtrip format) date/time string (e.g., "2023-06-15T13:45:00.123Z") # or a prefix of it that includes at least everything up to the seconds part as [datetime] instances. function ConvertFrom-JsonWithoutDateTimeDeserialization { param ( [Parameter( Position=0, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string]$InputObject ) # Set the regular expression patterns. $DateTimeRegex = '"(\d+-\d+.\d+T\d+:\d+:\d+\.?\d+(\+|\-)\d+:\d+)"' $DateTimeRegexWithHash = '(#)(\d+-\d+.\d+T\d+:\d+:\d+\.?\d+(\+|\-)\d+:\d+)' # Prepend the hash sign to round-trip date/time pattern strings. [string]$JsonWithPrefix = $InputObject -replace $DateTimeRegex, '"#$1"' # Convert to a PSCustomObject object. [pscustomobject]$PSObjectWithPrefix = $JsonWithPrefix | ConvertFrom-Json # Remove the added hash signs. Set-PSObjectText -InputObject $PSObjectWithPrefix -OldValue $DateTimeRegexWithHash -NewValue '$2' # return $InputObject } # Import the functions $SKYAPIFunctions = @(Get-ChildItem -Path $PSScriptRoot\Functions\*.ps1) Foreach($SKYAPIFunction in $SKYAPIFunctions) { Write-Verbose "Importing $SKYAPIFunction" Try { . $SKYAPIFunction.fullname } Catch { Write-Error -Message "Failed to import function $($SKYAPIFunction.fullname): $_" } } |