Sonntag, 3. November 2019

ErgebnisTypen automatisiert aktualisieren

Wenn im Rahmen eines Projektes Anzeigevorlagen (DisplayTemplates) und Ergebnistypen (ResultTypes) erstellt werden, müssen die Ergebnistypen immer wieder aktualisiert werden.
Die entsprechende Meldung stellt sich so dar:

Viele Projekte erschweren sich das Leben, indem dieser Schritt immer manuell vollzogen werden muss - auch wenn der Rest automatisiert ist.

Das aktualisieren ist eigentlich (on premises) kein Hexenwerk: Set-SPEnterpriseSearchResultItemType kann verwendet werden um die DisplayProperties zu aktualisieren.

Ich habe ein Skript erstellt, mit dem alle ResultTypes automatisch aktualisiert werden können:

<#  
.SYNOPSIS  
    Updates the DisplayProperties of the ResultItemTypes.
.Notes
    This script currently only updates Site-Level-ResultTypes.
    
.Example
    Update-ResultItemTypesProperties -SiteUrl https://my.sharepoint.com/sites/search
    Updates DisplayProperties of all non builtin ResultTypes
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)]
    [string]$SiteUrl
)
$ErrorActionPreference="Stop"

$site = Get-SPSite $SiteUrl     
$ssa = Get-SPEnterpriseSearchServiceApplication
$owner = Get-SPEnterpriseSearchOwner -Level SPSite -SPWeb $site.RootWeb
# TODO: Ssa and SPWeb are also possible locations for owner...

$masterPageGallery = $site.GetCatalog("MasterPageCatalog")

$resultTypes = Get-SPEnterpriseSearchResultItemType -Owner $owner -SearchApplication $ssa | ? { -not $_.Builtin }
$resultTypes | % {
    Write-Verbose "Processing: $($_.Name)"
    $displayTemplateFileName = Split-Path -Path $_.DisplayTemplateUrl -Leaf
    $displayTemplate = $masterPageGallery.Items | ? {$_.Name -eq $displayTemplateFileName}
    $propPairs = $displayTemplate[[guid]"a0dd6c22-0988-453e-b3e2-77479dc9f014"];
    $propsString = ""
    if(-not [string]::IsNullOrWhiteSpace($propPairs)){
        $props = $propPairs.Split(",") | %{ $_.Split(":") | Select -Last 1 } | %{ $_.Trim("'") }
        $propsString = [string]::Join(",", $props)    
    }

    if($_.DisplayProperties -eq $propsString) {
        # match
        return;
    }

    Set-SPEnterpriseSearchResultItemType  -Identity $_ -Owner $owner -SearchApplication $ssa -DisplayProperties $propsString
}

Mikael Svenson hat dazu auch einen c# Schnipsel veröffentlicht, der die entsprechende Aktualisierung durchführt. Dieser kann z.B. in einem FeatureReceiver verwendet werden.

Freitag, 25. Oktober 2019

SharePoint Benutzer-Spracheinstellungen mit PowerShell setzen.

Ich falle immer mal wieder darüber: Wie kann man für alle Benutzer die Spracheinstellungen per PowerShell setzen?

Die Frage ist schnell beantwortet und eine kurze Suche führt meistens etwas wie das folgende zutage:

[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Server")
[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Server.UserProfiles")

$ctx =  [Microsoft.Office.Server.ServerContext]::GetContext((Get-SPWebApplication | Select -First 1))
$upm = new-object Microsoft.Office.Server.UserProfiles.UserProfileManager -ArgumentList @($ctx)
$lang = "de-DE,en-US"

$enum = $upm.GetEnumerator()
while ($enum.MoveNext()) {
  $up = $enum.Current
  write-host "$($up.DisplayName) ($($up.AccountName))"

  $up["SPS-MUILanguages"].Value = $lang
  $up["SPS-ContentLanguages"].Value= $lang
  $up.Commit()
}

Meistens sieht dann in der Zentraladministration alles gut aus:


Aber für "einige" (oder auch alle...) Benutzer erscheinen die Werte dann nicht in den MySites:


Das "Geheimnis" ist ein TimerJob ("User Profile Service Application_LanguageAndRegionSync") der allerdings nur richtig arbeitet, wenn die eigenschaft 'SPS-RegionalSettings-Initialized' auf true gesetzt ist. Im Standard läuft dieser Job jede Minute - ein Start, direkt nach der Anpassung kann aber auch nicht schaden.
Das Finale Skript muss also so aussehen:

[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Server") | out-null
[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Server.UserProfiles") | out-null

$ctx =  [Microsoft.Office.Server.ServerContext]::GetContext((Get-SPWebApplication | Select -First 1))
$upm = new-object Microsoft.Office.Server.UserProfiles.UserProfileManager -ArgumentList @($ctx)
$lang = "de-DE,en-US"

$enum = $upm.GetEnumerator()
while ($enum.MoveNext()) {
  $up = $enum.Current
  write-host "$($up.DisplayName) ($($up.AccountName))"

  $up["SPS-MUILanguages"].Value = $lang
  $up["SPS-ContentLanguages"].Value= $lang
  $up["SPS-RegionalSettings-Initialized"].Value = $true
  $up.Commit()
}

Get-SPTimerJob | ?{ $_.Name -like "*user*profile*languageandregion*" } | %{ $_.RunNow() }

Danach erscheinen auch die Anpassungen in der MySite:


Was gibt es noch dazu zu sagen?

  • Die Sprach-codes müssen "korrekt" sein - also "de-DE" nicht "de-de".
  • Der Text darf keine leerzeichen enthalten - also "de-DE,en-US", nicht "de-DE, en-US"

Dienstag, 13. August 2019

LightCore bindings für Quartz.Net und Topshelf

Schon vor etwas längerer Zeit habe ich Bindings erstellt um zum einen Topshelf und zum anderen Quartz.Net "besser" mit LightCore verwenden zu können.

LightCore ist ein sehr schneller und einfach zu verwendender DI-Container.

Topshelf und Quarz.Net können beide sehr gut mit DI-Containern verwendet werden, allerdings gab es keine Anbindung an LightCore.

Wenn Quartz.LightCore und Topshelf.LightCore verwendet werden sieht der code so aus:

// setup LightCore
var builder = new ContainerBuilder();
/* some fancy setup here */
var container = builder.Build();

// setup Quartz
var scheduler = await new StdSchedulerFactory()
    .GetScheduler()
    .UseLightCoreResolverJobFacotry(container);
scheduler.ScheduleJob(
    JobBuilder.Create().Build(),
    TriggerBuilder.Create().StartNow().Build());

// setup Topshelf
var host = HostFactory.Run(x =>
{
    x.UseLightCore(container); // Enable LightCore
    x.Service(s =>
    {
        s.ConstructUsingLightCore(); // Construct service using LightCore
        s.WhenStarted(tc => tc.Start());
        s.WhenStopped(tc => tc.Stop());
        /* more Topshelf code... */
    });
});

Die NuGet-Pakete finden sich unter Install-Package Topshelf.LightCore und Install-Package Quartz.LightCore

Montag, 15. April 2019

Rekursives Hochladen von Ordnern in SharePoint mit PNP

Die Frage heute beim Kunden war: Kann ich "schnell" und mit PNP (d.h. ohne server-code) einen kompletten Ordner - mit Unterordnern - in eine SharePoint-Bibliothek hochladen?

Die Antwort ist ja, aber... (nicht "einfach so")
Ich habe hier ein Skript vorbereitet, dass die Arbeit übernimmt:

  • Erstellen eines Ordners im SharePoint, falls gewünscht
  • Hochladen aller Dateien aus dem Quell-Ordner
  • Rekursive Verarbeitung aller Unterverzeichnisse
<#  
.SYNOPSIS  
    Uploads a Folder, including all Files and subfolders
.Notes
    This Cmdlet assumes you have PNP installed and Connect-PNPOnline was issued before this command.
.Example
    Add-PNPFolderRecursive -Path C:\temp -Folder /SiteAssets -NoRootFolderCreation
    Uploads all of c:\temp to /SiteAssets
.Example
    Add-PNPFolderRecursive -Path C:\temp -Folder /SiteAssets
    Creates a "temp" folder in /SiteAssets, then uploads all of c:\temp to the newly created /SiteAssets/temp
#>
[CmdletBinding()]
param(
    [Parameter(Position=0, 
        Mandatory=$true)]
    $Path,
    [Parameter(Position=1, 
        Mandatory=$true)]
    $Folder,
    [Parameter(Mandatory=$false)]
    [switch]$NoRootFolderCreation
)
$ErrorActionPreference="Stop"

$dstFolder = Get-PNPFolder $Folder
Write-Verbose "Acquired folder: $($dstFolder.ServerRelativeUrl)"

if(!$NoRootFolderCreation.IsPresent) {
    $folderName = Split-Path -Leaf $Path
    Write-Verbose "Creating target-folder $folderName"
    Add-PNPFolder -Name $folderName -Folder $dstFolder.ServerRelativeUrl
    $dstFolder = Get-PNPFolder "$($dstFolder.ServerRelativeUrl)/$($folderName)"
    Write-Verbose "Acquired folder: $($dstFolder.ServerRelativeUrl)"
}

# get all childs of "path" and upload the files...
$files = Get-ChildItem -File -Path $Path

$files | % {
    Write-Verbose "Uploading $($_.FullName)"
    Add-PNPFile -Path $_.FullName -Folder $dstFolder.ServerRelativeUrl | Out-Null
}

# recursive call subfolders
$foders = Get-ChildItem -Directory -Path $Path

$foders | %{
    Write-Verbose "Descending to Folder: $($_.FullName)"
    Invoke-Expression -Command ($PSCommandPath + " -Path $($_.FullName) -Folder $($dstFolder.ServerRelativeUrl) ")
}
Write-verbose "Done for $Path"

Achtung: Ich bin nicht sicher, wie sich das Skript verhält, wenn sehr viele und tiefe Ordnerstrukturen vorliegen - aufgrund der Rekursion im Skript selber vermute ich, dass die Performance ab einem bestimmten Punkt deutlich einbrechen wird.

Dienstag, 6. November 2018

IEnumerable-ToList() - alternative in PowerShell

Jeder weiß (oder lernt schnell), dass ein IEnumerable nicht verändert werden darf, während es durchlaufen wird.
Speziell im SharePoint-Bereich gilt also, wenn man das folgende in PowerShell versucht:

(Get-SPSite http://mein.sharepoint).AllWebs `
  | select -ExpandProperty Lists `
  | select -ExpandProperty Fields `
  | %{ $_.Update() }

Führt dies zu einer Menge von Fehlern die wie folgt aussehen:
An error occurred while enumerating through a collection: Collection was modified; enumeration operation may not execute.....

In C# würde ich einfach ToList() einstreuen. Dies würde dazu führen, dass eine flache Kopie des IEnumerable erstellt wird und in weiteren Verarbeitungen der Liste dieser Fehler nicht auftritt.
(Es führt natürlich auch dazu, dass der Enumerator an dieser Stelle vollständig durchlaufen wird, und man all die schönen Vorteile des IEnumerables (d.h. "Lazy Evaluation") verliert)

Eine schöne PowerShell-alternative zu ToList() ist im PowerShell Team Blog verfügbar:

function ToArray {
  begin {
    $output = @(); 
  }
  process {
    $output += $_; 
  }
  end {
    return ,$output; 
  }
}

Dadurch sieht der Aufruf dann wie folgt aus:

(Get-SPSite http://mein.sharepoint).AllWebs `
  | select -ExpandProperty Lists `
  | select -ExpandProperty Fields `
  | ToArray `
  | %{ $_.Update() }

und Funktioniert fehlerfrei.


Mittwoch, 15. August 2018

Papierkörbe einer WebApplication Auflisten & Löschen

​Zur "Bereinigung" einer WebApplication kann man dann- und wann mal die Paprierkörbe ansehen und-/oder löschen.

Ich habe da mal ein keines Skript dazu Vorbereitet:
<# 
.SYNOPSIS  
    List or Remove elements from RecycleBin(s) of a WebApplication 
.DESCRIPTION  
    This script lists- and optionally removes all items from all RecycleBins 
    of a WebApplication, including the End-Users (1st Stage) and 
    Administrators (2nd Stage) RecycleBins.
.NOTES  
    File Name  : Delete-Site-Recycle-Bin.ps1  
    Author     : Nils Andresen - nils.andresen@adesso.de    
.Example
    .\Empty-SPRecycleBin.ps1 -WebApp http://sp.dev/ -FirstStageCleanup RemovePermanent -SecondStageCleanup RemovePermanent
    Removes all deleted items (1st and 2nd stage) from all Sites/Webs of the WebApplication
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory=$True, HelpMessage = "Url to the WebApp")]
    [string]$WebApp,
 
    [Parameter(HelpMessage = "What to do with the 1st-stage Recycle Bins")]
    [ValidateSet("ListOnly", "MoveTo2nd", "RemovePermanent")]
    [string]$FirstStageCleanup = "ListOnly",
 
    [Parameter(HelpMessage = "What to do with the 2nd-stage Recycle Bins")]
    [ValidateSet("ListOnly", "RemovePermanent")]
    [string]$SecondStageCleanup = "ListOnly"
)
 
if((Get-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) -eq $null) {
    Add-PSSnapin Microsoft.SharePoint.PowerShell
}
 
$Global:TotalRemovedSize = 0;
 
function Format-ForPc {
[CmdletBinding()]
param(
    [Parameter(Position=0, 
        Mandatory=$true, 
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
    [int]$size
)
    if($size -lt 1MB) {
        return "{0:0.0#}KB" -f ($size / 1KB);
    } 
    if($size -lt 1GB) {
        return "{0:0.0#}MB" -f ($size / 1MB);
    } 
    if($size -lt 1TB) {
        return "{0:0.0#}GB" -f ($size / 1GB);
    } 
    return "{0:0.0#}TB" -f ($size / 1TB);
}
 
function Process-Web {
[CmdletBinding()]
param(
    [Parameter(Position=0, 
        Mandatory=$true, 
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
    [Microsoft.SharePoint.SPWeb[]]$web
)
Process {
    Write-Verbose "Accessing Web: $($web.Url)";
 
    if((-not $web.RecycleBinEnabled) -or ($web.RecycleBin.Count -lt 1)) {
        return;
    }
    $size = 0;
    $web.RecycleBin | %{ $size += $_.Size }
 
    Write-Output "Web $($web.Title) has $($web.RecycleBin.Count) items ($($size | Format-ForPc)) in Users-RecycleBin";          
         
    switch ($FirstStageCleanup) {
        "ListOnly" {
            $web.RecycleBin | %{ Write-Output "- $($_.ItemType):$($_.Title) ($($_.Size | Format-ForPc), Deleted by $($_.DeletedByName))" }
        }
        "RemovePermanent" {
            $web.RecycleBin.DeleteAll();
            $Global:TotalRemovedSize += $size;
            Write-Output "- Deleted permanently";
        }
        "MoveTo2nd" {
            $web.RecycleBin.MoveAllToSecondStage();
            Write-Output "- Moved to second stage";
        }
    }
}
}
 
function Process-Site {
[CmdletBinding()]
param(
    [Parameter(Position=0,
        Mandatory=$true, 
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
    [Microsoft.SharePoint.SPSite[]]$site
)
Process {
    Write-Verbose "Accessing Site: $($site.Url)";
 
    $secondStage = $site.RecycleBin | ? { $_.ItemState -eq [Microsoft.SharePoint.SPRecycleBinItemState]::SecondStageRecycleBin }
 
    $site.AllWebs | Process-Web
 
    if($secondStage.length -lt 1) {
        return;
    }
    $size = 0;
    $secondStage | %{ $size += $_.Size }
 
    Write-Output "Site $($site.Title) ($($site.Url)) has $($secondStage.Length) items ($($size | Format-ForPc)) in Admin-RecycleBin";          
 
    switch ($SecondStageCleanup) {
        "ListOnly" {
            $secondStage | %{ Write-Output "- $($_.ItemType):$($_.Title) ($($_.Size | Format-ForPc), Deleted by $($_.DeletedByName))" }
        }
        "RemovePermanent" {
            $secondStage | %{ $_.Delete(); }
            $Global:TotalRemovedSize += $size;
            Write-Output "- Deleted permanently";
        }
    }
}
}
 
 
$sa = Start-SPAssignment
$w = Get-SPWebApplication $WebApp -AssignmentCollection $sa;
$w.Sites | Process-Site;
Stop-SPAssignment $sa
if($SecondStageCleanup -eq "RemovePermanent" -and $FirstStageCleanup -eq "MoveTo2nd") {
    Write-Warning "The selected combination of removing from second stage and moving from first to second possibly leaves items undeleted."
}
if($Global:TotalRemovedSize -gt 0) {
    Write-Output "$($Global:TotalRemovedSize | Format-ForPc) were removed permanently.";
}

SharePoint: Auflisten aller Obejkte mit eigener Berechtigungszuweisung

​Die Frage meines Kunden letztens: "Wie kann ich alle Elemente (Listen, Items, etc.) auflisten, bei denen eigene berechtigungszuweisungen erfolgt sind?"

Meine schnelle Antwort: "Nicht möglich, OOTB!".

Mittlerweile weiß ich: In 2007 ging das - aber nur mit dem SharePoint Administrator Toolkit.

Ich habe - mit Inspiration von Mike Smith - ein Skript erstellt​, dass eben dies macht.

<#    
    .SYNOPSIS 
    List all items with broken inheritance
 
    .DESCRIPTION
    Lists all Webs, Lists, Folders and Items with broken inheritance.
    This script was heavily inspired by http://techtrainingnotes.blogspot.de/2014/07/sharepoint-powershellfind-all-broken.html
 
    .PARAMETER SiteCollectionUrl
    Url to SiteCollection
 
    .PARAMETER Site
    Site. Can be Piped in.
 
    .PARAMETER StopAtWeb
    Stop the search at Web-Level. Do not search Lists/Folders/Items.
 
    .PARAMETER StopAtList
    Stop the search at List-Level. Do not search Folders/Items.
 
    .PARAMETER StopAtFolder
    Stop the search at Folder-Level. Do not search Items.
 
    .INPUTS
    Site
 
    .OUTPUTS
    Grid of Securable | Item | Url | Parent
    where
     Securable is one of "Web", "List", "Folder" or "Item"
     Item is the Name or Title
     Url is the url to the item
     Parent is the url to the parent-item
 
 
    .EXAMPLE
    .\Get-SpBrokenInheritances.ps1 -SiteCollectionUrl http://my.lovely.site/
#>
[CmdletBinding()]
param (
    [Parameter(ParameterSetName='SiteByUrl')]
    [string]$SiteCollectionUrl,
     
    [Parameter(ParameterSetName='SiteByObject', ValueFromPipeline=$true)]
    [Microsoft.SharePoint.SPSite]$Site,
 
    [Parameter()]
    [switch]$StopAtWeb = $false,
 
    [Parameter()]
    [switch]$StopAtList = $false,
 
    [Parameter()]
    [switch]$StopAtFolder = $false
)
 
Set-StrictMode -Version Latest
$script:ErrorActionPreference = "Stop";
 
function Get-ParentUrl {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.SharePoint.SPListItem]$Item
    )
    $List = $Item.ParentList;
     
    if ($List.BaseType -eq [Microsoft.SharePoint.SPBaseType]::DocumentLibrary) {
        return "$($_.ParentList.ParentWeb.ServerRelativeUrl)/$($_.File.ParentFolder.Url)";
    } else {
        # SPListItem.Url looks like ////_.000 - I have no idea how to get the folder Url "correctly"
        $FolderUrl = $Item.Url.Substring(0, $Item.Url.LastIndexOf("/"));
        return "$($List.ParentWeb.ServerRelativeUrl)/$FolderUrl";
    }
}
 
function Get-Url {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.SharePoint.SPListItem]$Item
    )
    $List = $Item.ParentList;
    if ($List.BaseType -eq [Microsoft.SharePoint.SPBaseType]::DocumentLibrary) {
        return "$($List.ParentWeb.ServerRelativeUrl)/$($Item.Url)";
    } else {
        # e.g. /blubb/Lists/Test12/DispForm.aspx?ID=1
        return "$($Item.ParentList.DefaultDisplayFormUrl)?ID=$($Item.Id)";
    }
}
 
function Process-Lists {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.SharePoint.SPList[]]$Lists
    )
    $Folders = $Lists | Select -ExpandProperty Folders;
    $Folders | ? { $_.HasUniqueRoleAssignments } | 
        Select @{Label="Securable"; Expression={"Folder"}}, 
            @{Label="Item"; Expression={$_.Title}}, 
            @{Label="Url"; Expression={"$($_.ParentList.ParentWeb.ServerRelativeUrl)/$($_.Url)"}},
            @{Label="Parent"; Expression={"$($_.ParentList.ParentWeb.ServerRelativeUrl)/$($_.ParentList.RootFolder.Url)"}} | Write-Output
 
    if($StopAtFolder) {
        return;
    }
    $Items = $Lists | Select -ExpandProperty Items;
    $Items | ? { $_.HasUniqueRoleAssignments } | 
        Select @{Label="Securable"; Expression={"Item"}}, 
            @{Label="Item"; Expression={$_.Name}}, 
            @{Label="Url"; Expression={Get-Url -Item $_ }},
            @{Label="Parent"; Expression={Get-ParentUrl -Item $_ }} | Write-Output
}
 
function Process-Webs {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        $Webs
    )
    $Lists = $Webs | Select -ExpandProperty Lists | ? { $_.EntityTypeName -ne "PublishedFeedList" -and -not $_.Hidden }
    $Lists | ?{ $_.HasUniqueRoleAssignments } | 
        Select @{Label="Securable"; Expression={"List"}}, 
            @{Label="Item"; Expression={$_.Title}}, 
            @{Label="Url"; Expression={"$($_.ParentWeb.ServerRelativeUrl)/$($_.RootFolder.Url)"}},
            @{Label="Parent"; Expression={$_.ParentWebUrl}} | Write-Output
   
    if($StopAtList) {
        return;
    }
    Process-Lists -Lists $Lists
}
 
function Process-Site {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.SharePoint.SPSite]$Site
    )
    Write-Verbose "Process-Site $($Site.RootWeb.Title)";
    $WebGc = Start-SPAssignment;
    try {
        $Webs = $Site | Get-SPWeb -AssignmentCollection $WebGc -Limit All;
        $Webs | ?{ $_.HasUniquePerm -and $_.ParentWeb -ne $Null } | 
            Select @{Label="Securable"; Expression={"Web"}}, 
                @{Label="Item"; Expression={$_.Title}}, 
                @{Label="Url"; Expression={$_.ServerRelativeUrl}}, 
                @{Label="Parent"; Expression={$_.ParentWeb.ServerRelativeUrl}} | Write-Output
   
        if($StopAtWeb) {
            return;
        }
        Process-Webs -Webs $Webs
    } finally {
        Stop-SPAssignment $WebGc;
    }
}
 
Add-PSSnapin "Microsoft.SharePoint.PowerShell" -ErrorAction Inquire
 
$gc = Start-SPAssignment;
try{
    if(!$Site) {
        Write-Verbose "Site was neither given, nor in Pipe. Fetching Site from Url:$SiteCollectionUrl";
        if(!$SiteCollectionUrl) {
            Write-Error "Neither -Site, nor -SiteCollectionUrl was given.";
            Exit -1;
        }
        $Site = Get-SpSite -Identity $SiteCollectionUrl -AssignmentCollection $gc -ErrorAction SilentlyContinue;
        if ($Site -eq $null)
        {
            Write-Error "No SiteCollection with Identity '$SiteCollectionUrl' found. Exiting...";
            Exit -1;
        }
    }
    Process-Site -Site $Site;
} finally {
    Stop-SPAssignment $gc;
}