Sonntag, 8. Dezember 2019

Testen des Word-Automation Service

Neulich kam die Frage auf, ob man den WordAutomationService von SharePoint irgendwie testen könnte.. Oder auch "direkt" starten um dabei das ULS-Log zu beobachten.

Ich habe dazu ein Skript erstellt, mit dem ein Dokument aus dem SharePoint (dieses muss existieren und in einem der unterstützten Formate vorliegen) direkt konvertiert werden kann.

<#
 .SYNOPSIS
  Tests the WordAutomationService by submitting a document and waiting for the results.

 .PARAMETER InFile
  Url (in SharePoint) that points to the document to convert
  This documents should exist.

 .PARAMETER OutFile
  Url (in SharePoint) to place the converted document
  This documents should *not* exist.

 .PARAMETER Format
  Format of the output. PDF or XPS. Default: PDF

 .EXAMPLE
  Test-WordAutomationService -InFile https://my.sp.farm/documents/word-doc.docx -OutFile https://my.sp.farm/documents/converted.pdf
  Converts https://my.sp.farm/documents/word-doc.docx to pdf and saves the result to https://my.sp.farm/documents/converted.pdf
#>

[CmdletBinding()]
[OutputType([System.Collections.Specialized.StringCollection])]
param (
  [Parameter(Mandatory=$true)]
  [string]$InFile,
  [Parameter(Mandatory=$true)]
  [string]$OutFile,
  [Parameter(Mandatory=$false)]
  [ValidateSet("pdf", "xps")]
  [string]$Format = "pdf"
)

$ErrorActionPreference="Stop"
Add-PSSnapin "Microsoft.SharePoint.PowerShell"

$tmp = [Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Word.Server")
if($tmp -eq $null) {
 throw "Could not load Microsoft.Office.Word.Server.dll. Somethong is not right..."
}
Write-Verbose "Assembly $($tmp.GetName().Name) is present and loaded"

$serviceApp = Get-SPServiceApplication | Where-Object { $_.TypeName -eq "Word Automation Services" } | Select-Object -First 1
if($null -eq $serviceApp) {
    throw "Could not find Word Automation Services Service Application. Somethong is not right..."
}
Write-Verbose "Using Service-Application $($serviceApp.Name)."
#TODO: Service app as param? what if there's more than one?!

$serviceAppProxy = Get-SPServiceApplicationProxy | Where-Object { $_.ServiceEndpointUri -eq $serviceApp.Uri.AbsoluteUri } 
Write-Verbose "Using Service-ApplicationProxy $($serviceAppProxy.Name)."

$web = $null
$url = $InFile
while ($null -eq $web) {
  if([string]::isNullOrEmpty($url)){
    throw "could not find a viable web from url: $($InFile)"
  }
  $url = (Split-Path -Path $url -Parent).Replace("\", "/")
  $web = Get-SPWeb $url -ErrorAction Ignore
}
Write-Verbose "Using web: $($web) ($($web.Url))"

$setting = New-Object "Microsoft.Office.Word.Server.Conversions.ConversionJobSettings"
$setting.OutputFormat = $Format.ToUpperInvariant()

$job = New-Object "Microsoft.Office.Word.Server.Conversions.ConversionJob" -ArgumentList @($serviceAppProxy.Id, $setting)
$job.UserToken = $web.CurrentUser.UserToken
$job.AddFile($InFile, $OutFile)
$job.Start()
Write-Verbose "Started conversion-job with id: $($job.JobId)"

$timer = Get-SPTimerJob | Where-Object { $_.TypeName -like "Word Automation Services Timer Job" } | Select-Object -First 1
Start-SPTimerJob $timer
Write-Verbose "TimerJob '$($timer.DisplayName)' started."

$status = New-Object "Microsoft.Office.Word.Server.Conversions.ConversionJobStatus" -ArgumentList @($serviceAppProxy.Id, $job.JobId, $null);
while (($status.NotStarted -ge 1) -or ($status.InProgress -ge 1)) {
    Write-Verbose "In Progress: $($status.InProgress)"
    Start-Sleep -Seconds 1
    $status.Refresh()
}

$items = $status.GetItems("Succeeded,Failed,Canceled")
Write-Verbose "Job ended. $($items.Length) items in this job."
,($items) #write them...

Das Skript erstellt einen neuen Job für den WordAutomationService, startet den entsprechenden TimerJob ("Word Automation Services Timer Job") und wartet bis die Konvertierung abgeschlossen ist. Das Ergebnis ggf. mit Fehlermeldung wird ausgegeben.

Wenn der WordAutomationService nicht läuft wie gewünscht, ist für mich immer die erste Anlaufstelle der Troubleshooting Guideline.

(Alle links beziehen sich auf SharePoint 2010 - es hat sich aber seit dem zu 2016/2019 nichts geändert.)

SQL-Alias in der ganzen SharePoint Farm

Nicht alle SharePoint-Farmen werden mit einem SQL-Alias aufgesetzt. Warum das so ist, ist mir unklar. SQL-Alias. Machen. Immer.

Ich habe letztens als "ausrede" gehört: "Das war so viel Arbeit". Okey. 5 Klicks pro Server in der Farm. Immerhin.

Das war allerdings der Anstoss dafür dass ich mir überlegte ob die Einrichtung nicht besser zu automatisieren ist. Initial hatte ich überlegt mit (Get-SPFarm).Servers zu arbeiten - das funktioniert ja aber nur, wenn die Farm schon steht - und dann könnte es für den SQL-Alias schon zu spät sein...

Dementsprechend habe ich ein Skript vorbereitet um einen SQL-Alias für eine Liste von Servern automatisiert zu setzen:

<#
  .SYNOPSIS
 Sets SQL-Aliases on multiple machines

  .PARAMETER Alias
 The Alias to use

  .PARAMETER Server
 The server to point to

  .PARAMETER Port
 The port to point to
   
  .PARAMETER Use32Bit
 set this flag to add the alias to 32bit only

  .PARAMETER Use64Bit
 set this flag to add the alias to 64bit only

  .PARAMETER Machines
 list of machines to set the alias on. Default is local

  .EXAMPLE
 Set-SqlAlias -Alias "sql1" -Server RealSqlServer 
 Sets a SQL-Alias from sql1 to point to RealSqlServer for 32 and 64Bit on the local machine only.
 
  .EXAMPLE
 Set-SqlAlias -Alias "oldServer\instance" -Server "newServer\Instance" -Use64Bit -Machines ((Get-SPFarm).Servers | ?{ $_.Role -ne "Invalid"} | Select-Object -ExpandProperty Address)
 Sets a SQL-Alias from "oldServer\instance" to point to  "newServer\Instance" for 64Bit only. This will be set on all machines of a SharePoint-Farm.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)]
    [string]$Alias,
 [Parameter(Mandatory=$true)]
    [string]$Server,
 [Parameter()]
    [int]$Port,
 [Parameter()]
    [switch]$Use32Bit,
 [Parameter()]
    [switch]$Use64Bit,
 [Parameter()]
    [string[]]$Machines
)
$ErrorActionPreference="Stop"

if((-not $Use64Bit) -and (-not $Use32Bit)){
 # default is 32 and 64
 $Use64Bit = $true
 $Use32Bit = $true
}

if($Use64Bit -and (-not [Environment]::Is64BitProcess)){
 throw "Unable to access 64Bit-Registry from a non-64Bit-Process. Use 64Bit PowerShell."
}

if($Machines -eq $null -or $Machines.Length -eq 0){
 $Machines = @($env:COMPUTERNAME)
}

$regViews = @();
if($Use32Bit) {
 $regViews += [Microsoft.Win32.RegistryView]::Registry32
}
if($Use64Bit) {
 $regViews += [Microsoft.Win32.RegistryView]::Registry64
}

$Machines | % {
 $machine = $_
 $regViews | % {
  $view = $_
  Write-Verbose "Accessing '$($machine)' for $($view)"
  try {
   $regEdit = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey("LocalMachine", $machine, $view)
  }
  catch [IO.IOException] {
   throw "Unable to connect to '$($machine)'. Error: $($_.Exception.Message)"
  }

  $key = $regEdit.OpenSubKey("SOFTWARE\Microsoft\MSSQLServer\Client\ConnectTo", $true)
  if($key -eq $null) {
   $key = $regEdit.OpenSubKey("SOFTWARE\Microsoft\MSSQLServer\Client", $true)
   if($key -eq $null) {
    throw "SQL-ClientTools not installed on $($machine)"
   }
   
   $key = $key.CreateSubKey("ConnectTo", $true)
   if($key -eq $null) {
    throw "Unable to access HKLM:SOFTWARE\Microsoft\MSSQLServer\Client\ConnectTo on '$($machine)'."
   }
  }
  
  $val = "DBMSSOCN,$($Server)"
  if($Port -gt 0) {
   $val += ",$($Port)"
  }
  $key.SetValue($Alias, $val)
  $regEdit.Close()
 }
}

Passend dazu habe ich noch ein Skript erstellt, mit dem die vorhandenen SQL-Aliase entsprechend aufgelistet werden können. (Z.B. zum Vergleichen der gesetzten Werte...)

<#
  .SYNOPSIS
 Gets SQL-Aliases on multiple machines
   
  .PARAMETER Use32Bit
 set this flag to get 32bit aliases only 

  .PARAMETER Use64Bit
 set this flag to get 64bit aliases only 

  .PARAMETER Machines
 list of machines to connect. Default is local

  .EXAMPLE
 Get-SqlAlias 
 gets all 32 and 64 Bit aliases of the local machine.
 
  .EXAMPLE
 Get-SqlAlias -Use64Bit -Machines ((Get-SPFarm).Servers | ?{ $_.Role -ne "Invalid"} | Select-Object -ExpandProperty Address)
 Gets all 64 Bit aliases for all machines of a SharePoint-Farm.
#>
[CmdletBinding()]
param(
 [Parameter()]
    [switch]$Use32Bit,
 [Parameter()]
    [switch]$Use64Bit,
 [Parameter()]
    [string[]]$Machines
)
$ErrorActionPreference="Stop"

if((-not $Use64Bit) -and (-not $Use32Bit)){
 # default is 32 and 64
 $Use64Bit = $true
 $Use32Bit = $true
}

if($Use64Bit -and (-not [Environment]::Is64BitProcess)){
 throw "Unable to access 64Bit-Registry from a non-64Bit-Process. Use 64Bit PowerShell."
}

if($Machines -eq $null -or $Machines.Length -eq 0){
 $Machines = @($env:COMPUTERNAME)
}

$regViews = @();
if($Use32Bit) {
 $regViews += [Microsoft.Win32.RegistryView]::Registry32
}
if($Use64Bit) {
 $regViews += [Microsoft.Win32.RegistryView]::Registry64
}

$Machines | % {
 $machine = $_
 $regViews | % {
  $view = $_
  Write-Verbose "Accessing '$($machine)' for $($view)"
  try {
   $regEdit = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey("LocalMachine", $machine, $view)
  }
  catch [IO.IOException] {
   throw "Unable to connect to '$($machine)'. Error: $($_.Exception.Message)"
  }
  
  $key = $regEdit.OpenSubKey("SOFTWARE\Microsoft\MSSQLServer\Client\ConnectTo", $true)
  if($key -eq $null) {
   $regEdit.Close()
   return
  }
  
  $names = $key.GetValueNames()
  $names | % {
   $parts = $key.GetValue($_).Split(",")
   $server = $parts[1]
   $port = ""
   if($parts.Length -gt 2) {
    $port = $parts[2]
   }
   [pscustomobject]@{
    Machine = $machine;
    RegistryView = $view;
    Alias = $_;
    Server = $server;
    Port = $port
   }
  }
  
  $regEdit.Close()
 }
}

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.