diff --git a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs index 4e6b7a521..275fea2d0 100644 --- a/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs +++ b/Commands/Provisioning/Site/AddFileToProvisioningTemplate.cs @@ -5,12 +5,18 @@ 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 System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; using PnPFileLevel = OfficeDevPnP.Core.Framework.Provisioning.Model.FileLevel; +using SPFile = Microsoft.SharePoint.Client.File; namespace SharePointPnP.PowerShell.Commands.Provisioning.Site { @@ -18,29 +24,45 @@ 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.", 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)] + [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 { - const string parameterSet_LOCALFILE = "Local File"; - const string parameterSet_REMOTEFILE = "Remove 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"; + 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; @@ -48,10 +70,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.")] @@ -63,6 +92,10 @@ 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")] + [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.")] public ITemplateProviderExtension[] TemplateProviderExtensions; @@ -84,61 +117,227 @@ protected override void ProcessRecord() { throw new ApplicationException("Invalid template file!"); } - if (this.ParameterSetName == parameterSet_REMOTEFILE) + + if (ExtractWebParts && (this.ParameterSetName == parameterSet_REMOTEFILE || this.ParameterSetName == parameterSet_REMOTEFOLDER)) + { + 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; + } + + 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); + } + + 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; + } + } + } + + 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)); + + 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)); + 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 { - SelectedWeb.EnsureProperty(w => w.ServerRelativeUrl); - var sourceUri = new Uri(SourceUrl, UriKind.RelativeOrAbsolute); - var serverRelativeUrl = - sourceUri.IsAbsoluteUri ? sourceUri.AbsolutePath : - SourceUrl.StartsWith("/", StringComparison.Ordinal) ? SourceUrl : - SelectedWeb.ServerRelativeUrl.TrimEnd('/') + "/" + SourceUrl; - - var file = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); - - var fileName = file.EnsureProperty(f => f.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 - var fi = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); + var fi = SelectedWeb.GetFileByServerRelativeUrl(serverRelativeUrl); #else - var fi = SelectedWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); + var fi = SelectedWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); #endif - var fileStream = fi.OpenBinaryStream(); - ClientContext.ExecuteQueryRetry(); - using (var ms = fileStream.Value) - { - AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl); - } + + IEnumerable webParts = null; + if (ExtractWebParts) + { + webParts = ExtractSPFileWebParts(file).ToArray(); } - catch (WebException exc) + + var fileStream = fi.OpenBinaryStream(); + ClientContext.ExecuteQueryRetry(); + using (var ms = fileStream.Value) { - WriteWarning($"Can't add file from url {serverRelativeUrl} : {exc}"); + AddFileToTemplate(template, ms, folderWebRelativeUrl, fileName, folderWebRelativeUrl, webParts); } } - else + 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)); + + if (string.Compare(System.IO.Path.GetExtension(file.Name), ".aspx", true) == 0) { - if (!System.IO.Path.IsPathRooted(Source)) + foreach (var spwp in SelectedWeb.GetWebParts(file.ServerRelativeUrl)) { - Source = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, Source); + 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 = tokenizedDefinition, + Order = (uint)spwp.WebPart.ZoneIndex, + Title = spwp.WebPart.Title, + Zone = spwp.ZoneId + }; } + } + } - // Load the file and add it to the .PNP file - using (var fs = System.IO.File.OpenRead(Source)) + 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) { - Folder = Folder.Replace("\\", "/"); + 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; - var fileName = Source.IndexOf("\\", StringComparison.Ordinal) > 0 ? Source.Substring(Source.LastIndexOf("\\") + 1) : Source; - var container = !string.IsNullOrEmpty(Container) ? Container : string.Empty; - AddFileToTemplate(template, fs, Folder, fileName, container); + 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) + 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); @@ -149,8 +348,8 @@ private void AddFileToTemplate(ProvisioningTemplate template, Stream fs, string } 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); @@ -163,6 +362,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 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