diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a798a5a..df5b8ec92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added `Get-PnPFlowOwner` cmdlet which allows retrieving the owners of a Power Automate flow [#3314](https://github.com/pnp/powershell/pull/3314) - Added `-AvailableForTagging` to `Set-PnPTerm` which allows the available for tagging property on a Term to be set [#3321](https://github.com/pnp/powershell/pull/3321) - Added `Get-PnPPowerPlatformConnector` cmdlet which allows for all custom connectors to be retrieved [#3309](https://github.com/pnp/powershell/pull/3309) +- Added `Convert-PnPFileToPdf` cmdlet which allows for a file to be converted to PDF [#3435](https://github.com/pnp/powershell/pull/3435) - Added `Set-PnPSearchExternalItem` cmdlet which allows ingesting external items into the Microsoft Search index for custom connectors. [#3420](https://github.com/pnp/powershell/pull/3420) - Added `Get-PnPTenantInfo` which allows retrieving tenant information by its Id or domain name [#3414](https://github.com/pnp/powershell/pull/3414) - Added option to create a Microsoft 365 Group with dynamic membership by passing in `-DynamicMembershipRule` [#3426](https://github.com/pnp/powershell/pull/3426) @@ -65,6 +66,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added the ability to set Manage and FullControl permissions directly when using Sites.Selected with `Grant-PnPAzureADAppSitePermission` [#3617](https://github.com/pnp/powershell/pull/3617) - Added `Remove-PnPMicrosoft365GroupPhoto` cmdlet which allows removal of profile picture of M365 Group. [#3607](https://github.com/pnp/powershell/pull/3607) + ### Fixed - Fixed `Add-PnPContentTypeToList` cmdlet to better handle piped lists. [#3244](https://github.com/pnp/powershell/pull/3244) diff --git a/documentation/Convert-PnPFileToPdf.md b/documentation/Convert-PnPFileToPdf.md new file mode 100644 index 000000000..658e50ee1 --- /dev/null +++ b/documentation/Convert-PnPFileToPdf.md @@ -0,0 +1,151 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Convert-PnPFileToPDF.html +external help file: PnP.PowerShell.dll-Help.xml +title: Convert-PnPFileToPDF +--- + +# Convert-PnPFileToPDF + +## SYNOPSIS +Converts a file to Pdf + +## SYNTAX + + +### Save to local path +```powershell +Convert-PnPFileToPDF -Url -Path [-Force] +``` + +### Return as memorystream +```powershell +Convert-PnPFileToPDF -Url -AsMemoryStream +``` + +### Save to SharePoint Online (Same SiteCollection) +```powershell +Convert-PnPFileToPDF -Url -Folder +``` + +## DESCRIPTION +Allows converting of a file from SharePoint Online. The file contents can either be directly saved to local disk, or stored in memory for further processing, or Can be uploaded back to SharePoint Online SiteCollection + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Convert-PnPFileToPDF -Url "/sites/project/Shared Documents/Document.docx" -AsMemoryStream +``` + +Retrieves the file and converts to PDF, and outputs its content to the console as a Memory Stream + +### EXAMPLE 2 +```powershell +Convert-PnPFileToPDF -Url "/sites/project/Shared Documents/Document.docx" +``` + +Retrieves the file and converts to PDF, and outputs its content to the console as a Memory Stream + +### EXAMPLE 3 +```powershell +Convert-PnPFileToPDF -Url "/sites/project/Shared Documents/Document.docx" -Path "C:\Temp" +``` + +Retrieves the file and converts to PDF, and save it to the given local path + +### EXAMPLE 4 +```powershell +Convert-PnPFileToPDF -Url "/sites/project/Shared Documents/Document.docx" -Path "C:\Temp" -Force +``` + +Retrieves the file and converts to PDF, and save it to the given local path. Force parameter will override the existing file in the location where the document gets saved. + +### EXAMPLE 5 +```powershell +Convert-PnPFileToPDF -Url "/sites/SampleTeamSite/Shared Documents/Nishkalank's/Book.xlsx.docx" -Folder "Archive" +``` + +Retrieves the file and converts to PDF, and save it to the given Document library (Folder) in SharePoint Online (Same SiteCollection). Returns the saved file information in the console. + + + +## PARAMETERS + +### -Url +The URL (server or site relative) to the file + +```yaml +Type: String +Parameter Sets: (All) +Aliases: ServerRelativeUrl, SiteRelativeUrl + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + + +### -AsMemoryStream + +```yaml +Type: SwitchParameter +Parameter Sets: Return as memorystream + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path +Local path where the file should be saved + +```yaml +Type: String +Parameter Sets: Save to local path + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + + +### -Force +Overwrites the file if it exists. + +```yaml +Type: SwitchParameter +Parameter Sets: Save to local path + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Folder +The destination library in the site + +```yaml +Type: FolderPipeBind +Parameter Sets: (UPLOADTOSHAREPOINT) + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Files/ConvertFileToPdf.cs b/src/Commands/Files/ConvertFileToPdf.cs new file mode 100644 index 000000000..5bd29e31f --- /dev/null +++ b/src/Commands/Files/ConvertFileToPdf.cs @@ -0,0 +1,153 @@ +using Microsoft.SharePoint.Client; +using PnP.Framework.Utilities; +using System.Management.Automation; +using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Utilities.REST; +using System.IO; +using PnP.PowerShell.Commands.Base.PipeBinds; + +namespace PnP.PowerShell.Commands.Files +{ + [Cmdlet(VerbsData.Convert, "PnPFileToPDF")] + public class ConvertFileToPDF : PnPWebCmdlet + { + private const string URLTOPATH = "Save to local path"; + private const string URLASMEMORYSTREAM = "Return as memorystream"; + private const string UPLOADTOSHAREPOINT = "Upload to SharePoint"; + + [Parameter(Mandatory = true, ParameterSetName = URLTOPATH, Position = 0, ValueFromPipeline = true)] + [Parameter(Mandatory = true, ParameterSetName = URLASMEMORYSTREAM, Position = 0, ValueFromPipeline = true)] + [Parameter(Mandatory = true, ParameterSetName = UPLOADTOSHAREPOINT, Position = 0, ValueFromPipeline = true)] + [Alias("ServerRelativeUrl", "SiteRelativeUrl")] + public string Url; + + [Parameter(Mandatory = true, ParameterSetName = URLTOPATH)] + public string Path = string.Empty; + + [Parameter(Mandatory = false, ParameterSetName = URLTOPATH)] + public SwitchParameter Force; + + [Parameter(Mandatory = true, ParameterSetName = UPLOADTOSHAREPOINT)] + [ValidateNotNullOrEmpty] + public FolderPipeBind Folder; + + [Parameter(Mandatory = false, ParameterSetName = URLASMEMORYSTREAM)] + public SwitchParameter AsMemoryStream; + + protected override void ExecuteCmdlet() + { + if (string.IsNullOrEmpty(Path)) + { + Path = SessionState.Path.CurrentFileSystemLocation.Path; + } + else if (!System.IO.Path.IsPathRooted(Path)) + { + Path = System.IO.Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, Path); + } + + Url = Utilities.UrlUtilities.UrlDecode(Url.Replace("+", "%2B")); + var serverRelativeUrl = GetServerRelativeUrl(Url); + var fileObj = GetFileByServerRelativePath(serverRelativeUrl); + var sourceFileName = fileObj.Name.ToString().Substring(0, fileObj.Name.ToString().LastIndexOf(".")); + var apiUrl = GeneratePdfApiUrl(Url, sourceFileName, fileObj); + byte[] response = RestHelper.GetByteArrayAsync(Connection.HttpClient, apiUrl, GraphAccessToken).GetAwaiter().GetResult(); + var fileToDownloadName = !string.IsNullOrEmpty(sourceFileName) ? sourceFileName : "Download"; + + switch (ParameterSetName) + { + case URLTOPATH: + var fileOut = System.IO.Path.Combine(Path, $"{fileToDownloadName}.pdf"); + if (!Directory.Exists(Path)) + { + throw new PSArgumentException("Path does not exists"); + } + if (!Force && System.IO.File.Exists(fileOut)) + { + WriteWarning($"File '{fileToDownloadName}' exists already. Use the -Force parameter to overwrite the file."); + } + else + { + System.IO.File.WriteAllBytes(fileOut, response); + WriteObject($"File saved as {fileOut}"); + } + break; + + case URLASMEMORYSTREAM: + var stream = new MemoryStream(response); + WriteObject(stream); + break; + + case UPLOADTOSHAREPOINT: + var targetLibrary = EnsureFolder(); + Stream fileStream = new MemoryStream(response); + var targetFileName = $"{fileToDownloadName}.pdf"; + var uploadedFile = targetLibrary.UploadFileAsync(targetFileName, fileStream, true).GetAwaiter().GetResult(); + ClientContext.Load(uploadedFile); + try + { + ClientContext.ExecuteQueryRetry(); + } + catch (ServerException) + { + ClientContext.Load(uploadedFile, f => f.Length, f => f.Name, f => f.TimeCreated, f => f.TimeLastModified, f => f.Title); + ClientContext.ExecuteQueryRetry(); + } + WriteObject(uploadedFile); + break; + } + } + + private string GetServerRelativeUrl(string url) + { + var webUrl = CurrentWeb.EnsureProperty(w => w.ServerRelativeUrl); + return url.ToLower().StartsWith(webUrl.ToLower()) ? url : UrlUtility.Combine(webUrl, Url); + } + + private Microsoft.SharePoint.Client.File GetFileByServerRelativePath(string serverRelativeUrl) + { + var fileListItem = CurrentWeb.GetFileByServerRelativePath(ResourcePath.FromDecodedUrl(serverRelativeUrl)); + ClientContext.Load(fileListItem, f => f.Exists, f => f.ListItemAllFields, f => f.ListId, f => f.Name); + ClientContext.ExecuteQueryRetry(); + if (fileListItem.Exists) + { + return fileListItem; + } + else + { + throw new PSArgumentException($"No file found with the provided Url {serverRelativeUrl}", "Url"); + } + } + + private string GeneratePdfApiUrl(string url, string sourceFileName, Microsoft.SharePoint.Client.File fileObj) + { + var siteId = PnPContext.Site.Id.ToString(); + var listId = fileObj.ListId.ToString(); + var itemId = fileObj.ListItemAllFields.Id.ToString(); + return $"https://{Connection.GraphEndPoint}/v1.0/sites/{siteId}/lists/{listId}/items/{itemId}/driveItem/content?format=pdf"; + } + + + private Folder EnsureFolder() + { + // First try to get the folder if it exists already. This avoids an Access Denied exception if the current user doesn't have Full Control access at Web level + CurrentWeb.EnsureProperty(w => w.ServerRelativeUrl); + + Folder library = null; + try + { + library = Folder.GetFolder(CurrentWeb); + library.EnsureProperties(f => f.ServerRelativeUrl); + return library; + } + // Exception will be thrown if the library does not exist yet on SharePoint + catch (ServerException serverEx) when (serverEx.ServerErrorCode == -2147024894) + { + // create the library + CurrentWeb.CreateList(ListTemplateType.DocumentLibrary, Folder.ServerRelativeUrl, false, true, "", false, false); + library = Folder.GetFolder(CurrentWeb); + library.EnsureProperties(f => f.ServerRelativeUrl); + return library; + } + } + } +} diff --git a/src/Commands/Utilities/REST/RestHelper.cs b/src/Commands/Utilities/REST/RestHelper.cs index b159e78df..c340be8ee 100644 --- a/src/Commands/Utilities/REST/RestHelper.cs +++ b/src/Commands/Utilities/REST/RestHelper.cs @@ -153,6 +153,12 @@ public static async Task GetAsync(HttpClient httpClient, string url, str return await SendMessageAsync(httpClient, message); } + public static async Task GetByteArrayAsync(HttpClient httpClient, string url, string accessToken, string accept = "application/json") + { + var message = GetMessage(url, HttpMethod.Get, accessToken, accept); + return await SendMessageByteArrayAsync(httpClient, message); + } + public static async Task GetAsync(HttpClient httpClient, string url, ClientContext clientContext, string accept = "application/json") { var message = GetMessage(url, HttpMethod.Get, clientContext, accept); @@ -649,6 +655,27 @@ private static async Task SendMessageAsync(HttpClient httpClient, HttpRe } } + private static async Task SendMessageByteArrayAsync(HttpClient httpClient, HttpRequestMessage message) + { + var response = await httpClient.SendAsync(message); + while (response.StatusCode == (HttpStatusCode)429) + { + // throttled + var retryAfter = response.Headers.RetryAfter; + await Task.Delay(retryAfter.Delta.Value.Seconds * 1000); + response = await httpClient.SendAsync(CloneMessage(message)); + } + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsByteArrayAsync(); + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException($"HTTP Error {response.StatusCode}: {errorContent}"); + } + } + private static async Task SendMessageAsyncGetResponseHeader(HttpClient httpClient, HttpRequestMessage message) { var response = await httpClient.SendAsync(message);