From 8f3bb53057d448045f27bb9edfe0c7bf3595e65c Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 11:04:34 +0200 Subject: [PATCH 1/8] Add parameter `ExtractWebParts` to command `Add-PnPFileToProvisioningTemplate` --- .../Site/AddFileToProvisioningTemplate.cs | 88 +++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index 3de0bcce0..fea65c791 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -5,12 +5,15 @@ using OfficeDevPnP.Core.Framework.Provisioning.Providers; using OfficeDevPnP.Core.Framework.Provisioning.Providers.Xml; using SharePointPnP.PowerShell.CmdletHelpAttributes; +using SharePointPnP.PowerShell.Commands.Utilities; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation; using System.Net; using PnPFileLevel = OfficeDevPnP.Core.Framework.Provisioning.Model.FileLevel; +using SPFile = Microsoft.SharePoint.Client.File; namespace SharePointPnP.PowerShell.Commands.Provisioning.Site { @@ -37,10 +40,14 @@ namespace SharePointPnP.PowerShell.Commands.Provisioning.Site Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceUrl ""Shared%20Documents/ProjectStatus.docs""", Remarks = "Adds a file to a PnP Provisioning Template retrieved from the currently connected site. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", SortOrder = 5)] + [CmdletExample( + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceUrl ""SitePages/Home.aspx"" -ExtractWebParts", + Remarks = "Adds a file to a PnP Provisioning Template retrieved from the currently connected site. If the file is a classic page, also extract its webparts. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", + SortOrder = 6)] public class AddFileToProvisioningTemplate : PnPWebCmdlet { const string parameterSet_LOCALFILE = "Local File"; - const string parameterSet_REMOTEFILE = "Remove File"; + const string parameterSet_REMOTEFILE = "Remote File"; [Parameter(Mandatory = true, Position = 0, HelpMessage = "Filename of the .PNP Open XML site template to read from, optionally including full path.")] public string Path; @@ -63,6 +70,9 @@ public class AddFileToProvisioningTemplate : PnPWebCmdlet [Parameter(Mandatory = false, Position = 5, HelpMessage = "Set to overwrite in site, Defaults to true")] public SwitchParameter FileOverwrite = true; + [Parameter(Mandatory = false, Position = 6, ParameterSetName = parameterSet_REMOTEFILE, HelpMessage = "Include webparts if the file is a page")] + public SwitchParameter ExtractWebParts = true; + [Parameter(Mandatory = false, Position = 4, HelpMessage = "Allows you to specify ITemplateProviderExtension to execute while loading the template.")] public ITemplateProviderExtension[] TemplateProviderExtensions; @@ -83,7 +93,15 @@ protected override void ProcessRecord() } if (this.ParameterSetName == parameterSet_REMOTEFILE) { - SelectedWeb.EnsureProperty(w => w.ServerRelativeUrl); + if (ExtractWebParts) + { + ClientContext.Load(SelectedWeb, web => web.Url, web => web.Id, web => web.ServerRelativeUrl); + ClientContext.Load(((ClientContext)SelectedWeb.Context).Site, site => site.Id, site => site.ServerRelativeUrl, site => site.Url); + ClientContext.Load(SelectedWeb.Lists, lists => lists.Include(l => l.Title, l => l.RootFolder.ServerRelativeUrl, l => l.Id)); + } + + ClientContext.ExecuteQuery(); + var sourceUri = new Uri(SourceUrl, UriKind.RelativeOrAbsolute); var serverRelativeUrl = sourceUri.IsAbsoluteUri ? sourceUri.AbsolutePath : @@ -91,11 +109,11 @@ protected override void ProcessRecord() SelectedWeb.ServerRelativeUrl.TrimEnd('/') + "/" + SourceUrl; var file = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); - - var fileName = file.EnsureProperty(f => f.Name); + file.EnsureProperties(f => f.Name, f => f.ServerRelativeUrl); + var fileName = file.Name; var folderRelativeUrl = serverRelativeUrl.Substring(0, serverRelativeUrl.Length - fileName.Length - 1); var folderWebRelativeUrl = HttpUtility.UrlKeyValueDecode(folderRelativeUrl.Substring(SelectedWeb.ServerRelativeUrl.TrimEnd('/').Length + 1)); - if (ClientContext.HasPendingRequest) ClientContext.ExecuteQuery(); + try { #if SP2013 || SP2016 @@ -103,11 +121,18 @@ protected override void ProcessRecord() #else var fi = SelectedWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); #endif + + IEnumerable webParts = null; + if (ExtractWebParts) + { + webParts = ExtractSPFileWebParts(file).ToArray(); + } + var fileStream = fi.OpenBinaryStream(); ClientContext.ExecuteQueryRetry(); using (var ms = fileStream.Value) { - AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl); + AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl, webParts); } } catch (WebException exc) @@ -134,7 +159,54 @@ protected override void ProcessRecord() } } - private void AddFileToTemplate(ProvisioningTemplate template, Stream fs, string folder, string fileName, string container) + private IEnumerable ExtractSPFileWebParts(SPFile file) + { + if (file == null) throw new ArgumentNullException(nameof(file)); + + if (string.Compare(System.IO.Path.GetExtension(file.Name), ".aspx", true) == 0) + { + foreach (var spwp in SelectedWeb.GetWebParts(file.ServerRelativeUrl)) + { + spwp.EnsureProperties(wp => wp.WebPart +#if !SP2016 // Missing ZoneId property in SP2016 version of the CSOM Library + , wp => wp.ZoneId +#endif + ); + yield return new WebPart + { + Contents = Tokenize(SelectedWeb.GetWebPartXml(spwp.Id, file.ServerRelativeUrl)), + Order = (uint)spwp.WebPart.ZoneIndex, + Title = spwp.WebPart.Title, +#if !SP2016 // Missing ZoneId property in SP2016 version of the CSOM Library + Zone = spwp.ZoneId +#endif + }; + } + } + } + private string Tokenize(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + foreach (var list in SelectedWeb.Lists) + { + var webRelativeUrl = list.GetWebRelativeUrl(); + if (!webRelativeUrl.StartsWith("_catalogs", StringComparison.Ordinal)) + { + input = input + .ReplaceCaseInsensitive(list.Id.ToString("D"), "{listid:" + list.Title + "}") + .ReplaceCaseInsensitive(webRelativeUrl, "{listurl:" + list.Title + "}"); + } + } + return input.ReplaceCaseInsensitive(SelectedWeb.Url, "{site}") + .ReplaceCaseInsensitive(SelectedWeb.ServerRelativeUrl, "{site}") + .ReplaceCaseInsensitive(SelectedWeb.Id.ToString(), "{siteid}") + .ReplaceCaseInsensitive(((ClientContext)SelectedWeb.Context).Site.ServerRelativeUrl, "{sitecollection}") + .ReplaceCaseInsensitive(((ClientContext)SelectedWeb.Context).Site.Id.ToString(), "{sitecollectionid}") + .ReplaceCaseInsensitive(((ClientContext)SelectedWeb.Context).Site.Url, "{sitecollection}"); + } + + private void AddFileToTemplate(ProvisioningTemplate template, Stream fs, string folder, string fileName, string container, IEnumerable webParts = null) { var source = !string.IsNullOrEmpty(container) ? (container + "/" + fileName) : fileName; @@ -160,6 +232,8 @@ private void AddFileToTemplate(ProvisioningTemplate template, Stream fs, string Overwrite = FileOverwrite, }; + if (webParts != null) newFile.WebParts.AddRange(webParts); + template.Files.Add(newFile); // Determine the output file name and path From adccf7b54203a594b7b98165a2017c558d9fcbf5 Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 11:20:48 +0200 Subject: [PATCH 2/8] Fix `WebPartDefinition.ZoneId` since it's available since the Feb19 release --- .../Provisioning/Site/AddFileToProvisioningTemplate.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index fea65c791..2a81c1ccd 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -167,19 +167,13 @@ private IEnumerable ExtractSPFileWebParts(SPFile file) { foreach (var spwp in SelectedWeb.GetWebParts(file.ServerRelativeUrl)) { - spwp.EnsureProperties(wp => wp.WebPart -#if !SP2016 // Missing ZoneId property in SP2016 version of the CSOM Library - , wp => wp.ZoneId -#endif - ); + spwp.EnsureProperties(wp => wp.WebPart, wp => wp.ZoneId); yield return new WebPart { Contents = Tokenize(SelectedWeb.GetWebPartXml(spwp.Id, file.ServerRelativeUrl)), Order = (uint)spwp.WebPart.ZoneIndex, Title = spwp.WebPart.Title, -#if !SP2016 // Missing ZoneId property in SP2016 version of the CSOM Library Zone = spwp.ZoneId -#endif }; } } From 4ce4d4dc2898685460f825e7c26ec9d186e894e4 Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 11:45:21 +0200 Subject: [PATCH 3/8] Code improvements : - parameter checking - Methods split to improve maintenability - speed improvements (StringExtensions.ReplaceCaseInsensitive) - formatting --- .../Site/AddFileToProvisioningTemplate.cs | 91 ++++++++------ Commands/Utilities/StringExtensions.cs | 115 ++++++++++++++++-- 2 files changed, 164 insertions(+), 42 deletions(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index 2a81c1ccd..5a1fc8e4b 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -91,6 +91,7 @@ protected override void ProcessRecord() { throw new ApplicationException("Invalid template file!"); } + // Add a file from the connected Web if (this.ParameterSetName == parameterSet_REMOTEFILE) { if (ExtractWebParts) @@ -101,7 +102,7 @@ protected override void ProcessRecord() } ClientContext.ExecuteQuery(); - + var sourceUri = new Uri(SourceUrl, UriKind.RelativeOrAbsolute); var serverRelativeUrl = sourceUri.IsAbsoluteUri ? sourceUri.AbsolutePath : @@ -109,37 +110,9 @@ protected override void ProcessRecord() SelectedWeb.ServerRelativeUrl.TrimEnd('/') + "/" + SourceUrl; var file = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); - file.EnsureProperties(f => f.Name, f => f.ServerRelativeUrl); - var fileName = file.Name; - var folderRelativeUrl = serverRelativeUrl.Substring(0, serverRelativeUrl.Length - fileName.Length - 1); - var folderWebRelativeUrl = HttpUtility.UrlKeyValueDecode(folderRelativeUrl.Substring(SelectedWeb.ServerRelativeUrl.TrimEnd('/').Length + 1)); - - try - { -#if SP2013 || SP2016 - var fi = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); -#else - var fi = SelectedWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); -#endif - - IEnumerable webParts = null; - if (ExtractWebParts) - { - webParts = ExtractSPFileWebParts(file).ToArray(); - } - - var fileStream = fi.OpenBinaryStream(); - ClientContext.ExecuteQueryRetry(); - using (var ms = fileStream.Value) - { - AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl, webParts); - } - } - catch (WebException exc) - { - WriteWarning($"Can't add file from url {serverRelativeUrl} : {exc}"); - } + AddSPFileToTemplate(template, file); } + // Add a file from the file system else { if (!System.IO.Path.IsPathRooted(Source)) @@ -150,15 +123,55 @@ protected override void ProcessRecord() // Load the file and add it to the .PNP file using (var fs = System.IO.File.OpenRead(Source)) { - Folder = Folder.Replace("\\", "/"); + Folder = Folder.Replace('\\', '/'); - var fileName = Source.IndexOf("\\", StringComparison.Ordinal) > 0 ? Source.Substring(Source.LastIndexOf("\\") + 1) : Source; + var fileName = Source.IndexOf(System.IO.Path.DirectorySeparatorChar) > 0 + ? Source.Substring(Source.LastIndexOf(System.IO.Path.DirectorySeparatorChar) + 1) + : Source; var container = !string.IsNullOrEmpty(Container) ? Container : string.Empty; AddFileToTemplate(template, fs, Folder, fileName, container); } } } + private void AddSPFileToTemplate(ProvisioningTemplate template, SPFile file) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (file == null) throw new ArgumentNullException(nameof(file)); + + file.EnsureProperties(f => f.Name, f => f.ServerRelativeUrl); + var serverRelativeUrl = file.ServerRelativeUrl; + var fileName = file.Name; + var folderRelativeUrl = serverRelativeUrl.Substring(0, serverRelativeUrl.Length - fileName.Length - 1); + var folderWebRelativeUrl = HttpUtility.UrlKeyValueDecode(folderRelativeUrl.Substring(SelectedWeb.ServerRelativeUrl.TrimEnd('/').Length + 1)); + + try + { +#if SP2013 || SP2016 + var fi = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); +#else + var fi = SelectedWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); +#endif + + IEnumerable webParts = null; + if (ExtractWebParts) + { + webParts = ExtractSPFileWebParts(file).ToArray(); + } + + var fileStream = fi.OpenBinaryStream(); + ClientContext.ExecuteQueryRetry(); + using (var ms = fileStream.Value) + { + AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl, webParts); + } + } + catch (WebException exc) + { + WriteWarning($"Can't add file from url {serverRelativeUrl} : {exc}"); + } + } + private IEnumerable ExtractSPFileWebParts(SPFile file) { if (file == null) throw new ArgumentNullException(nameof(file)); @@ -200,8 +213,18 @@ private string Tokenize(string input) .ReplaceCaseInsensitive(((ClientContext)SelectedWeb.Context).Site.Url, "{sitecollection}"); } - private void AddFileToTemplate(ProvisioningTemplate template, Stream fs, string folder, string fileName, string container, IEnumerable webParts = null) + private void AddFileToTemplate( + ProvisioningTemplate template, + Stream fs, + string folder, + string fileName, + string container, + IEnumerable webParts = null + ) { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (fs == null) throw new ArgumentNullException(nameof(fs)); + var source = !string.IsNullOrEmpty(container) ? (container + "/" + fileName) : fileName; template.Connector.SaveFileStream(fileName, container, fs); diff --git a/Commands/Utilities/StringExtensions.cs b/Commands/Utilities/StringExtensions.cs index 49817c70e..7b8c6178f 100644 --- a/Commands/Utilities/StringExtensions.cs +++ b/Commands/Utilities/StringExtensions.cs @@ -1,17 +1,116 @@ -using System.Text.RegularExpressions; +using System; +using System.Diagnostics; +using System.Text; namespace SharePointPnP.PowerShell.Commands.Utilities { + /// + /// StringExtensions provides useful methods regarding string manipulation + /// public static class StringExtensions { - public static string ReplaceCaseInsensitive(this string input, string search, string replacement) + [DebuggerStepThrough] + public static string ReplaceCaseInsensitive(this string str, string oldValue, string newValue) { - return Regex.Replace( - input, - Regex.Escape(search), - replacement.Replace("$", "$$"), - RegexOptions.IgnoreCase - ); + return Replace(str, oldValue, newValue, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns a new string in which all occurrences of a specified string in the current instance are replaced with another + /// specified string according the type of search to use for the specified string. + /// + /// The string performing the replace method. + /// The string to be replaced. + /// The string replace all occurrences of . + /// If value is equal to null, than all occurrences of will be removed from the . + /// One of the enumeration values that specifies the rules for the search. + /// A string that is equivalent to the current string except that all instances of are replaced with . + /// If is not found in the current instance, the method returns the current instance unchanged. + // Credits to https://stackoverflow.com/a/45756981/588868 + [DebuggerStepThrough] + public static string Replace( + this string str, + string oldValue, + string @newValue, + StringComparison comparisonType + ) + { + // Check inputs. + if (str == null) + { + // Same as original .NET C# string.Replace behavior. + throw new ArgumentNullException(nameof(str)); + } + if (str.Length == 0) + { + // Same as original .NET C# string.Replace behavior. + return str; + } + if (oldValue == null) + { + // Same as original .NET C# string.Replace behavior. + throw new ArgumentNullException(nameof(oldValue)); + } + if (oldValue.Length == 0) + { + // Same as original .NET C# string.Replace behavior. + throw new ArgumentException("String cannot be of zero length."); + } + + //if (oldValue.Equals(newValue, comparisonType)) + //{ + //This condition has no sense + //It will prevent method from replacesing: "Example", "ExAmPlE", "EXAMPLE" to "example" + //return str; + //} + + // Prepare string builder for storing the processed string. + // Note: StringBuilder has a better performance than String by 30-40%. + var resultStringBuilder = new StringBuilder(str.Length); + + // Analyze the replacement: replace or remove. + var isReplacementNullOrEmpty = string.IsNullOrEmpty(@newValue); + + // Replace all values. + const int valueNotFound = -1; + int foundAt; + var startSearchFromIndex = 0; + while ((foundAt = str.IndexOf(oldValue, startSearchFromIndex, comparisonType)) != valueNotFound) + { + // Append all characters until the found replacement. + var @charsUntilReplacment = foundAt - startSearchFromIndex; + var isNothingToAppend = @charsUntilReplacment == 0; + if (!isNothingToAppend) + { + resultStringBuilder.Append(str, startSearchFromIndex, @charsUntilReplacment); + } + + // Process the replacement. + if (!isReplacementNullOrEmpty) + { + resultStringBuilder.Append(@newValue); + } + + // Prepare start index for the next search. + // This needed to prevent infinite loop, otherwise method always start search + // from the start of the string. For example: if an oldValue == "EXAMPLE", newValue == "example" + // and comparisonType == "any ignore case" will conquer to replacing: + // "EXAMPLE" to "example" to "example" to "example" … infinite loop. + startSearchFromIndex = foundAt + oldValue.Length; + if (startSearchFromIndex == str.Length) + { + // It is end of the input string: no more space for the next search. + // The input string ends with a value that has already been replaced. + // Therefore, the string builder with the result is complete and no further action is required. + return resultStringBuilder.ToString(); + } + } + + // Append the last part to the result. + var @charsUntilStringEnd = str.Length - startSearchFromIndex; + resultStringBuilder.Append(str, startSearchFromIndex, @charsUntilStringEnd); + + return resultStringBuilder.ToString(); } } } \ No newline at end of file From 076ac3f69ab3ce350398bc3494e8918fa35ef9b4 Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 16:06:58 +0200 Subject: [PATCH 4/8] Add SourceFolder and SourceFolderUrl to support injecting files from either a local folder or a folder in the remote Web --- .../Site/AddFileToProvisioningTemplate.cs | 167 ++++++++++++------ 1 file changed, 115 insertions(+), 52 deletions(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index 5a1fc8e4b..c7b0c0eeb 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -21,21 +21,21 @@ namespace SharePointPnP.PowerShell.Commands.Provisioning.Site [CmdletHelp("Adds a file to a PnP Provisioning Template", Category = CmdletHelpCategory.Provisioning)] [CmdletExample( - Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source $sourceFilePath -Folder $targetFolder", - Remarks = "Adds a file to a PnP Site Template", - SortOrder = 1)] + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source $sourceFilePath -Folder $targetFolder", + Remarks = "Adds a file to a PnP Site Template", + SortOrder = 1)] [CmdletExample( - Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.xml -Source $sourceFilePath -Folder $targetFolder", - Remarks = "Adds a file reference to a PnP Site XML Template", - SortOrder = 2)] + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.xml -Source $sourceFilePath -Folder $targetFolder", + Remarks = "Adds a file reference to a PnP Site XML Template", + SortOrder = 2)] [CmdletExample( - Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source ""./myfile.png"" -Folder ""folderinsite"" -FileLevel Published -FileOverwrite:$false", - Remarks = "Adds a file to a PnP Site Template, specifies the level as Published and defines to not overwrite the file if it exists in the site.", - SortOrder = 3)] + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source ""./myfile.png"" -Folder ""folderinsite"" -FileLevel Published -FileOverwrite:$false", + Remarks = "Adds a file to a PnP Site Template, specifies the level as Published and defines to not overwrite the file if it exists in the site.", + SortOrder = 3)] [CmdletExample( - Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source $sourceFilePath -Folder $targetFolder -Container $container", - Remarks = "Adds a file to a PnP Site Template with a custom container for the file", - SortOrder = 4)] + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -Source $sourceFilePath -Folder $targetFolder -Container $container", + Remarks = "Adds a file to a PnP Site Template with a custom container for the file", + SortOrder = 4)] [CmdletExample( Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceUrl ""Shared%20Documents/ProjectStatus.docs""", Remarks = "Adds a file to a PnP Provisioning Template retrieved from the currently connected site. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", @@ -46,8 +46,10 @@ namespace SharePointPnP.PowerShell.Commands.Provisioning.Site SortOrder = 6)] public class AddFileToProvisioningTemplate : PnPWebCmdlet { - const string parameterSet_LOCALFILE = "Local File"; - const string parameterSet_REMOTEFILE = "Remote File"; + private const string parameterSet_LOCALFILE = "Local File"; + private const string parameterSet_REMOTEFILE = "Remote File"; + private const string parameterSet_LOCALFOLDER = "Local Folder"; + private const string parameterSet_REMOTEFOLDER = "Remote Folder"; [Parameter(Mandatory = true, Position = 0, HelpMessage = "Filename of the .PNP Open XML site template to read from, optionally including full path.")] public string Path; @@ -55,10 +57,17 @@ public class AddFileToProvisioningTemplate : PnPWebCmdlet [Parameter(Mandatory = true, Position = 1, ParameterSetName = parameterSet_LOCALFILE, HelpMessage = "The file to add to the in-memory template, optionally including full path.")] public string Source; - [Parameter(Mandatory = true, Position = 1, ParameterSetName = parameterSet_REMOTEFILE, HelpMessage = "The file to add to the in-memory template, specifying its url in the current connected Web.")] + [Parameter(Mandatory = true, Position = 1, ParameterSetName = parameterSet_REMOTEFILE, HelpMessage = "The folder where to search for files, to be added to the in-memory template, specifying its url in the current connected Web.")] public string SourceUrl; + [Parameter(Mandatory = true, Position = 1, ParameterSetName = parameterSet_LOCALFOLDER, HelpMessage = "The file to add to the in-memory template, optionally including full path.")] + public string SourceFolder; + + [Parameter(Mandatory = true, Position = 1, ParameterSetName = parameterSet_REMOTEFOLDER, HelpMessage = "The local folder where to search for files to be added to the in-memory template.")] + public string SourceFolderUrl; + [Parameter(Mandatory = true, Position = 2, ParameterSetName = parameterSet_LOCALFILE, HelpMessage = "The target Folder for the file to add to the in-memory template.")] + [Parameter(Mandatory = true, Position = 2, ParameterSetName = parameterSet_LOCALFOLDER, HelpMessage = "The target Folder for the files to add to the in-memory template.")] public string Folder; [Parameter(Mandatory = false, Position = 3, HelpMessage = "The target Container for the file to add to the in-memory template, optional argument.")] @@ -91,49 +100,102 @@ protected override void ProcessRecord() { throw new ApplicationException("Invalid template file!"); } - // Add a file from the connected Web - if (this.ParameterSetName == parameterSet_REMOTEFILE) + + if (ExtractWebParts && (this.ParameterSetName == parameterSet_REMOTEFILE || this.ParameterSetName == parameterSet_REMOTEFOLDER)) { - if (ExtractWebParts) - { - ClientContext.Load(SelectedWeb, web => web.Url, web => web.Id, web => web.ServerRelativeUrl); - ClientContext.Load(((ClientContext)SelectedWeb.Context).Site, site => site.Id, site => site.ServerRelativeUrl, site => site.Url); - ClientContext.Load(SelectedWeb.Lists, lists => lists.Include(l => l.Title, l => l.RootFolder.ServerRelativeUrl, l => l.Id)); - } + ClientContext.Load(SelectedWeb, web => web.Url, web => web.Id, web => web.ServerRelativeUrl); + ClientContext.Load(((ClientContext)SelectedWeb.Context).Site, site => site.Id, site => site.ServerRelativeUrl, site => site.Url); + ClientContext.Load(SelectedWeb.Lists, lists => lists.Include(l => l.Title, l => l.RootFolder.ServerRelativeUrl, l => l.Id)); ClientContext.ExecuteQuery(); + } + switch (this.ParameterSetName) + { + // Add a file from the connected Web + case parameterSet_REMOTEFILE: + { + var serverRelativeUrl = UrlToServerRelativeUrl(SourceUrl); + + var file = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); + AddSPFileToTemplate(template, file); + break; + } - var sourceUri = new Uri(SourceUrl, UriKind.RelativeOrAbsolute); - var serverRelativeUrl = - sourceUri.IsAbsoluteUri ? sourceUri.AbsolutePath : - SourceUrl.StartsWith("/", StringComparison.Ordinal) ? SourceUrl : - SelectedWeb.ServerRelativeUrl.TrimEnd('/') + "/" + SourceUrl; + case parameterSet_REMOTEFOLDER: + { + var serverRelativeUrl = UrlToServerRelativeUrl(SourceFolderUrl); + + var folder = SelectedWeb.GetFolderByServerRelativeUrl(serverRelativeUrl); + var files = folder.Files; + SelectedWeb.Context.Load(files); + SelectedWeb.Context.ExecuteQueryRetry(); + foreach (var file in files) + { + AddSPFileToTemplate(template, file); + } + break; + } + + case parameterSet_LOCALFILE: + { + if (!System.IO.Path.IsPathRooted(Source)) + { + Source = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, Source); + } - var file = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); - AddSPFileToTemplate(template, file); + Folder = Folder.Replace('\\', '/'); + // Load the file and add it to the .PNP file + AddLocalFile(template, Source, Folder, Container); + + break; + } + + case parameterSet_LOCALFOLDER: + { + if (!System.IO.Path.IsPathRooted(SourceFolder)) + { + SourceFolder = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, SourceFolder); + } + + var files = System.IO.Directory.GetFiles(SourceFolder); + var container = Container ?? System.IO.Path.GetFileName(Folder); // Default to name of the targeted folder + Folder = Folder.Replace('\\', '/'); + foreach (var file in files) + { + AddLocalFile(template, file, Folder, container); + } + + break; + } } - // Add a file from the file system - else - { - if (!System.IO.Path.IsPathRooted(Source)) - { - Source = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, Source); - } + } - // Load the file and add it to the .PNP file - using (var fs = System.IO.File.OpenRead(Source)) - { - Folder = Folder.Replace('\\', '/'); + private void AddLocalFile(ProvisioningTemplate template, string source, string folder, string container) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (source == null) throw new ArgumentNullException(nameof(source)); - var fileName = Source.IndexOf(System.IO.Path.DirectorySeparatorChar) > 0 - ? Source.Substring(Source.LastIndexOf(System.IO.Path.DirectorySeparatorChar) + 1) - : Source; - var container = !string.IsNullOrEmpty(Container) ? Container : string.Empty; - AddFileToTemplate(template, fs, Folder, fileName, container); - } + using (var fs = System.IO.File.OpenRead(source)) + { + var fileName = source.IndexOf(System.IO.Path.DirectorySeparatorChar) > 0 + ? source.Substring(source.LastIndexOf(System.IO.Path.DirectorySeparatorChar) + 1) + : source; + AddFileToTemplate(template, fs, folder, fileName, container ?? string.Empty); } } + private string UrlToServerRelativeUrl(string url) + { + if (url == null) throw new ArgumentNullException(nameof(url)); + + var sourceFolderUri = new Uri(url, UriKind.RelativeOrAbsolute); + var serverRelativeUrl = + sourceFolderUri.IsAbsoluteUri ? sourceFolderUri.AbsolutePath : + url.StartsWith("/", StringComparison.Ordinal) ? url : + SelectedWeb.ServerRelativeUrl.TrimEnd('/') + "/" + url; + return serverRelativeUrl; + } + private void AddSPFileToTemplate(ProvisioningTemplate template, SPFile file) { if (template == null) throw new ArgumentNullException(nameof(template)); @@ -191,6 +253,7 @@ private IEnumerable ExtractSPFileWebParts(SPFile file) } } } + private string Tokenize(string input) { if (string.IsNullOrEmpty(input)) return input; @@ -215,12 +278,12 @@ private string Tokenize(string input) private void AddFileToTemplate( ProvisioningTemplate template, - Stream fs, + Stream fs, string folder, - string fileName, + string fileName, string container, IEnumerable webParts = null - ) + ) { if (template == null) throw new ArgumentNullException(nameof(template)); if (fs == null) throw new ArgumentNullException(nameof(fs)); @@ -235,8 +298,8 @@ private void AddFileToTemplate( } var existing = template.Files.FirstOrDefault(f => - f.Src == $"{container}/{fileName}" - && f.Folder == folder); + f.Src == $"{container}/{fileName}" + && f.Folder == folder); if (existing != null) template.Files.Remove(existing); From 858424b902a0e92a8e2aef92269589303258cb6a Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 16:12:25 +0200 Subject: [PATCH 5/8] Add usage example --- .../Provisioning/Site/AddFileToProvisioningTemplate.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index c7b0c0eeb..b5b3a4ed4 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -44,6 +44,14 @@ namespace SharePointPnP.PowerShell.Commands.Provisioning.Site Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceUrl ""SitePages/Home.aspx"" -ExtractWebParts", Remarks = "Adds a file to a PnP Provisioning Template retrieved from the currently connected site. If the file is a classic page, also extract its webparts. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", SortOrder = 6)] + [CmdletExample( + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceFolderUrl ""Shared Documents""", + Remarks = "Adds the content of a remote folder to a PnP Provisioning Template retrieved from the currently connected site. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", + SortOrder = 7)] + [CmdletExample( + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceFolder ""c:\\data\reports"" -Folder ""Shared Documents""", + Remarks = "Adds the content of a local folder to a PnP Provisioning Template retrieved from the currently connected site.", + SortOrder = 8)] public class AddFileToProvisioningTemplate : PnPWebCmdlet { private const string parameterSet_LOCALFILE = "Local File"; From ba2aeb1413c70355f6496286fe071c7b1cc262d6 Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 16:19:30 +0200 Subject: [PATCH 6/8] Fix type --- Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index b5b3a4ed4..d14ecf0ce 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -49,7 +49,7 @@ namespace SharePointPnP.PowerShell.Commands.Provisioning.Site Remarks = "Adds the content of a remote folder to a PnP Provisioning Template retrieved from the currently connected site. The url can be server relative or web relative. If specifying a server relative url has to start with the current site url.", SortOrder = 7)] [CmdletExample( - Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceFolder ""c:\\data\reports"" -Folder ""Shared Documents""", + Code = @"PS:> Add-PnPFileToProvisioningTemplate -Path template.pnp -SourceFolder ""c:\\data\\reports"" -Folder ""Shared Documents""", Remarks = "Adds the content of a local folder to a PnP Provisioning Template retrieved from the currently connected site.", SortOrder = 8)] public class AddFileToProvisioningTemplate : PnPWebCmdlet From 7082dd088cd783f6e175ab477db61c4e200fb01c Mon Sep 17 00:00:00 2001 From: Steve B Date: Fri, 4 Oct 2019 11:28:49 +0200 Subject: [PATCH 7/8] Fix parameter set --- Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index d14ecf0ce..9b455a2c6 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -88,6 +88,7 @@ public class AddFileToProvisioningTemplate : PnPWebCmdlet public SwitchParameter FileOverwrite = true; [Parameter(Mandatory = false, Position = 6, ParameterSetName = parameterSet_REMOTEFILE, HelpMessage = "Include webparts if the file is a page")] + [Parameter(Mandatory = false, Position = 6, ParameterSetName = parameterSet_REMOTEFOLDER, HelpMessage = "Include webparts if the files are pages")] public SwitchParameter ExtractWebParts = true; [Parameter(Mandatory = false, Position = 4, HelpMessage = "Allows you to specify ITemplateProviderExtension to execute while loading the template.")] From 41787a5d17355ea9dc90949e23c699b1b8215b8b Mon Sep 17 00:00:00 2001 From: Steve B Date: Fri, 4 Oct 2019 15:57:15 +0200 Subject: [PATCH 8/8] More reliable tokenization --- .../Site/AddFileToProvisioningTemplate.cs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index 9b455a2c6..681909a00 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -12,6 +12,9 @@ using System.Linq; using System.Management.Automation; using System.Net; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; using PnPFileLevel = OfficeDevPnP.Core.Framework.Provisioning.Model.FileLevel; using SPFile = Microsoft.SharePoint.Client.File; @@ -58,6 +61,8 @@ public class AddFileToProvisioningTemplate : PnPWebCmdlet private const string parameterSet_REMOTEFILE = "Remote File"; private const string parameterSet_LOCALFOLDER = "Local Folder"; private const string parameterSet_REMOTEFOLDER = "Remote Folder"; + private const string webpartNSV2 = "http://schemas.microsoft.com/WebPart/v2"; + private const string webpartNSV3 = "http://schemas.microsoft.com/WebPart/v3"; [Parameter(Mandatory = true, Position = 0, HelpMessage = "Filename of the .PNP Open XML site template to read from, optionally including full path.")] public string Path; @@ -252,9 +257,11 @@ private IEnumerable ExtractSPFileWebParts(SPFile file) foreach (var spwp in SelectedWeb.GetWebParts(file.ServerRelativeUrl)) { spwp.EnsureProperties(wp => wp.WebPart, wp => wp.ZoneId); + var webPartDefinition = XElement.Parse(SelectedWeb.GetWebPartXml(spwp.Id, file.ServerRelativeUrl), LoadOptions.PreserveWhitespace); + var tokenizedDefinition = Tokenize(webPartDefinition); yield return new WebPart { - Contents = Tokenize(SelectedWeb.GetWebPartXml(spwp.Id, file.ServerRelativeUrl)), + Contents = tokenizedDefinition, Order = (uint)spwp.WebPart.ZoneIndex, Title = spwp.WebPart.Title, Zone = spwp.ZoneId @@ -263,6 +270,37 @@ private IEnumerable ExtractSPFileWebParts(SPFile file) } } + private static XmlNamespaceManager g_nsMgr = InitNamespaceManager(); + + private static XmlNamespaceManager InitNamespaceManager() + { + var result = new XmlNamespaceManager(new NameTable()); + result.AddNamespace("v3", webpartNSV3); + result.AddNamespace("v2", webpartNSV2); + return result; + } + + private string Tokenize(XElement webPartDefinition) + { + var propNodes = webPartDefinition.Name.Namespace == webpartNSV2 ? + webPartDefinition.Elements() : + webPartDefinition.XPathSelectElements("v3:webPart/v3:data/v3:properties/v3:property", g_nsMgr); + + foreach (var propNode in propNodes) + { + if (propNode.FirstNode is XCData cdataValue) + { + propNode.ReplaceNodes(new XCData(Tokenize(cdataValue.Value))); + } + else if (propNode.Value.Length > 0) + { + propNode.Value = Tokenize(propNode.Value); + } + } + + return webPartDefinition.ToString(); + } + private string Tokenize(string input) { if (string.IsNullOrEmpty(input)) return input; @@ -273,8 +311,8 @@ private string Tokenize(string input) if (!webRelativeUrl.StartsWith("_catalogs", StringComparison.Ordinal)) { input = input - .ReplaceCaseInsensitive(list.Id.ToString("D"), "{listid:" + list.Title + "}") - .ReplaceCaseInsensitive(webRelativeUrl, "{listurl:" + list.Title + "}"); + .ReplaceCaseInsensitive(list.Id.ToString("D"), "{listid:" + list.Title + "}"); + //.ReplaceCaseInsensitive(webRelativeUrl, "{listurl:" + list.Title + "}"); } } return input.ReplaceCaseInsensitive(SelectedWeb.Url, "{site}")