Lister og kolonner fra et Excel-ark med PnP PowerShell
Én Excel-fil beskriver listerne, ét ark pr. liste og én linje pr. kolonne. Scriptet opretter, opdaterer og sletter kolonner, så udvikling, test og produktion er ens.
Mit script fra april 2023. En Excel-fil beskriver listerne på et websted: ét ark pr. liste og én linje pr. kolonne. Scriptet opretter listen, hvis den mangler, opretter eller opdaterer hver kolonne ud fra en XML-definition og sletter de kolonner på listen, som ikke står i arket. Den samme fil køres mod udvikling, test og produktion, så listerne er ens alle tre steder.
Excel-filen læses med modulet ImportExcel (Install-Module ImportExcel), så Excel skal ikke være installeret. XML-formen for kolonnerne er fra Kofi Frimpongs “Create SharePoint field types with XML using PnP PowerShell”.
Excel-filen#
Arkets navn er listens titel. Et ark med navnet _BackendData springes over og kan bruges til opslagsværdier i selve Excel-filen. Kolonnerne i arket:
| Kolonne | Bruges til |
|---|---|
Name | Kolonnens interne navn. Det er nøglen: findes navnet på listen, bliver kolonnen opdateret, ellers oprettet |
DisplayName | Visningsnavnet |
Type | Text, Note, Number, Currency, DateTime, Choice, URL, User, Calculated, Boolean, Lookup eller Thumbnail |
Description | Beskrivelsen |
Required | TRUE eller FALSE |
Default | Standardværdi (Number, Currency, Boolean, Choice) |
NumberMin, NumberMax, Decimals | Number og Currency |
LCID | Currency: valutaens land, fx 1030 for kroner |
DateFormat | DateOnly eller DateTime |
DateViewFormat | Standard eller Friendly |
UserSelectionMode | PeopleOnly eller PeopleAndGroups |
CHOICEFormat | Dropdown eller RadioButtons |
FillInChoice | TRUE eller FALSE: må brugeren skrive sin egen værdi |
CHOICE1 … CHOICE10 | Valgmulighederne. Tomme felter springes over |
CALCFormula, CALCResultType | Formlen og resultatets type (Text, Number, DateTime, Boolean, Currency) |
LookupListSource, LookupShowField | Opslagslistens titel eller id, og den kolonne, der vises |
Forbindelsen#
-Interactive kræver en egen appregistrering med -ClientId, siden PnP fjernede sin fælles app i september 2024. Til en kørsel uden bruger står forbindelsen med certifikat i Upload fra et fildrev til SharePoint Online med PowerShell.
Scriptet#
1Import-Module PnP.PowerShell
2###########################################################################
3# Name: Deploy lists and columns to DEV
4# Description: Script that deploys all lists and columns to DEV
5# Author: Ulrich Gerting Bojko
6# Updated: 23. April 2023
7# GUID Generator used: https://www.guidgenerator.com/
8# XML-format: https://www.kofrimpong.com/create-sharepoint-field-types-with-xml-using-pnp-powershell/
9# Import fra Excel: https://github.com/dfinke/ImportExcel
10#
11###########################################################################
12
13# Set parameter values
14$SiteURL="https://<tenant>.sharepoint.com/sites/<websted>"
15$ClientId = "<klient-id>"
16$ExcelFilePath = ".\Lister.xlsx"
17$TemplateListName = "DeployScriptTemplateList123456789"
18
19Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
20
21function fieldtypeText($data) {
22 $output = "<Field Type='Text' "
23 $output += "DisplayName='" + $data.DisplayName + "' "
24 $output += "Name='" + $data.Name + "' "
25 $output += "Description='" + $data.Description + "' "
26 $output += "Required='" + $data.Required + "'>"
27 $output += "</Field>"
28 return $output
29}
30
31function fieldtypeNote($data) {
32 $output = "<Field Type='Note' "
33 $output += "DisplayName='" + $data.DisplayName + "' "
34 $output += "Name='" + $data.Name + "' "
35 $output += "Description='" + $data.Description + "' "
36 $output += "Required='" + $data.Required + "'>"
37 $output += "</Field>"
38 return $output
39}
40
41function fieldtypeBoolean($data) {
42 $output = "<Field Type='Boolean' "
43 $output += "DisplayName='" + $data.DisplayName + "' "
44 $output += "Name='" + $data.Name + "' "
45 $output += "Description='" + $data.Description + "' "
46 $output += "Required='" + $data.Required + "'>"
47 $output += "<Default>" + $data.Default + "</Default>"
48 $output += "</Field>"
49 return $output
50}
51
52function fieldtypeUser($data) {
53 $output = "<Field Type='User' "
54 $output += "DisplayName='" + $data.DisplayName + "' "
55 $output += "Name='" + $data.Name + "' "
56 $output += "Description='" + $data.Description + "' "
57 $output += "UserSelectionMode='" + $data.UserSelectionMode + "' "
58 $output += "UserSelectionScope='0' "
59 $output += "Required='" + $data.Required + "'>"
60 $output += "</Field>"
61 return $output
62}
63
64function fieldtypeNumber($data) {
65 $output = "<Field Type='Number' "
66 $output += "DisplayName='" + $data.DisplayName + "' "
67 $output += "Name='" + $data.Name + "' "
68 $output += "Description='" + $data.Description + "' "
69 if ($null -ne $data.NumberMin) {
70 $output += "Min='" + $data.NumberMin + "' "
71 }
72 if ($null -ne $data.NumberMax) {
73 $output += "Max='" + $data.NumberMax + "' "
74 }
75 if ($null -ne $data.Decimals) {
76 $output += "Decimals='" + $data.Decimals + "' "
77 }
78 $output += "Required='" + $data.Required + "'>"
79 $output += "<Default>" + $data.Default + "</Default>"
80 $output += "</Field>"
81 return $output
82}
83
84function fieldtypeCurrency($data) {
85 $output = "<Field Type='Currency' "
86 $output += "DisplayName='" + $data.DisplayName + "' "
87 $output += "Name='" + $data.Name + "' "
88 $output += "Description='" + $data.Description + "' "
89 $output += "LCID='" + $data.LCID + "' "
90 if ($null -ne $data.Decimals) {
91 $output += "Decimals='" + $data.Decimals + "' "
92 }
93 $output += "Required='" + $data.Required + "'>"
94 $output += "<Default>" + $data.Default + "</Default>"
95 $output += "</Field>"
96 return $output
97}
98
99function fieldtypeURL($data) {
100 $output = "<Field Type='URL' "
101 $output += "DisplayName='" + $data.DisplayName + "' "
102 $output += "Name='" + $data.Name + "' "
103 $output += "Description='" + $data.Description + "' "
104 $output += "Format='Hyperlink' "
105 $output += "Required='" + $data.Required + "'>"
106 $output += "</Field>"
107 return $output
108}
109
110function fieldtypeDateTime($data) {
111 $FriendlyDisplayFormat = "Enabled"
112 if ($data.DateViewFormat -eq "Standard") {
113 $FriendlyDisplayFormat = "Disabled"
114 }
115 $output = "<Field Type='DateTime' "
116 $output += "DisplayName='" + $data.DisplayName + "' "
117 $output += "Name='" + $data.Name + "' "
118 $output += "Description='" + $data.Description + "' "
119 $output += "Format='" + $data.DateFormat + "' "
120 $output += "FriendlyDisplayFormat='" + $FriendlyDisplayFormat + "' "
121 $output += "Required='" + $data.Required + "'>"
122 $output += "</Field>"
123 return $output
124}
125
126function fieldtypeChoice($data) {
127 $output = "<Field Type='Choice' "
128 $output += "DisplayName='" + $data.DisplayName + "' "
129 $output += "Name='" + $data.Name + "' "
130 $output += "Description='" + $data.Description + "' "
131 $output += "Format ='" + $data.CHOICEFormat + "' "
132 $output += "FillInChoice ='" + $data.FillInChoice + "' "
133 $output += "Required='" + $data.Required + "'>"
134 $output += "<Default>" + $data.Default + "</Default>"
135 $output += "<CHOICES>"
136 for ($i = 1; $i -le 10; $i++) {
137 $choice = $data."CHOICE$i"
138 if ($choice) {
139 $output += "<CHOICE>" + $choice + "</CHOICE>"
140 }
141 }
142 $output += "</CHOICES>"
143 $output += "</Field>"
144 return $output
145}
146function fieldtypeCalculated($data) {
147 $arr = $data.CALCFormula.split("]")
148 $FieldRefOutput = ""
149 foreach($item in $arr)
150 {
151 $FieldRefOutput += $FieldRef
152 $field = $item.split("[")[1]
153 $FieldRef = "<FieldRef Name='" + $field +"' />"
154 }
155
156 $output = "<Field Type='Calculated' "
157 $output += "DisplayName='" + $data.DisplayName + "' "
158 $output += "Name='" + $data.Name + "' "
159 $output += "Description='" + $data.Description + "' "
160 $output += "ResultType='" + $data.CALCResultType + "'>"
161 $output += "<FieldRefs>"
162 $output += $FieldRefOutput
163 $output += "</FieldRefs>"
164 $output += "<Formula>" + [System.Security.SecurityElement]::Escape($data.CALCFormula) + "</Formula>"
165 $output += "</Field>"
166 return $output
167}
168function fieldtypeLookup($data) {
169 # Get list by GUID
170 $List = Get-PnPList -Identity $data.LookupListSource
171 $output = "<Field Type='Lookup' "
172 $output += "DisplayName='" + $data.DisplayName + "' "
173 $output += "Name='" + $data.Name + "' "
174 $output += "Description='" + $data.Description + "' "
175 $output += "List='" + $List.Id + "' "
176 $output += "ShowField='" + $data.LookupShowField + "'/>"
177 return $output
178}
179
180function fieldtypeThumbnail($data) {
181 $output = "<Field Type='Thumbnail' "
182 $output += "DisplayName='" + $data.DisplayName + "' "
183 $output += "Name='" + $data.Name + "' "
184 $output += "Description='" + $data.Description + "' >"
185 $output += "</Field>"
186 return $output
187}
188
189# Get list of fieldnames to be excluded based on an empty standardlist
190$ExcludeFieldNames = [System.Collections.ArrayList]::new()
191
192## Create list of fields that is to be excluded from the removal of fields
193New-PnPList -Title $TemplateListName -Template GenericList
194
195# Get fields from SP List
196$Fields = Get-PnPField -List $TemplateListName
197 ForEach($Field in $Fields){
198 [void]$ExcludeFieldNames.add($Field.InternalName)
199 }
200Remove-PnPList -Identity $TemplateListName -Force
201
202# Get Worksheetnames
203$WorksheetNames = Get-ExcelSheetInfo $ExcelFilePath
204
205# Go through each worksheet
206ForEach($Item in $WorksheetNames)
207{
208$ListName = $Item.Name
209
210# Reset arrays
211$SPListFieldNames = [System.Collections.ArrayList]::new()
212$ExcelListFieldNames = @()
213
214## Create new or update existing fields
215if ($ListName -ne '_BackendData') {
216
217 # Create list if it does not exist
218 $List = Get-PnPList -Identity $ListName -ErrorAction SilentlyContinue
219
220 if ($Null -eq $List) {
221 New-PnPList -Title $ListName -Template GenericList
222
223 }
224
225 Import-Excel $ExcelFilePath -WorksheetName $ListName | foreach-object {
226 if ($_.Type -eq 'Text') {
227 $XmlText = fieldtypeText($_)
228 }
229 if ($_.Type -eq 'Note') {
230 $XmlText = fieldtypeNote($_)
231 }
232 if ($_.Type -eq 'Number') {
233 $XmlText = fieldtypeNumber($_)
234 }
235 if ($_.Type -eq 'Currency') {
236 $XmlText = fieldtypeCurrency($_)
237 }
238 if ($_.Type -eq 'DateTime') {
239 $XmlText = fieldtypeDateTime($_)
240 }
241 if ($_.Type -eq 'Choice') {
242 $XmlText = fieldtypeChoice($_)
243 }
244 if ($_.Type -eq 'URL') {
245 $XmlText = fieldtypeURL($_)
246 }
247 if ($_.Type -eq 'User') {
248 $XmlText = fieldtypeUser($_)
249 }
250 if ($_.Type -eq 'Calculated') {
251 $XmlText = fieldtypeCalculated($_)
252 }
253 if ($_.Type -eq 'Boolean') {
254 $XmlText = fieldtypeBoolean($_)
255 }
256 if ($_.Type -eq 'Lookup') {
257 $XmlText = fieldtypeLookup($_)
258 }
259 if ($_.Type -eq 'Thumbnail') {
260 $XmlText = fieldtypeThumbnail($_)
261 }
262
263 $FieldTitle = $_.Name
264 if($FieldTitle -ne $Null)
265 {
266 $Field = Get-PnPField -Identity $FieldTitle -List $ListName -ErrorAction SilentlyContinue | Select InternalName
267
268 If($Field.InternalName -eq $Null)
269 {
270 Write-Host "-------------------------------------"-f Yellow
271 Write-host "The field '$FieldTitle' does not exists in the list '$ListName'. Creating it now...." -f Yellow
272 Write-Host "-------------------------------------"-f Yellow
273 # Write-host $XmlText
274 Add-PnPFieldFromXml -List $ListName -FieldXml $XmlText
275 }
276 Else
277 {
278 # Set-PnPField -List $ListName -Identity $_.Name -Values $fieldObject -ErrorAction Stop
279 Set-PnPField -List $ListName -Identity $_.Name -Values @{SchemaXml=$XmlText} -ErrorAction Stop
280 Write-host "Updating field '$FieldTitle' in the list '$ListName'" -f Green
281 }
282
283 }
284
285 # Add Internal name to array for comparison later
286 $ExcelListFieldNames += $_.Name
287 }
288
289 Write-Host "-------------------------------------"
290
291 #######################################################################################################################
292 ## Delete unused fields
293 # Add the excluded fields to the excel-array
294 $ExcelListFieldNames += $ExcludeFieldNames
295
296 # Write-Host "----------`$ExcelListFieldNames-------------------------"
297 # Write-Host $ExcelListFieldNames
298
299 # Build the SPListfield array
300 # Get fields from SP List
301 $Fields = Get-PnPField -List $ListName
302 ForEach($Field in $Fields){
303 [void]$SPListFieldNames.add($Field.InternalName)
304 }
305 # Write-Host "----------`$SPListFieldNames-------------------------"
306 # Write-Host $SPListFieldNames
307
308
309 # Compare the two arrays and delete the difference
310
311 $SPListFieldNames | ForEach-Object {
312 if ($ExcelListFieldNames -notcontains $_) {
313 Write-Host "The field [$_] on the list $ListName is deleted" -f Red
314 Remove-PnPField -List $ListName -Identity $_ -Force
315 Write-Host "-------------------------------------" -f Red
316 }
317 }
318 ##
319 #######################################################################################################################
320
321 }
322}
Rettet i forhold til 2023-udgaven#
Kommentarerne med # var blevet til [[…]]-links, da noten kom ind i Obsidian. De er sat tilbage. Webstedets adresse er erstattet af en pladsholder, og -ClientId er tilføjet. Ud over det fire fejl, som viste sig, da XML-funktionerne blev kørt i PowerShell 7, og resultatet blev læst som XML:
- URL.
Format='Hyperlink'blev skrevet efter>og endte som tekst inde i elementet i stedet for som attribut. - DateTime.
if ($data.DateViewFormat = "Standard")er en tildeling, ikke en sammenligning, så betingelsen var altid sand, ogFriendlyDisplayFormatblev altidDisabled. Nu-eq. - Choice.
FillInChoicefik værdien fraCHOICEFormat(fxDropdown). Den har fået sin egen kolonne i arket. TommeCHOICE-felter blev til tomme valgmuligheder. Nu springes de over. - Calculated. En formel med
<,>eller&gav ugyldig XML. Formlen escapes nu.
Det der driller#
- Sletningen. Alle kolonner på listen, der hverken står i arket eller findes på en tom standardliste, bliver slettet med
Remove-PnPField -Force, uden at spørge og med de data, der står i dem. Det gælder også kolonner, som nogen har lagt på i hånden, eller som er kommet med en indholdstype. Første kørsel mod et nyt miljø bør være mod en kopi, eller med sletningen kommenteret ud. - Hjælpelisten. Scriptet opretter
DeployScriptTemplateList123456789for at se, hvilke kolonner en tom liste har, og sletter den igen. Stopper scriptet midt imellem, bliver listen stående og skal fjernes i hånden. - Ingen escaping ud over formlen. Et
',&eller<i et visningsnavn eller en beskrivelse giver ugyldig XML. - Opdatering sætter hele
SchemaXml.Set-PnPField -Values @{SchemaXml=…}skriver hele definitionen om. At skifte typen på en eksisterende kolonne har jeg ikke prøvet. - Opslagskolonner. Listen, der slås op i, skal findes, før arket med opslaget køres. Arkene køres i den rækkefølge, de står i filen.
- Beregnede kolonner.
FieldRefbygges af navnene i[…]i formlen. Scriptet går ud fra, at de er de interne navne.
PnP’s egne skabeloner#
Den anden vej er at eksportere lister fra et websted, der allerede er rigtigt, og lægge dem på et andet:
1Get-PnPSiteTemplate -Out lister.xml -Handlers Lists,Fields,ContentTypes -ListsToExtract "<liste>"
2Invoke-PnPSiteTemplate -Path lister.xml
Get-PnPSiteTemplate eksporterer fra det websted, forbindelsen peger på, og Invoke-PnPSiteTemplate lægger skabelonen på det websted, der er forbundet til bagefter. Skabelonen sletter ingenting. Hvor godt en ny kørsel opdaterer kolonner, der allerede findes, står ikke i PnP’s dokumentation, og det har jeg ikke efterprøvet. Excel-vejen har den fordel, at beskrivelsen af listerne kan læses og rettes uden at kende skabelonformatet, og at den fjerner det, der ikke står i den.