SharePoint Online: Location Feld und wie wird es befüllt?

Zuerst ein paar einleitende Worte. Mein Name ist Dominik und ich arbeite seit einigen Jahren mit Frank zusammen. Er hat mir die Möglichkeit geboten hier Artikel zu veröffentlichen.
Vielen Dank an dieser Stelle! 🙂

Diesmal soll es sich um das neue Location Feld in SharePoint Online drehen.
Das Feld wurde um Februar 2019 ausgerollt. Die Ankündigung fand relativ unscheinbar in der Tech Community statt:
https://techcommunity.microsoft.com/t5/sharepoint/add-location-details-to-sharepoint-data-and-content/m-p/284875

Glücklicherweise benötigt man diesmal keine PowerShell o.ä. Magie, das Hinzufügen geht einfach über „Add column“ in der UI:

Eine der Neuerungen sind die zusätzlichen Felder, die automatisch mit dem Location Feld erstellt werden, ähnlich wie beim Anzeigen zusätzlicher Felder bei Lookup Feldern:

Damit soll das klassische GeoLocation Feld komplett ersetzt werden. Das bedeutet auch, dass das Location Feld völlig anders funktioniert und einen neuen Field Type besitzt:

$conn = Connect-PnPOnline -Url "https://M365x341299.sharepoint.com/sites/Location2" -Credentials $cred -ReturnConnection
$list = Get-PnPList "Location" -Connection $conn
$conn.Context.Load($list.Fields)
$conn.Context.ExecuteQuery()
$fieldLocation = $list.Fields | Where-Object{$_.Title -eq "Location"}
$fieldLocation | fl SchemaXml, TypedObject
​
SchemaXml   : <Field DisplayName="Location" Format="Dropdown" Name="Location" Title="Location" Type="Location" ID="{63de745a-dc11-4462-b766-1b818dd91953}" SourceID="{83ea0a46-4968-4cb0-a4c0-0023b0035e58}" StaticName="Location" ColName="ntext2" RowOrdinal="0" />
TypedObject : Microsoft.SharePoint.Client.FieldLocation

So sieht das Feld in Listeneinstellungen aus:

Nun stellt sich natürlich die berechtige Frage, wie kann das Feld per Code befüllt werden?

Wirft man einen Blick in den Code (SharePoint PnP, die Klasse wurde schon im Juli 2018 implementiert, siehe https://developer.microsoft.com/en-us/office/blogs/new-sharepoint-csom-version-released-for-sharepoint-online-july-2018/), findet man folgendes:

Das deutet schon darauf hin, dass gegenüber dem klassischen GeoLocation Feld der Standort als JSON hinterlegt ist.
​​​​​​​
​​​​​​​Wird also ein Standort hinzugefügt

sieht das JSON so aus:

$item = $list.GetItemById(1)
$conn.Context.Load($item)
$conn.Context.ExecuteQuery()
$item["Location"]
​
{
    "Address": {
        "City": "Hof",
        "CountryOrRegion": "Germany",
        "PostalCode": "95030",
        "State": "Bayern",
        "Street": "Ossecker Straße 172"
    },
    "Coordinates": {
        "Latitude": 50.30946731567383,
        "Longitude": 11.881319046020507
    },
    "DisplayName": "Ossecker Straße 172, 95030 Hof",
    "LocationSource": "Bing",
    "LocationUri": "https://www.bingapis.com/api/v6/addresses/QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ%3d%3d?setLang=en",
    "UniqueId": "https://www.bingapis.com/api/v6/addresses/QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ%3d%3d?setLang=en"
}

Das JSON ist an sich logisch aufgebaut und ist selbsterklärend, bis auf 3 Attribute: LocationSource, LocationUri, UniqueId.

​​​​​​​Leider ist weder das JSON noch das Feld in irgendeiner Weise von Microsoft dokumentiert:

Mit ein bisschen Trial & Error kommt man aber schnell dahinter 🙂
Grundsätzlich funktioniert das Feld auch ohne die 3 Attribute LocationSource, LocationUri, UniqueId. In der UI wird bei einem Mouseover Event über das Location Feld geprüft, ob die Attribute existieren, falls ja erscheint folgender Dialog:

Mit diesen Attributen wird also angegeben von welchem Maps Anbieter der Standort (LocationSource) und der verlinkte Standort (LocationUri) kommt. Die UniqueId ist anscheinend immer dieselbe wie die LocationUri.

Nun müsste man davon ausgehen, dass sich beim Aufruf der URL Bing Maps öffnet, natürlich ist das (so einfach) nicht der Fall 🙂

Die Authentifizierung sollte mit großer Wahrscheinlichkeit serverseitig ablaufen. Dabei dürfte ein API-Key angehängt werden, welcher für den Request benötigt wird. Das habe ich an der Stelle nicht weiterverfolgt. Eine Dokumentation über diese API konnte ich auch nicht finden.

Interessant bzw. erwähnenswert ist an der Stelle nur noch der Parameter nach addresses, in dem Fall z.B.:

QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ%3d%3d

Im Parameter verbirgt sich die Adresse, codiert in Base64:

$addressParameter = "QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ%3d%3d"
$addressParameterUrlDecoded = [System.Web.HttpUtility]::UrlDecode($addressParameter)
$addressParameterUrlDecodedBase64Decoded = [Convert]::FromBase64String($addressParameterUrlDecoded)
$addressParameterUrlDecodedBase64DecodedAsString = [System.Text.Encoding]::UTF8.GetString($addressParameterUrlDecodedBase64Decoded)
$addressParameterUrlDecodedBase64DecodedAsStringUrlDecoded = [System.Web.HttpUtility]::UrlDecode($addressParameterUrlDecodedBase64DecodedAsString)
​
Write-Host
Write-Host "Parameter: $addressParameter"
Write-Host "Step 1:    $addressParameterUrlDecoded"
Write-Host "Step 2:    $addressParameterUrlDecodedBase64DecodedAsString"
Write-Host "Step 3:    $addressParameterUrlDecodedBase64DecodedAsStringUrlDecoded"
Write-Host -ForegroundColor Yellow "Readable:"
$addressParameterUrlDecodedBase64DecodedAsStringUrlDecoded.Split("?") | %{$_.Split("|")}
​
Parameter: QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ%3d%3d
Step 1:    QWRkcmVzcy83MDIxMTI5MTU0NTgyMDIwMDk5JTdjMTcyP2FsdFF1ZXJ5PWFsJTVlT3NzZWNrZXIrU3RyYSVjMyU5ZmUrMTcyJTdjbGMlNWVIb2YlN2NhMSU1ZUJhdmFyaWElN2NjciU1ZUdlcm1hbnklN2Npc28lNWVERQ==
Step 2:    Address/7021129154582020099%7c172?altQuery=al%5eOssecker+Stra%c3%9fe+172%7clc%5eHof%7ca1%5eBavaria%7ccr%5eGermany%7ciso%5eDE
Step 3:    Address/7021129154582020099|172?altQuery=al^Ossecker Straße 172|lc^Hof|a1^Bavaria|cr^Germany|iso^DE
Readable:
Address/7021129154582020099
172
altQuery=al^Ossecker Straße 172
lc^Hof
a1^Bavaria
cr^Germany
iso^DE

Soll das Feld genauso befüllt werden wie von Microsoft vorgesehen, stellt sich also die Frage wie die LocationUri generiert wird. Die URL manuell zusammenzubauen dürfte recht komplex und fehleranfällig sein, zumal unklar was ist was in diesem Beispiel „Address/7021129154582020099“ darstellen soll.

Bei der Erstellung eines neuen Ortes bzw. beim Bearbeiten in der UI ist im Netzwerkmonitor folgendes zu finden:

Microsoft benutzt hier also eine andere (neue?) API um an die LocationUri zu gelangen. Wie erwartet ist auch die findmeetinglocations API nicht dokumentiert:

Ohne Authentifizierung lässt sich die API auch nicht nutzen:

$query = "ossecker str"
​
$body = @"
{
    "BingMarket": "en-US",
    "LocationProvider": 32,
    "QueryConstraint": {
        "Query": "$query"
    }
}
"@
​
Invoke-RestMethod -ContentType 'application/json;odata=verbose;charset=utf-8' -Method Post -Uri "https://outlook.office365.com/SchedulingB2/api/v1.0/me/findmeetinglocations" -Body $body <#-Headers $headers#>
​
Invoke-RestMethod : 
401 - Unauthorized: Access is denied due to invalid credentials.

In der UI wird im Request Header ein Bearer Token mitgegeben:

Im Payload sieht man folgende appid: „00000003-0000-0ff1-ce00-000000000000“ das ist die well-known ID für die (Office 365) SharePoint Online Application.

{
  "aud": "https://outlook.office365.com",
  "iss": "https://sts.windows.net/b09e638b-82eb-4014-b8a8-51a1ae59182c/",
  "iat": 1597662995,
  "nbf": 1597662995,
  "exp": 1597666895,
  "acct": 0,
  "acr": "1",
  "aio": "E2BgYDhd8fqRWTDzBA1btcBEz3sfe/fe2ThV70+Vicop/xLmR5wA",
  "amr": [
    "pwd"
  ],
  "app_displayname": "Office 365 SharePoint Online",
  "appid": "00000003-0000-0ff1-ce00-000000000000",
  "appidacr": "2",
  "auth_time": 1597658121,
  "enfpolids": [],
  "family_name": "Administrator",
  "given_name": "MOD",
  "ipaddr": "87.168.90.56",
  "name": "MOD Administrator",
  "oid": "96c17473-6c5c-4e80-8f23-4417b2a47b8f",
  "puid": "10032000CF58545C",
  "scp": "Calendars.Read Group.Read.All Group.ReadWrite.All",
  "sid": "2fad4cf9-4295-43fb-8711-de480d8adcf9",
  "sub": "sXZtT25DVjYV74OLaFcQAAbfyAYRobP-sAOqxD4F7vg",
  "tid": "b09e638b-82eb-4014-b8a8-51a1ae59182c",
  "unique_name": "",
  "upn": "",
  "uti": "LYdU-1IZ4kOVCnDMc4xkAA",
  "ver": "1.0",
  "wids": [
    "62e90394-69f5-4237-9190-012177145e10"
  ]
}

Ich habe keine Möglichkeit gefunden über die Application einen Bearer Token zu generieren.

Am Ende des Tages ist die Application auch nicht ausschlaggebend. Wichtig ist nur, einen gültigen Bearer Token für Exchange Online (outlook.office365.com) im User Kontext zu generieren.

Glücklicherweise gibt es dafür ein PowerShell Module, AADInternals: https://github.com/Gerenios/AADInternals

Mit diesem Modul reicht ein CMDlet aus, um sich einen entsprechenden Token zu generieren:

Import-Module AADInternals
​
$cred = Get-Credential
​
$accessTokenEXO = Get-AADIntAccessTokenForEXO -Credentials $cred

Payload:

{
  "aud": "https://outlook.office365.com",
  "iss": "https://sts.windows.net/b09e638b-82eb-4014-b8a8-51a1ae59182c/",
  "iat": 1597671151,
  "nbf": 1597671151,
  "exp": 1597678651,
  "acct": 0,
  "acr": "1",
  "aio": "E2BgYLjmVxCVZrzR5k3fphjX4klFx3bPdu9ztAz31bxlabDk8CkA",
  "amr": [
    "pwd"
  ],
  "app_displayname": "Microsoft Office",
  "appid": "d3590ed6-52b3-4102-aeff-aad2292ab01c",
  "appidacr": "0",
  "enfpolids": [],
  "family_name": "Administrator",
  "given_name": "MOD",
  "ipaddr": "87.168.90.56",
  "name": "MOD Administrator",
  "oid": "96c17473-6c5c-4e80-8f23-4417b2a47b8f",
  "puid": "10032000CF58545C",
  "scp": "Branford-Internal.ReadWrite Calendars.ReadWrite Calendars.ReadWrite.Shared Contacts.ReadWrite Contacts.ReadWrite.Shared EAS.AccessAsUser.All EopPolicySync.AccessAsUser.All EopPsorWs.AccessAsUser.All EWS.AccessAsUser.All Files.ReadWrite.All Group.ReadWrite.All Mail.ReadWrite Mail.ReadWrite.Shared Mail.Send Mail.Send.Shared MailboxSettings.ReadWrite MapiHttp.AccessAsUser.All Notes.Read Notes.ReadWrite Oab.AccessAsUser.All OutlookService.AccessAsUser.All OWA.AccessAsUser.All People.Read People.ReadWrite Place.Read.All Privilege.ELT Signals.Read Signals.ReadWrite SubstrateSearch-Internal.ReadWrite Tags.ReadWrite Tasks.ReadWrite.Shared User.ReadBasic user_impersonation User-Internal.ReadWrite",
  "sid": "75972910-b269-412d-97c7-1783edf186ff",
  "sub": "sXZtT25DVjYV74OLaFcQAAbfyAYRobP-sAOqxD4F7vg",
  "tid": "b09e638b-82eb-4014-b8a8-51a1ae59182c",
  "unique_name": "",
  "upn": "",
  "uti": "8RvbWcMQsEuma2nHguyVAA",
  "ver": "1.0",
  "wids": [
    "62e90394-69f5-4237-9190-012177145e10"
  ]
}

Mit dem frischen Token in der Hand lässt sich der Standort erfolgreich abfragen:

$authorization = "Bearer $accessTokenEXO"
$query = "ossecker str"
​
$headers = @{
    'authorization' = "Bearer $accessTokenEXO"
}
​
$body = @"
{
    "BingMarket": "en-US",
    "LocationProvider": 32,
    "QueryConstraint": {
        "Query": "$query"
    }
}
"@
​
​
$resultFindMeetingLocations = Invoke-RestMethod -ContentType 'application/json;odata=verbose;charset=utf-8' -Method Post -Uri "https://outlook.office365.com/SchedulingB2/api/v1.0/me/findmeetinglocations" -Headers $headers -Body $body
$resultFindMeetingLocations.MeetingLocations
​
MeetingLocation                                                                                                                                                                                             
---------------                                                                                                                                                                                             
@{EntityType=PostalAddress; LocationSource=Bing; LocationUri=https://www.bingapis.com/api/v6/addresses/QWRkcmVzcy8tMjk2MDE5MjA5OT9hbHRRdWVyeT1hbCU1ZU9zc2Vja2VyK1N0cmElYzMlOWZlJTdjbGMlNWVIb2YlN2NhMiU1ZU...
@{EntityType=LocalBusiness; LocationSource=Bing; LocationUri=https://www.bingapis.com/api/v6/localbusinesses/YN6740x7046739364591696611?setLang=en; UniqueId=https://www.bingapis.com/api/v6/localbusines...
@{EntityType=LocalBusiness; LocationSource=Bing; LocationUri=https://www.bingapis.com/api/v6/localbusinesses/YN7414x234600105?setLang=en; UniqueId=https://www.bingapis.com/api/v6/localbusinesses/YN7414...
@{EntityType=LocalBusiness; LocationSource=Bing; LocationUri=https://www.bingapis.com/api/v6/localbusinesses/YN7414x261532248?setLang=en; UniqueId=https://www.bingapis.com/api/v6/localbusinesses/YN7414...
@{EntityType=LocalBusiness; LocationSource=Bing; LocationUri=https://www.bingapis.com/api/v6/localbusinesses/YN6740x616921264?setLang=en; UniqueId=https://www.bingapis.com/api/v6/localbusinesses/YN6740...

Wichtig: Bei den Ergebnissen gibt es ein Paar Dinge zu beachten.

  1. Wie in diesem Beispiel zu sehen ist, gibt es mehrere Standorte. Das liegt an dem ungenauen Query, die Reihenfolge der Ergebnisse hängt von der Wahrscheinlichkeit ab. Sprich das Erste ist auch das wahrscheinlichste Ergebnis.
  2. In seltenen Fällen findet die API, trotz vermeintlich korrekter Angaben, keinen Standort. Manchmal liegt dies an Abkürzungen wie z.B. Halle a. d. Saale oder an Adressen mit mehreren Hausnummern z.B. Musterstraße 5-9.

Die Probleme lassen sich 1:1 in der UI abbilden.

Mit diesem Ergebnis lassen sich die drei benötigten Attribute im JSON befüllen. Eine Meeting Location sieht dann folgendermaßen aus:

$resultFindMeetingLocations.MeetingLocations[0].MeetingLocation
​
​
EntityType     : PostalAddress
LocationSource : Bing
LocationUri    : https://www.bingapis.com/api/v6/addresses/QWRkcmVzcy8tMjk2MDE5MjA5OT9hbHRRdWVyeT1hbCU1ZU9zc2Vja2VyK1N0cmElYzMlOWZlJTdjbGMlNWVIb2YlN2NhMiU1ZUhvZisoU3RhZHQpJTdjYTElNWVCYXZhcmlhJTdjY3IlNWVHZ
                 XJtYW55JTdjaXNvJTVlREU%3d?setLang=en
UniqueId       : https://www.bingapis.com/api/v6/addresses/QWRkcmVzcy8tMjk2MDE5MjA5OT9hbHRRdWVyeT1hbCU1ZU9zc2Vja2VyK1N0cmElYzMlOWZlJTdjbGMlNWVIb2YlN2NhMiU1ZUhvZisoU3RhZHQpJTdjYTElNWVCYXZhcmlhJTdjY3IlNWVHZ
                 XJtYW55JTdjaXNvJTVlREU%3d?setLang=en
DisplayName    : Ossecker Straße
Address        : @{Street=Ossecker Straße; City=Hof; State=Bavaria; CountryOrRegion=Germany; PostalCode=95030}
Coordinates    :

Um das JSON vollständig zu befüllen fehlen jetzt, falls nicht bereits vorhanden, nur noch die Koordinaten.

Hierfür kann die Bing REST API benutzt werden. Dafür wird allerdings ein API Key benötigt, ein kostenloser Dev Key lässt sich aber ohne weiteres erstellen.​​​​​​​

Im Folgenden wird anhand des vorherigen Ergebnisses derselbe Standort über die REST API abgefragt, hier sind auch die Koordinaten enthalten.

$bingMapsAPIKey = "-----add your key-----"
$uri = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.LocationUri
$uniqueId = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.UniqueId
$locationSource = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.LocationSource
$city = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.Address.City
$countryOrRegion = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.Address.CountryOrRegion
$postalCode = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.Address.PostalCode
$state = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.Address.State
$street = ($resultFindMeetingLocations.MeetingLocations | select -First 1).MeetingLocation.Address.Street
​
​
$resultBingMaps = Invoke-RestMethod -Uri "http://dev.virtualearth.net/REST/v1/Locations?q=$street, $postalCode $city&key=$bingMapsAPIKey" -Method Get
​
$latitude = $resultBingMaps.resourceSets.resources.point.coordinates[0]
$longitude = $resultBingMaps.resourceSets.resources.point.coordinates[1]

Daraus lässt sich nun (endlich 😉 ) das JSON erstellen:

$locationJSON = '{"Address":{"City":"' + $city + '","CountryOrRegion":"' + $countryOrRegion + '","PostalCode":"' + $postalCode + '","State":"' + $state + '","Street":"' + $street + '"},"Coordinates":{"Latitude":' + $latitude + ',"Longitude":' + $longitude + '},"DisplayName":"' + $query + '","LocationSource":"' + $locationSource + '","LocationUri":"' + $uri + '","UniqueId":"' + $uniqueId + '"}'

Aber keine Learnings ohne ToDos… 🙂 das Ganze funktioniert aktuell nur ohne MFA/Intune. Das werde ich in Zukunft ergänzen.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert