From 8f3bb53057d448045f27bb9edfe0c7bf3595e65c Mon Sep 17 00:00:00 2001 From: Steve B Date: Thu, 3 Oct 2019 11:04:34 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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