SharePointEssentials.psm1
function Set-LoggingCapabilities { <# .SYNOPSIS Sets up logging capabilities by managing log files. .DESCRIPTION This function sets up logging capabilities by creating the necessary directories and managing the number of log files based on the specified maximum. .PARAMETER LogPath The path where the log files will be stored. .PARAMETER ScriptPath The path of the script that generates the logs. .PARAMETER LogMaximum The maximum number of log files to keep. Older files will be deleted if this limit is exceeded. .PARAMETER ShowTime Switch to include timestamps in the log entries. .PARAMETER TimeFormat The format of the timestamps in the log entries. .EXAMPLE Set-LoggingCapabilities -LogPath "C:\Logs\log.log" -ScriptPath "C:\Scripts\script.ps1" -LogMaximum 10 -ShowTime -TimeFormat "yyyy-MM-dd HH:mm:ss" .NOTES This function is used in: - CleanupMonster - PasswordSolution - SharePointEssentials And many other scripts. #> [CmdletBinding()] param( [Alias('Path', 'Log', 'Folder', 'LiteralPath', 'FilePath')][string] $LogPath, [string] $ScriptPath, [Alias('Maximum')][int] $LogMaximum, [switch] $ShowTime, [string] $TimeFormat ) $Script:PSDefaultParameterValues = @{ "Write-Color:LogFile" = $LogPath "Write-Color:ShowTime" = if ($PSBoundParameters.ContainsKey('ShowTime')) { $ShowTime.IsPresent } else { $null } "Write-Color:TimeFormat" = $TimeFormat } if ($LogPath) { try { $FolderPath = [io.path]::GetDirectoryName($LogPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false } if ($LogMaximum -gt 0) { if ($ScriptPath) { $ScriptPathFolder = [io.path]::GetDirectoryName($ScriptPath) if ($ScriptPathFolder -eq $FolderPath) { Write-Color -Text '[i] ', "LogMaximum is set to ", $LogMaximum, " but log files are in the same folder as the script. Cleanup disabled." -Color Yellow, White, DarkCyan, White return } $LogPathExtension = [io.path]::GetExtension($LogPath) if ($LogPathExtension) { $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath -Filter "*$LogPathExtension" -ErrorAction Stop | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $LogMaximum } else { $CurrentLogs = $null Write-Color -Text '[i] ', "Log file has no extension (?!). Cleanup disabled." -Color Yellow, White, DarkCyan, White } if ($CurrentLogs) { Write-Color -Text '[i] ', "Logs directory has more than ", $LogMaximum, " log files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Log in $CurrentLogs) { try { Remove-Item -LiteralPath $Log.FullName -Confirm:$false -WhatIf:$false Write-Color -Text '[+] ', "Deleted ", "$($Log.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete log file $($Log.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } else { Write-Color -Text '[i] ', "LogMaximum is set to ", $LogMaximum, " but no script path detected. Most likely running interactively. Cleanup disabled." -Color Yellow, White, DarkCyan, White } } else { Write-Color -Text '[i] ', "LogMaximum is set to 0 (Unlimited). No log files will be deleted." -Color Yellow, DarkCyan } } catch { Write-Color -Text "[e] ", "Couldn't create the log directory. Error: $($_.Exception.Message)" -Color Yellow, Red $Script:PSDefaultParameterValues["Write-Color:LogFile"] = $null } } else { $Script:PSDefaultParameterValues["Write-Color:LogFile"] = $null } Remove-EmptyValue -Hashtable $Script:PSDefaultParameterValues } function Write-Color { <# .SYNOPSIS Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. .DESCRIPTION Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. It provides: - Easy manipulation of colors, - Logging output to file (log) - Nice formatting options out of the box. - Ability to use aliases for parameters .PARAMETER Text Text to display on screen and write to log file if specified. Accepts an array of strings. .PARAMETER Color Color of the text. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER BackGroundColor Color of the background. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER StartTab Number of tabs to add before text. Default is 0. .PARAMETER LinesBefore Number of empty lines before text. Default is 0. .PARAMETER LinesAfter Number of empty lines after text. Default is 0. .PARAMETER StartSpaces Number of spaces to add before text. Default is 0. .PARAMETER LogFile Path to log file. If not specified no log file will be created. .PARAMETER DateTimeFormat Custom date and time format string. Default is yyyy-MM-dd HH:mm:ss .PARAMETER LogTime If set to $true it will add time to log file. Default is $true. .PARAMETER LogRetry Number of retries to write to log file, in case it can't write to it for some reason, before skipping. Default is 2. .PARAMETER Encoding Encoding of the log file. Default is Unicode. .PARAMETER ShowTime Switch to add time to console output. Default is not set. .PARAMETER NoNewLine Switch to not add new line at the end of the output. Default is not set. .PARAMETER NoConsoleOutput Switch to not output to console. Default all output goes to console. .EXAMPLE Write-Color -Text "Red ", "Green ", "Yellow " -Color Red,Green,Yellow .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan -StartTab 3 -LinesBefore 1 -LinesAfter 1 .EXAMPLE Write-Color "1. ", "Option 1" -Color Yellow, Green Write-Color "2. ", "Option 2" -Color Yellow, Green Write-Color "3. ", "Option 3" -Color Yellow, Green Write-Color "4. ", "Option 4" -Color Yellow, Green Write-Color "9. ", "Press 9 to exit" -Color Yellow, Gray -LinesBefore 1 .EXAMPLE Write-Color -LinesBefore 2 -Text "This little ","message is ", "written to log ", "file as well." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" -TimeFormat "yyyy-MM-dd HH:mm:ss" Write-Color -Text "This can get ","handy if ", "want to display things, and log actions to file ", "at the same time." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" .EXAMPLE Write-Color -T "My text", " is ", "all colorful" -C Yellow, Red, Green -B Green, Green, Yellow Write-Color -t "my text" -c yellow -b green Write-Color -text "my text" -c red .EXAMPLE Write-Color -Text "Testuję czy się ładnie zapisze, czy będą problemy" -Encoding unicode -LogFile 'C:\temp\testinggg.txt' -Color Red -NoConsoleOutput .NOTES Understanding Custom date and time format strings: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings Project support: https://github.com/EvotecIT/PSWriteColor Original idea: Josh (https://stackoverflow.com/users/81769/josh) #> [alias('Write-Colour')] [CmdletBinding()] param ( [alias ('T')] [String[]]$Text, [alias ('C', 'ForegroundColor', 'FGC')] [ConsoleColor[]]$Color = [ConsoleColor]::White, [alias ('B', 'BGC')] [ConsoleColor[]]$BackGroundColor = $null, [alias ('Indent')][int] $StartTab = 0, [int] $LinesBefore = 0, [int] $LinesAfter = 0, [int] $StartSpaces = 0, [alias ('L')] [string] $LogFile = '', [Alias('DateFormat', 'TimeFormat')][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [alias ('LogTimeStamp')][bool] $LogTime = $true, [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf7', 'utf32', 'ascii', 'default', 'oem')][string]$Encoding = 'Unicode', [switch] $ShowTime, [switch] $NoNewLine, [alias('HideConsole')][switch] $NoConsoleOutput ) if (-not $NoConsoleOutput) { $DefaultColor = $Color[0] if ($null -ne $BackGroundColor -and $BackGroundColor.Count -ne $Color.Count) { Write-Error "Colors, BackGroundColors parameters count doesn't match. Terminated." return } if ($LinesBefore -ne 0) { for ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line before if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } # Add TABS before text if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } # Add SPACES before text if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } # Add Time before output if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { # the real deal coloring if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } } else { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } } } else { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -NoNewline } } else { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -BackgroundColor $BackGroundColor[0] -NoNewline } } } } if ($NoNewLine -eq $true) { Write-Host -NoNewline } else { Write-Host } # Support for no new line if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line after } if ($Text.Count -and $LogFile) { # Save to file $TextToFile = "" for ($i = 0; $i -lt $Text.Length; $i++) { $TextToFile += $Text[$i] } $Saved = $false $Retry = 0 Do { $Retry++ try { if ($LogTime) { "[$([datetime]::Now.ToString($DateTimeFormat))] $TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } else { "$TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } $Saved = $true } catch { if ($Saved -eq $false -and $Retry -eq $LogRetry) { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Tried ($Retry/$LogRetry))" } else { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Retrying... ($Retry/$LogRetry)" } } } Until ($Saved -eq $true -or $Retry -ge $LogRetry) } } function Remove-EmptyValue { <# .SYNOPSIS Removes empty values from a hashtable recursively. .DESCRIPTION This function removes empty values from a given hashtable. It can be used to clean up a hashtable by removing keys with null, empty string, empty array, or empty dictionary values. The function supports recursive removal of empty values. .PARAMETER Hashtable The hashtable from which empty values will be removed. .PARAMETER ExcludeParameter An array of keys to exclude from the removal process. .PARAMETER Recursive Indicates whether to recursively remove empty values from nested hashtables. .PARAMETER Rerun Specifies the number of times to rerun the removal process recursively. .PARAMETER DoNotRemoveNull If specified, null values will not be removed. .PARAMETER DoNotRemoveEmpty If specified, empty string values will not be removed. .PARAMETER DoNotRemoveEmptyArray If specified, empty array values will not be removed. .PARAMETER DoNotRemoveEmptyDictionary If specified, empty dictionary values will not be removed. .EXAMPLE $hashtable = @{ 'Key1' = ''; 'Key2' = $null; 'Key3' = @(); 'Key4' = @{} } Remove-EmptyValue -Hashtable $hashtable -Recursive Description ----------- This example removes empty values from the $hashtable recursively. #> [alias('Remove-EmptyValues')] [CmdletBinding()] param( [alias('Splat', 'IDictionary')][Parameter(Mandatory)][System.Collections.IDictionary] $Hashtable, [string[]] $ExcludeParameter, [switch] $Recursive, [int] $Rerun, [switch] $DoNotRemoveNull, [switch] $DoNotRemoveEmpty, [switch] $DoNotRemoveEmptyArray, [switch] $DoNotRemoveEmptyDictionary ) foreach ($Key in [string[]] $Hashtable.Keys) { if ($Key -notin $ExcludeParameter) { if ($Recursive) { if ($Hashtable[$Key] -is [System.Collections.IDictionary]) { if ($Hashtable[$Key].Count -eq 0) { if (-not $DoNotRemoveEmptyDictionary) { $Hashtable.Remove($Key) } } else { Remove-EmptyValue -Hashtable $Hashtable[$Key] -Recursive:$Recursive } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } } if ($Rerun) { for ($i = 0; $i -lt $Rerun; $i++) { Remove-EmptyValue -Hashtable $Hashtable -Recursive:$Recursive } } } Function Export-FilesToSharePoint { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)][Array] $Source, [Parameter(Mandatory)][string] $SourceFolderPath, [Parameter(Mandatory)][string] $TargetLibraryName, [Parameter(Mandatory)][Microsoft.SharePoint.Client.ClientObject] $TargetFolder ) # Get all files from SharePoint Online $TargetFilesCount = 0 $TargetDirectoryCount = 0 $TargetFiles = Get-PnPListItem -List $TargetLibraryName -PageSize 2000 $Target = foreach ($File in $TargetFiles) { # Dates are not the same as in SharePoint, so we need to convert them to UTC # And make sure we don't add miliseconds $Date = $File.FieldValues.Modified.ToUniversalTime() if ($File.FieldValues.FileRef -like "$($TargetFolder.ServerRelativeUrl)/*") { [PSCustomObject] @{ FullName = $File.FieldValues.FileRef.Replace($TargetFolder.ServerRelativeURL, $SourceFolderPath).Replace("/", "\") PSIsContainer = $File.FileSystemObjectType -eq "Folder" TargetItemURL = $File.FieldValues.FileRef.Replace($Web.ServerRelativeUrl, [string]::Empty) LastUpdated = [datetime]::new($Date.Year, $Date.Month, $Date.Day, $Date.Hour, $Date.Minute, $Date.Second) } if (-not $File.FileSystemObjectType -eq "Folder") { $TargetFilesCount++ } else { $TargetDirectoryCount++ } #Write-Color -Text "[i] ", "File ", "'$($File.FieldValues.FileRef)'", " is in the target folder." -Color Yellow, White, Yellow } else { #Write-Color -Text "[!] ", "File ", "'$($File.FieldValues.FileRef)'", " is not in the target folder. Skipping." -Color Yellow, White, Yellow, Red } } Write-Color -Text "[i] ", "Total items (files) in target: ", "$($TargetFilesCount)" -Color Yellow, White, Green Write-Color -Text "[i] ", "Total items (folders) in target: ", "$($TargetDirectoryCount)" -Color Yellow, White, Green # Compare source/target and add files that are not in the target $CacheFilesTarget = [ordered] @{} $ActionsToDo = [ordered] @{ "Add" = [System.Collections.Generic.List[Object]]::new() "Nothing" = [System.Collections.Generic.List[Object]]::new() "Update" = [System.Collections.Generic.List[Object]]::new() "Remove" = [System.Collections.Generic.List[Object]]::new() } foreach ($File in $Target) { $CacheFilesTarget[$File.FullName] = $File } foreach ($File in $Source) { if ($CacheFilesTarget[$File.FullName]) { if (-not $File.PSIsContainer) { $TargetFile = $CacheFilesTarget[$File.FullName] if ($Source.PSIsContainer -eq $TargetFile.PSiSContainer -and $Source.TargetItemURL -eq $TargetFile.TargetItemURL -and $Source.LastUpdated -eq $TargetFile.LastUpdated) { $ActionsToDo["Nothing"].Add($File) } elseif ($Source.PSIsContainer -eq $TargetFile.PSiSContainer -and $Source.TargetItemURL -eq $TargetFile.TargetItemURL -and $Source.LastUpdated -ne $TargetFile.LastUpdated) { #Write-Color -Text "[>] Update ", $($File.FullName), " is required. Dates are different: ", "$($File.LastUpdated)", " vs ", "$($TargetFile.LastUpdated)" -Color Yellow, White, Yellow, White, Yellow, Red $ActionsToDo["Update"].Add($File) } elseif ($Source.PSIsContainer -ne $TargetFile.PSiSContainer -or $Source.TargetItemURL -ne $TargetFile.TargetItemURL) { # not really needed here Write-Color -Text "This should never happen 1" -Color Red } else { # this should never happen right? Write-Color -Text "This should never happen 2" -Color Red } } } else { $ActionsToDo["Add"].Add($File) } } Write-Color -Text "[i] ", "Total items to update: ", "$($ActionsToDo['Update'].Count)" -Color Yellow, White, Green Write-Color -Text "[i] ", "Total items to add: ", "$($ActionsToDo['Add'].Count)" -Color Yellow, White, Green Write-Color -Text "[i] ", "Total items matching: ", "$($ActionsToDo['Nothing'].Count)" -Color Yellow, White, Green $Counter = 1 foreach ($SourceFile in $ActionsToDo["Add"] | Sort-Object TargetItemURL) { # Calculate Target Folder URL for the file $TargetFolderURL = (Split-Path $SourceFile.TargetItemURL -Parent).Replace("\", "/") If ($TargetFolderURL.StartsWith("/")) { $TargetFolderURL = $TargetFolderURL.Remove(0, 1) } $ItemName = Split-Path $SourceFile.FullName -Leaf # Replace Invalid Characters $ItemName = [RegEx]::Replace($ItemName, "[{0}]" -f ([RegEx]::Escape([String]'\*:<>?/\|')), '_') If ($SourceFile.PSIsContainer) { } else { If ($PSCmdlet.ShouldProcess($TargetFolderURL, "Adding new file '$($SourceFile.FullName)' to SharePoint folder")) { Write-Color -Text "[+] ", "Adding new file ", "($($Counter) of $($ActionsToDo["Add"].Count)) ", "'$($SourceFile.FullName)'", " to Folder ", "'$TargetFolderURL'" -Color Yellow, White, Yellow, White, Yellow, Cyan try { $null = Add-PnPFile -Path $SourceFile.FullName -Folder $TargetFolderURL -Values @{"Modified" = $SourceFile.LastUpdated.ToLocalTime() } -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Error adding file ", "'$($SourceFile.FullName)'", " to Folder ", "'$TargetFolderURL'", ". Error: ", $_.Exception.Message -Color Yellow, White, Yellow, White, Yellow, Red } } } $Counter++ } $Counter = 1 foreach ($SourceFile in $ActionsToDo["Update"] | Sort-Object TargetItemURL) { # Calculate Target Folder URL for the file $TargetFolderURL = (Split-Path $SourceFile.TargetItemURL -Parent).Replace("\", "/") If ($TargetFolderURL.StartsWith("/")) { $TargetFolderURL = $TargetFolderURL.Remove(0, 1) } $ItemName = Split-Path $SourceFile.FullName -Leaf # Replace Invalid Characters $ItemName = [RegEx]::Replace($ItemName, "[{0}]" -f ([RegEx]::Escape([String]'\*:<>?/\|')), '_') If ($SourceFile.PSIsContainer) { } else { If ($PSCmdlet.ShouldProcess($TargetFolderURL, "Updating file '$($SourceFile.FullName)' to SharePoint folder")) { Write-Color -Text "[+] ", "Updating file ", "($($Counter) of $($ActionsToDo["Update"].Count)) ", "'$($SourceFile.FullName)'", " to Folder ", "'$TargetFolderURL'" -Color Yellow, White, Yellow, White, Yellow, Cyan try { $null = Add-PnPFile -Path $SourceFile.FullName -Folder $TargetFolderURL -Values @{"Modified" = $SourceFile.LastUpdated.ToLocalTime() } -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Error updating file ", "'$($SourceFile.FullName)'", " to Folder ", "'$TargetFolderURL'", ". Error: ", $_.Exception.Message -Color Yellow, White, Yellow, White, Yellow, Red } } } $Counter++ } } function Get-FilesLocal { [CmdletBinding()] param( [Array] $SourceFileList, [string] $SourceFolderPath, [string] $Include, [string] $TargetFolderSiteRelativeURL ) if ($SourceFileList) { $SourceDirectoryPath = $null # Lets get all files from the source folder [Array] $SourceItems = foreach ($Item in $SourceFileList) { # We need to find the shortest path to the files $TempSourceDirectoryPath = [io.path]::GetDirectoryName($Item) if ($null -eq $SourceDirectoryPath) { $SourceDirectoryPath = $TempSourceDirectoryPath } elseif ($SourceDirectoryPath -ne $TempSourceDirectoryPath) { if ($TempSourceDirectoryPath.Length -lt $SourceDirectoryPath.Length) { $SourceDirectoryPath = $TempSourceDirectoryPath } } try { Get-Item -Path $Item -ErrorAction Stop Get-Item -Path $TempSourceDirectoryPath -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Unable to get file '$Item' from the source file list. Make sure the path is correct and you have permissions to access it." -Color Yellow, Red Write-Color -Text "[e] ", "Error: ", $_.Exception.Message -Color Yellow, Red return } } if ($SourceItems.Count -eq 0) { Write-Color -Text "[e] ", "No files found in the source file list. Please make sure the list is not empty." -Color Yellow, Red return } } else { $SourceDirectoryPath = $SourceFolderPath # Lets get all files from the source folder $getChildItemSplat = @{ Path = $SourceFolderPath Recurse = $true } if ($Include) { $getChildItemSplat["Include"] = $Include } try { $SourceItems = @( Get-ChildItem -Directory -Path $SourceFolderPath -Recurse -ErrorAction Stop Get-ChildItem @getChildItemSplat -ErrorAction Stop ) } catch { Write-Color -Text "[e] ", "Unable to get files from the source folder. Make sure the path is correct and you have permissions to access it." -Color Yellow, Red Write-Color -Text "[e] ", "Error: ", $_.Exception.Message -Color Yellow, Red return } } $SourceFilesCount = 0 $SourceDirectoryCount = 0 [Array] $Source = foreach ($File in $SourceItems | Sort-Object -Unique -Property FullName) { # Dates are not the same as in SharePoint, so we need to convert them to UTC # And make sure we don't add miliseconds, as it will cause issues with comparison $Date = $File.LastWriteTimeUtc [PSCustomObject] @{ FullName = $File.FullName PSIsContainer = $File.PSIsContainer TargetItemURL = $File.FullName.Replace($SourceDirectoryPath, $TargetFolderSiteRelativeURL).Replace("\", "/") LastUpdated = [datetime]::new($Date.Year, $Date.Month, $Date.Day, $Date.Hour, $Date.Minute, $Date.Second) } if (-not $File.PSIsContainer) { $SourceFilesCount++ } else { $SourceDirectoryCount++ } } [ordered] @{ Source = $Source SourceFilesCount = $SourceFilesCount SourceDirectoryCount = $SourceDirectoryCount SourceDirectoryPath = $SourceDirectoryPath } } Function Remove-FilesFromSharePoint { [CmdletBinding(SupportsShouldProcess)] param ( [Array] $Source, [Parameter(Mandatory)] [string] $SiteURL, [Parameter(Mandatory)] [string] $SourceFolderPath, [Parameter(Mandatory)] [string] $TargetLibraryName, $TargetFolder, [string[]] $ExcludeFromRemoval ) # Get all files on SharePoint Online $TargetFiles = Get-PnPListItem -List $TargetLibraryName -PageSize 2000 $Target = foreach ($File in $TargetFiles) { $Date = $File.FieldValues.Modified.ToUniversalTime() if ($File.FieldValues.FileRef -like "$($TargetFolder.ServerRelativeUrl)/*") { [PSCustomObject] @{ FullName = $File.FieldValues.FileRef.Replace($TargetFolder.ServerRelativeURL, $SourceFolderPath).Replace("/", "\") PSIsContainer = $File.FileSystemObjectType -eq "Folder" TargetItemURL = $File.FieldValues.FileRef.Replace($Web.ServerRelativeUrl, [string]::Empty) LastUpdated = [datetime]::new($Date.Year, $Date.Month, $Date.Day, $Date.Hour, $Date.Minute, $Date.Second) } #Write-Color -Text "[i] ", "File ", "'$($File.FieldValues.FileRef)'", " is in the target folder." -Color Yellow, White, Yellow } else { #Write-Color -Text "[!] ", "File ", "'$($File.FieldValues.FileRef)'", " is not in the target folder. Skipping." -Color Yellow, White, Yellow, Red } } # Compare source/target and remove files that are not in the source # Ignore LastUpdated as it doesn't matter - the file either exists or it doesn't $FilesDiff = Compare-Object -ReferenceObject $Source -DifferenceObject $Target -Property FullName, PSIsContainer, TargetItemURL #, LastUpdated [Array] $TargetDelta = foreach ($File in $FilesDiff) { If ($File.SideIndicator -eq "=>") { $File } } If ($TargetDelta.Count -gt 0) { Write-Color -Text "[information] ", "Found ", "$($TargetDelta.Count)", " differences in the Target. Removal is required." -Color Yellow, White, Yellow, White, Yellow, Red $Counter = 1 :topLoop foreach ($TargetFile in $TargetDelta | Sort-Object TargetItemURL -Descending) { If ($TargetFile.PSIsContainer) { $Folder = Get-PnPFolder -Url $TargetFile.TargetItemURL -ErrorAction SilentlyContinue If ($Null -ne $Folder -and $Folder.Items.Count -eq 0) { if ($ExcludeFromRemoval) { foreach ($Exclude in $ExcludeFromRemoval) { If ($TargetFile.TargetItemURL -like $Exclude) { Write-Color -Text "[!] ", "Folder ", "'$($TargetFile.TargetItemURL)'", " is excluded from removal." -Color Yellow, White, Yellow, Red Continue topLoop } } } If ($PSCmdlet.ShouldProcess($TargetFile.TargetItemURL, "Removing folder from SharePoint")) { Write-Color -Text "[-] ", "Removing Item ", "($($Counter) of $($TargetDelta.Count)) ", "'$($TargetFile.TargetItemURL)'" -Color Red, White, Yellow, Red try { $null = $Folder.Recycle() Invoke-PnPQuery } catch { Write-Color -Text "[!] ", "Failed to remove folder ", "'$($TargetFile.TargetItemURL)'", ". Error: ", $_.Exception.Message -Color Yellow, White, Yellow, Red } } } else { Write-Color -Text "[!] ", "Folder ", "'$($TargetFile.TargetItemURL)'", " is not empty. Skipping." -Color Yellow, White, Yellow, Red } } else { $File = Get-PnPFile -Url $TargetFile.TargetItemURL -ErrorAction SilentlyContinue If ($Null -ne $File) { if ($ExcludeFromRemoval) { foreach ($Exclude in $ExcludeFromRemoval) { If ($TargetFile.TargetItemURL -like $Exclude) { Write-Color -Text "[!] ", "File ", "'$($TargetFile.TargetItemURL)'", " is excluded from removal." -Color Yellow, White, Yellow, Red Continue topLoop } } } If ($PSCmdlet.ShouldProcess($TargetFile.TargetItemURL, "Removing file from SharePoint")) { Write-Color -Text "[-] ", "Removing Item ", "($($Counter) of $($TargetDelta.Count)) ", "'$($TargetFile.TargetItemURL)'" -Color Red, White, Yellow, Red try { Remove-PnPFile -SiteRelativeUrl $TargetFile.TargetItemURL -Force -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Failed to remove file ", "'$($TargetFile.TargetItemURL)'", ". Error: ", $_.Exception.Message -Color Yellow, White, Yellow, Red } } } } $Counter++ } } } Function Sync-FilesToSharePoint { <# .SYNOPSIS Synchronizes files from local folder to SharePoint Online library .DESCRIPTION Synchronizes files from local folder to SharePoint Online library Provides an easy way to keep local folder in sync with SharePoint Online library - Deleting content on local folder will delete it on SharePoint Online - Adding content to local folder will add it to SharePoint Online - Updating content on local folder will update it on SharePoint Online - Deleting content on SharePoint Online will trigger reupload from local folder .PARAMETER SiteURL Site URL where the library is located .PARAMETER SourceFolderPath Local folder path to synchronize .PARAMETER SourceFileList List of files to synchronize. If this is used, then SourceFolderPath is ignored This is useful when you want to synchronize specific files only .PARAMETER TargetLibraryName Name of the library to synchronize to without site url .PARAMETER LogPath Path to log file where all actions will be logged .PARAMETER LogMaximum Maximum number of log files to keep. If 0 then unlimited. Default unlimited. Please keep in mind that this will only work if the logs are in the dedicated folder. If you use the same folder as the script, then logging deletion will be disabled. .PARAMETER LogShowTime Show time in console output. Default $false. Logs will always have time. .PARAMETER LogTimeFormat Time format to use in log file. Default "yyyy-MM-dd HH:mm:ss" .PARAMETER Include Include filter for files. Default "*.*" .PARAMETER ExcludeFromRemoval List of files/folders to exclude from removal. Default $null .PARAMETER SkipRemoval Skip removal of files/folders from SharePoint Online. Default $false .EXAMPLE $Url = 'https://yoursharepoint.sharepoint.com/sites/TheDashboard' $ClientID = '438511c4' # Temp SharePoint App $TenantID = 'ceb371f6' Connect-PnPOnline -Url $Url -ClientId $ClientID -Thumbprint '2EC7C86E1AF0E434E93DE3EAC' -Tenant $TenantID $syncFiles = @{ SiteURL = 'https://yoursharepoint.sharepoint.com/sites/TheDashboard' SourceFolderPath = "C:\Support\GitHub\TheDashboard\Examples\Reports" TargetLibraryName = "Shared Documents" LogPath = "$PSScriptRoot\Logs\Sync-FilesToSharePoint-$($(Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log" LogMaximum = 5 Include = "*.aspx" } Sync-FilesToSharePoint @syncFiles -WhatIf .NOTES General notes #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'SourceFolder')] param ( [Parameter(Mandatory)][string] $SiteURL, [Parameter(Mandatory, ParameterSetName = 'SourceFolder')][string] $SourceFolderPath, [Parameter(Mandatory, ParameterSetName = 'SourceFileList')][Array] $SourceFileList, [Parameter(Mandatory)][string] $TargetLibraryName, [string] $LogPath, [int] $LogMaximum, [switch] $LogShowTime, [string] $LogTimeFormat, [string] $Include, [string[]] $ExcludeFromRemoval, [switch] $SkipRemoval ) Set-LoggingCapabilities -LogPath $LogPath -LogMaximum $LogMaximum -ShowTime:$LogShowTime -TimeFormat $LogTimeFormat -ScriptPath $MyInvocation.ScriptName Write-Color -Text "[i] ", "Starting synchronization of files from ", $SourceFolderPath, " to ", $SiteUrl -Color Yellow, White, Yellow, White, Green Write-Color -Text "[i] ", "Target library: ", $TargetLibraryName -Color Yellow, White, Green # Connect to SharePoint Online try { $Web = Get-PnPWeb -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Unable to connect to SharePoint Online. Please make sure you are connected to the Internet and that you have permissions to the site." -Color Yellow, Red Write-Color -Text "[e] ", "Error: ", $_.Exception.Message -Color Yellow, Red return } try { $Library = Get-PnPList -Identity $TargetLibraryName -Includes RootFolder -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Unable to get list of libraries on SharePoint Online. Make sure that you have permissions to the site." -Color Yellow, Red Write-Color -Text "[e] ", "Error: ", $_.Exception.Message -Color Yellow, Red return } $TargetFolder = $null if ($TargetLibraryName -like "*/*") { $Path = $TargetLibraryName -split "/" $TargetLibraryPath = $Path[0] $ListOfSharePointFolders = Get-PnPFolderItem -ItemType Folder -Recursive -FolderSiteRelativeUrl $TargetLibraryPath #| Select-Object Name, ServerRelativeUrl, ItemCount foreach ($Folder in $ListOfSharePointFolders) { $FolderFound = $Folder.ServerRelativeUrl.Replace($Web.ServerRelativeURL, [string]::Empty) if ($FolderFound.TrimStart("/") -eq $TargetLibraryName.TrimStart("/")) { $TargetFolder = $Folder break } } } else { $TargetFolder = $Library.RootFolder } if (-not $TargetFolder) { Write-Color -Text "[e] ", "Unable to find folder ", $TargetLibraryName, " in the library. Please make sure the folder exists." -Color Yellow, Red return } # Get the site relative path of the target folder If ($web.ServerRelativeURL -eq "/") { $TargetFolderSiteRelativeURL = $TargetFolder.ServerRelativeUrl } Else { $TargetFolderSiteRelativeURL = $TargetFolder.ServerRelativeURL.Replace($Web.ServerRelativeUrl, [string]::Empty) } $FilesLocalOutput = Get-FilesLocal -SourceFileList $SourceFileList -SourceFolderPath $SourceFolderPath -Include $Include -TargetFolderSiteRelativeURL $TargetFolderSiteRelativeURL [Array] $Source = $FilesLocalOutput.Source [string] $SourceDirectoryPath = $FilesLocalOutput.SourceDirectoryPath Write-Color -Text "[i] ", "Total items (files) in source: ", "$($FilesLocalOutput.SourceFilesCount)" -Color Yellow, White, Green Write-Color -Text "[i] ", "Total items (folders) in source: ", "$($FilesLocalOutput.SourceDirectoryCount)" -Color Yellow, White, Green #Write-Color -Text "[i] ", "Total items in target: ", "$($TargetFolder.Itemcount)" -Color Yellow, White, Green Write-Color -Text "[i] ", "Starting processing files/folders to SharePoint ", $SiteUrl -Color Yellow, White, Green # Upload files to SharePoint $exportFilesToSharePointSplat = @{ Source = $Source SourceFolderPath = $SourceDirectoryPath TargetLibraryName = $TargetLibraryName TargetFolder = $TargetFolder WhatIf = $WhatIfPreference } Export-FilesToSharePoint @exportFilesToSharePointSplat if (-not $SkipRemoval) { Write-Color -Text "[i] ", "Starting removal of files/folders from SharePoint ", $SiteUrl -Color Yellow, White, Green # Remove files from SharePoint that are no longer in the source folder $removeFileShareDeltaInSPOSplat = @{ Source = $Source SiteURL = $SiteURL SourceFolderPath = $SourceDirectoryPath TargetLibraryName = $TargetLibraryName TargetFolder = $TargetFolder WhatIf = $WhatIfPreference ExcludeFromRemoval = $ExcludeFromRemoval } Remove-FilesFromSharePoint @removeFileShareDeltaInSPOSplat } else { Write-Color -Text "[i] ", "Skipping removal of files/folders from SharePoint as requested", $SiteUrl -Color Yellow, White, Green } Write-Color -Text "[i] ", "Finished synchronization of files from ", $SourceFolderPath, " to ", $SiteUrl -Color Yellow, White, Yellow, White, Green } # Export functions and aliases as required Export-ModuleMember -Function @('Sync-FilesToSharePoint') -Alias @() # SIG # Begin signature block # MIItqwYJKoZIhvcNAQcCoIItnDCCLZgCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCD6H4q6i77uvw1D # lztX6NcHPYCsIKr2+3GoCYLMAiy13KCCJq4wggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggWQMIIDeKADAgECAhAFmxtXno4hMuI5B72nd3VcMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/mkHNo3rvkXUo8MCIw # aTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/zG6Q4FutWxpdtHauyefLK # EdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZanMylNEQRBAu34LzB4Tm # dDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7sWxq868nPzaw0QF+xembu # d8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL2pNe3I6PgNq2kZhAkHnD # eMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfbBHMqbpEBfCFM1LyuGwN1 # XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3JFxGj2T3wWmIdph2PVld # QnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3cAORFJYm2mkQZK37AlLTS # YW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqxYxhElRp2Yn72gLD76GSm # M9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0viastkF13nqsX40/ybzT # QRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aLT8LWRV+dIPyhHsXAj6Kx # fgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD # VR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwPTzANBgkq # hkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNkaA9Wz3eucPn9mkqZucl4 # XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjSPMFDQK4dUPVS/JA7u5iZ # aWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK7VB6fWIhCoDIc2bRoAVg # X+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eBcg3AFDLvMFkuruBx8lbk # apdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp5aPNoiBB19GcZNnqJqGL # FNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msgdDDS4Dk0EIUhFQEI6FUy # 3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vriRbgjU2wGb2dVf0a1TD9u # KFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ79ARj6e/CVABRoIoqyc54 # zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5nLGbsQAe79APT0JsyQq8 # 7kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3i0objwG2J5VT6LaJbVu8 # aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0HEEcRrYc9B9F1vM/zZn4w # ggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1 # c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqG # SIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbS # g9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9 # /UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXn # HwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0 # VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4f # sbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40Nj # gHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0 # QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvv # mz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T # /jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk # 42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5r # mQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4E # FgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5n # P+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcG # CCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu # Y29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8v # Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNV # HSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIB # AH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxp # wc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIl # zpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQ # cAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfe # Kuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+j # Sbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJsh # IUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6 # OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDw # N7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR # 81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2 # VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIGsDCCBJigAwIBAgIQ # CK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEV # MBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t # MSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAw # MDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT # aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+k # jmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9 # NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9 # URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegY # E2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS # 4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJa # wv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+w # c86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eR # Gv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF2 # 3r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCK # ZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhEC # AwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2 # O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9P # MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcB # AQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggr # BgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1 # c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGln # aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAH # BgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6 # mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/ # SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzY # gBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9 # kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ # 9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAew # Q3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5Lm # Tl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HA # SIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xr # y7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhR # ILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFu # v2jiJmCG6sivqf6UHedjGzqGVnhOMIIGvDCCBKSgAwIBAgIQC65mvFq6f5WHxvnp # BOMzBDANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5 # NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTI0MDkyNjAwMDAwMFoXDTM1MTEy # NTIzNTk1OVowQjELMAkGA1UEBhMCVVMxETAPBgNVBAoTCERpZ2lDZXJ0MSAwHgYD # VQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyNDCCAiIwDQYJKoZIhvcNAQEBBQAD # ggIPADCCAgoCggIBAL5qc5/2lSGrljC6W23mWaO16P2RHxjEiDtqmeOlwf0KMCBD # Er4IxHRGd7+L660x5XltSVhhK64zi9CeC9B6lUdXM0s71EOcRe8+CEJp+3R2O8oo # 76EO7o5tLuslxdr9Qq82aKcpA9O//X6QE+AcaU/byaCagLD/GLoUb35SfWHh43rO # H3bpLEx7pZ7avVnpUVmPvkxT8c2a2yC0WMp8hMu60tZR0ChaV76Nhnj37DEYTX9R # eNZ8hIOYe4jl7/r419CvEYVIrH6sN00yx49boUuumF9i2T8UuKGn9966fR5X6kgX # j3o5WHhHVO+NBikDO0mlUh902wS/Eeh8F/UFaRp1z5SnROHwSJ+QQRZ1fisD8UTV # DSupWJNstVkiqLq+ISTdEjJKGjVfIcsgA4l9cbk8Smlzddh4EfvFrpVNnes4c16J # idj5XiPVdsn5n10jxmGpxoMc6iPkoaDhi6JjHd5ibfdp5uzIXp4P0wXkgNs+CO/C # acBqU0R4k+8h6gYldp4FCMgrXdKWfM4N0u25OEAuEa3JyidxW48jwBqIJqImd93N # Rxvd1aepSeNeREXAu2xUDEW8aqzFQDYmr9ZONuc2MhTMizchNULpUEoA6Vva7b1X # CB+1rxvbKmLqfY/M/SdV6mwWTyeVy5Z/JkvMFpnQy5wR14GJcv6dQ4aEKOX5AgMB # AAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAWBgNVHSUB # Af8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0OBBYEFJ9X # LAN3DigVkGalY17uT5IfdqBbMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwz # LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEyNTZUaW1l # U3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUFBzABhhho # dHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6Ly9jYWNl # cnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEyNTZU # aW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAD2tHh92mVvjOIQS # R9lDkfYR25tOCB3RKE/P09x7gUsmXqt40ouRl3lj+8QioVYq3igpwrPvBmZdrlWB # b0HvqT00nFSXgmUrDKNSQqGTdpjHsPy+LaalTW0qVjvUBhcHzBMutB6HzeledbDC # zFzUy34VarPnvIWrqVogK0qM8gJhh/+qDEAIdO/KkYesLyTVOoJ4eTq7gj9UFAL1 # UruJKlTnCVaM2UeUUW/8z3fvjxhN6hdT98Vr2FYlCS7Mbb4Hv5swO+aAXxWUm3Wp # ByXtgVQxiBlTVYzqfLDbe9PpBKDBfk+rabTFDZXoUke7zPgtd7/fvWTlCs30VAGE # sshJmLbJ6ZbQ/xll/HjO9JbNVekBv2Tgem+mLptR7yIrpaidRJXrI+UzB6vAlk/8 # a1u7cIqV0yef4uaZFORNekUgQHTqddmsPCEIYQP7xGxZBIhdmm4bhYsVA6G2WgNF # YagLDBzpmk9104WQzYuVNsxyoVLObhx3RugaEGru+SojW4dHPoWrUhftNpFC5H7Q # EY7MhKRyrBe7ucykW7eaCuWBsBb4HOKRFVDcrZgdwaSIqMDiCLg4D+TPVgKx2EgE # deoHNHT9l3ZDBD+XgbF+23/zBjeCtxz+dL/9NWR6P2eZRi7zcEO1xwcdcqJsyz/J # ceENc2Sg8h3KeFUCS7tpFk7CrDqkMIIHXzCCBUegAwIBAgIQB8JSdCgUotar/iTq # F+XdLjANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT # aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMB4XDTIzMDQxNjAwMDAwMFoX # DTI2MDcwNjIzNTk1OVowZzELMAkGA1UEBhMCUEwxEjAQBgNVBAcMCU1pa2/FgsOz # dzEhMB8GA1UECgwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMSEwHwYDVQQDDBhQ # cnplbXlzxYJhdyBLxYJ5cyBFVk9URUMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw # ggIKAoICAQCUmgeXMQtIaKaSkKvbAt8GFZJ1ywOH8SwxlTus4McyrWmVOrRBVRQA # 8ApF9FaeobwmkZxvkxQTFLHKm+8knwomEUslca8CqSOI0YwELv5EwTVEh0C/Daeh # vxo6tkmNPF9/SP1KC3c0l1vO+M7vdNVGKQIQrhxq7EG0iezBZOAiukNdGVXRYOLn # 47V3qL5PwG/ou2alJ/vifIDad81qFb+QkUh02Jo24SMjWdKDytdrMXi0235CN4Rr # W+8gjfRJ+fKKjgMImbuceCsi9Iv1a66bUc9anAemObT4mF5U/yQBgAuAo3+jVB8w # iUd87kUQO0zJCF8vq2YrVOz8OJmMX8ggIsEEUZ3CZKD0hVc3dm7cWSAw8/FNzGNP # lAaIxzXX9qeD0EgaCLRkItA3t3eQW+IAXyS/9ZnnpFUoDvQGbK+Q4/bP0ib98XLf # QpxVGRu0cCV0Ng77DIkRF+IyR1PcwVAq+OzVU3vKeo25v/rntiXCmCxiW4oHYO28 # eSQ/eIAcnii+3uKDNZrI15P7VxDrkUIc6FtiSvOhwc3AzY+vEfivUkFKRqwvSSr4 # fCrrkk7z2Qe72Zwlw2EDRVHyy0fUVGO9QMuh6E3RwnJL96ip0alcmhKABGoIqSW0 # 5nXdCUbkXmhPCTT5naQDuZ1UkAXbZPShKjbPwzdXP2b8I9nQ89VSgQIDAQABo4IC # AzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE # FHrxaiVZuDJxxEk15bLoMuFI5233MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK # BggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEz # ODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwPgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3 # dy5kaWdpY2VydC5jb20vQ1BTMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcw # AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8v # Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmlu # Z1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEB # CwUAA4ICAQC3EeHXUPhpe31K2DL43Hfh6qkvBHyR1RlD9lVIklcRCR50ZHzoWs6E # BlTFyohvkpclVCuRdQW33tS6vtKPOucpDDv4wsA+6zkJYI8fHouW6Tqa1W47YSrc # 5AOShIcJ9+NpNbKNGih3doSlcio2mUKCX5I/ZrzJBkQpJ0kYha/pUST2CbE3JroJ # f2vQWGUiI+J3LdiPNHmhO1l+zaQkSxv0cVDETMfQGZKKRVESZ6Fg61b0djvQSx51 # 0MdbxtKMjvS3ZtAytqnQHk1ipP+Rg+M5lFHrSkUlnpGa+f3nuQhxDb7N9E8hUVev # xALTrFifg8zhslVRH5/Df/CxlMKXC7op30/AyQsOQxHW1uNx3tG1DMgizpwBasrx # h6wa7iaA+Lp07q1I92eLhrYbtw3xC2vNIGdMdN7nd76yMIjdYnAn7r38wwtaJ3KY # D0QTl77EB8u/5cCs3ShZdDdyg4K7NoJl8iEHrbqtooAHOMLiJpiL2i9Yn8kQMB6/ # Q6RMO3IUPLuycB9o6DNiwQHf6Jt5oW7P09k5NxxBEmksxwNbmZvNQ65Zn3exUAKq # G+x31Egz5IZ4U/jPzRalElEIpS0rgrVg8R8pEOhd95mEzp5WERKFyXhe6nB6bSYH # v8clLAV0iMku308rpfjMiQkqS3LLzfUJ5OHqtKKQNMLxz9z185UCszGCBlMwggZP # AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEw # PwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2 # IFNIQTM4NCAyMDIxIENBMQIQB8JSdCgUotar/iTqF+XdLjANBglghkgBZQMEAgEF # AKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgor # BgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3 # DQEJBDEiBCDltJZlfd3e206eL5mDlIIj5Ghps/m7blP7r9MteeltmTANBgkqhkiG # 9w0BAQEFAASCAgAhSL3+g3IMu6FYi3/K6QPQ7LPhVSP3QGCAYqIB8HIKSv8JAvyD # MUoiZgBTVa1ArJ8WiPgBU0dqRHuKnp55Ff31AFfGqgIHJgo9jZMHkUqTome8VqXr # krzoJouK4ERLJefucotWGLo9oBwHuyRU5cdECFlFDm7rzov4FuJWfSU5cq6iSPco # hNi6RCdQYps/GNyCJZIW9VcE7B2Y/zhroklS3Yi5qFrl0wBEH+6mMi6DCYwl/HjU # X58X3zJdNQeQmzf/mZyomrxsBMCfISAkDc9YtYcRwOQJ+vY/e9Yo8O3GxfsgWTbZ # 6AKZ/R0qAFNK4Ei7jTpPLXrPC2apSav2jsUH68sCmmmfglVkZbVdiDbijQhfL4x7 # u2zKA62Zei/KpjXsnc8BFjp6iOr3+LRq4rBS7E4IqduEocPMeR4DakP14XTi2hqR # r0b5CqZCNKdeoVqaOqaOBHkLldnkkqBejMPgulaoelfom/cA/q4NPpUieeNTaZFV # O6s08JzzfydzUo3KVnyGQU10zZ3yUHq73H7MoabbpMUgQHeTem2XDD3Pq0NAMXnW # O2D6tcWVQe6uvSZQzsiQSFRqP4gGTSJbhtMsD2IFKp95Khno0Rwlr6tD0PxtgVOu # bxGsbMZFu51HI6R3m+eqOJzBbHFLJ6pzTaRvCis+RXeF1+vWOgcxHphrH6GCAyAw # ggMcBgkqhkiG9w0BCQYxggMNMIIDCQIBATB3MGMxCzAJBgNVBAYTAlVTMRcwFQYD # VQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBH # NCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAuuZrxaun+Vh8b56QTj # MwQwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNTAxMjAxOTQzMjZaMC8GCSqGSIb3DQEJBDEiBCC5i6KV # TfGR4YS1DGV5p5s5VIEmzrQgrRKVPCAwSgm+ODANBgkqhkiG9w0BAQEFAASCAgC8 # IJB1su63W+dfmFCPZGbAlnxqS1OKNQ+ribLPERS1S5Mjbo97QtAZAr65bJ00+Eoz # a2Mg2UoPeS/EgD9hdNO6qsDPLFJa7R8MuwL6D/YdoV7rS/B7tSMCTgPv8Z+9jBo8 # l5ROExfLGkL3lSRNTJrSozcaDsJaCPWAjVkrUunq+Msn7oyC2I6+Cv079Lo7pleS # PSFaWiOGi7IZLefA4vx2SltB05IWLeL5dHSU8lQ/wkpstSNSJsey7MSy7AId0ad/ # lHQ8zgDy9lIsugXfEXqy9MtHtEvJPokGUYWxx7swyeqLb4CtWJZRq/u51kTa+MFb # cav5BnRbZZyfNX7Rl4LNl4ogfKLF7QKAq0H8twpRHCJ58cYi5htFv/patGIxd60T # 1fe8dX885+8BLM6M1nfRTm4o/xxGQaaOP809N43VB6kqNdylNfwfBwWaSoSenCvO # 68UbGH3tXwDIAlxdsSbEAeHHNbUQPl2qsKWSUYVuzA+V/tVwJqD5xSySky/0XRg9 # L0DXgbzDQ821MSFwRIRnvUtfU2kVJJjCa/Y3xZSPOmf6fCtvObvTBCwZClbgAPMP # iUhglHAF9mNEI+xMIrNTVmqiheTr4gJoB96w66/n2oq/pLoYxwQn11bzBB/4WNYZ # ViuYhHdOUGcko41XZXq4HnHvNyQ4/xpL5N1v7X1Dsg== # SIG # End signature block |