diff --git a/documentation/Restore-PnPRecycleBinItem.md b/documentation/Restore-PnPRecycleBinItem.md index 8131c6507..c4d4c02da 100644 --- a/documentation/Restore-PnPRecycleBinItem.md +++ b/documentation/Restore-PnPRecycleBinItem.md @@ -18,9 +18,12 @@ Restores the provided recycle bin item to its original location. Restore-PnPRecycleBinItem -Identity [-Force] [-RowLimit ] [-Connection ] ``` +```powershell +Restore-PnPRecycleBinItem -IdList [-Connection ] +``` ## DESCRIPTION -This cmdlet restores the specified item from the recycle bin to its original location. +This cmdlet restores the specified item or set of items from the recycle bin to its original location. ## EXAMPLES @@ -29,14 +32,14 @@ This cmdlet restores the specified item from the recycle bin to its original loc Restore-PnPRecycleBinItem -Identity 72e4d749-d750-4989-b727-523d6726e442 ``` -Restores the recycle bin item with Id 72e4d749-d750-4989-b727-523d6726e442 to its original location. +Restores the recycle bin item with Id 72e4d749-d750-4989-b727-523d6726e442 to its original location asking for confirmation to do so. ### EXAMPLE 2 ```powershell Get-PnPRecycleBinItem | ? -Property LeafName -like "*.docx" | Restore-PnPRecycleBinItem ``` -Restores all the items of which the filename ends with the .docx extension from the first and second stage recycle bins to their original location. +Restores all the items of which the filename ends with the .docx extension from the first and second stage recycle bins to their original location asking for confirmation to do so. ### EXAMPLE 3 ```powershell @@ -45,6 +48,13 @@ Get-PnPRecycleBinItem -RowLimit 10000 | Restore-PnPRecycleBinItem -Force Permanently restores up to 10,000 items in the recycle bin without asking for confirmation. +### EXAMPLE 4 +```powershell +Restore-PnPRecycleBinItem -IdList @("31897b05-fd3b-4c49-9898-2e7f10e59cac","b16f0733-9b07-4ef3-a4b6-896edca4babd", "367ef9d2-6080-45ea-9a03-e8c9029f59dd") +``` + +Restores the recycle bin items with Id 31897b05-fd3b-4c49-9898-2e7f10e59cac, b16f0733-9b07-4ef3-a4b6-896edca4babd, 367ef9d2-6080-45ea-9a03-e8c9029f59dd to their original location. + ## PARAMETERS ### -Connection @@ -66,7 +76,7 @@ If provided, no confirmation will be asked to restore the recycle bin item. ```yaml Type: SwitchParameter -Parameter Sets: (All) +Parameter Sets: (Restore Single Item By Id) Required: False Position: Named @@ -80,7 +90,7 @@ Id of the recycle bin item or the recycle bin item object itself to restore. ```yaml Type: RecycleBinItemPipeBind -Parameter Sets: (All) +Parameter Sets: (Restore Single Item By Id) Required: False Position: Named @@ -90,11 +100,11 @@ Accept wildcard characters: False ``` ### -RowLimit -Limits restoration to specified number of items. +Limits restoration to a specified number of items. ```yaml Type: Int32 -Parameter Sets: (All) +Parameter Sets: (Restore Single Item By Id) Required: False Position: Named @@ -102,8 +112,20 @@ Default value: None Accept pipeline input: False Accept wildcard characters: False ``` +### -IdList +Array of recycle bin item GUIDs -## RELATED LINKS +```yaml +Type: String[] +Parameter Sets: (Restore Multiple Items By Id) -[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) +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) \ No newline at end of file diff --git a/src/Commands/Properties/launchSettings.json b/src/Commands/Properties/launchSettings.json index 6f38dd905..ac107e695 100644 --- a/src/Commands/Properties/launchSettings.json +++ b/src/Commands/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "profiles": { + "profiles": { "PnP.PowerShell-Module": { "commandName": "Executable", "executablePath": "pwsh", diff --git a/src/Commands/RecycleBin/RestoreRecycleBinItem.cs b/src/Commands/RecycleBin/RestoreRecycleBinItem.cs index 5b2fc76f4..9a799ae4d 100644 --- a/src/Commands/RecycleBin/RestoreRecycleBinItem.cs +++ b/src/Commands/RecycleBin/RestoreRecycleBinItem.cs @@ -1,75 +1,39 @@ -using Microsoft.SharePoint.Client; -using PnP.PowerShell.Commands.Base.PipeBinds; +using PnP.PowerShell.Commands.Base.PipeBinds; using PnP.PowerShell.Commands.Utilities; using System.Management.Automation; -using Resources = PnP.PowerShell.Commands.Properties.Resources; -namespace PnP.PowerShell.Commands.RecycleBin +namespace PnP.PowerShell.Commands.RecycleBin; +[Cmdlet(VerbsData.Restore, "PnPRecycleBinItem")] +[OutputType(typeof(void))] +public class RestoreRecycleBinItem : PnPSharePointCmdlethttps://github.com/pnp/powershell/pull/5199/conflict?name=src%252FCommands%252FUtilities%252FRecycleBinUtility.cs&ancestor_oid=464e3967380956cbb32b389afb9657ae6b9b2916&base_oid=09a7f7d9d33cfaea67f77b29022ef9e3f2a2f341&head_oid=8b1b9c9d30f37b07ac1d51cfd1739d24181fb366 { - [Cmdlet(VerbsData.Restore, "PnPRecycleBinItem")] - [OutputType(typeof(void))] - public class RestoreRecycleBinItem : PnPSharePointCmdlet - { - [Parameter(Mandatory = false, ValueFromPipeline = true)] - public RecycleBinItemPipeBind Identity; + private const string ParameterSetName_RESTORE_MULTIPLE_ITEMS_BY_ID = "Restore Multiple Items By Id"; + private const string ParameterSetName_RESTORE_SINGLE_ITEM_BY_ID = "Restore Single Items By Id"; + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetName_RESTORE_SINGLE_ITEM_BY_ID, Position = 0, ValueFromPipeline = true)] + public RecycleBinItemPipeBind Identity; - [Parameter(Mandatory = false)] - public SwitchParameter Force; + [Parameter(Mandatory = false, ParameterSetName = ParameterSetName_RESTORE_SINGLE_ITEM_BY_ID)] + public SwitchParameter Force; - [Parameter(Mandatory = false)] - public int RowLimit; + [Parameter(Mandatory = false, ParameterSetName = ParameterSetName_RESTORE_SINGLE_ITEM_BY_ID)] + public int RowLimit; - protected override void ExecuteCmdlet() + [Parameter(Mandatory = true, ParameterSetName = ParameterSetName_RESTORE_MULTIPLE_ITEMS_BY_ID)] + public string[] IdList; + + protected override void ExecuteCmdlet() + { + switch (ParameterSetName) { - if (ParameterSpecified(nameof(Identity))) - { - // if Identity has item, use it - if (Identity.Item != null) - { - if (Force || ShouldContinue(string.Format(Resources.RestoreRecycleBinItem, Identity.Item.LeafName), Resources.Confirm)) - { - Identity.Item.Restore(); - ClientContext.ExecuteQueryRetry(); - } - } - else - { - var recycleBinItem = Identity.GetRecycleBinItem(Connection.PnPContext); + case ParameterSetName_RESTORE_SINGLE_ITEM_BY_ID: + RecycleBinUtility.RestoreRecycleBinItemSingle(ClientContext, this); + break; - if (recycleBinItem == null) - { - throw new PSArgumentException("Recycle bin item not found with the ID specified", nameof(Identity)); - } + case ParameterSetName_RESTORE_MULTIPLE_ITEMS_BY_ID: + RecycleBinUtility.RestoreRecycleBinItemInBulk(HttpClient, ClientContext, IdList, this); + break; - if (Force || ShouldContinue(string.Format(Resources.RestoreRecycleBinItem, recycleBinItem.LeafName), Resources.Confirm)) - { - recycleBinItem.Restore(); - } - } - } - else - { - if (ParameterSpecified(nameof(RowLimit))) - { - if (Force || ShouldContinue(string.Format(Resources.Restore0RecycleBinItems, RowLimit), Resources.Confirm)) - { - var recycleBinItemCollection = RecycleBinUtility.GetRecycleBinItemCollection(ClientContext, RowLimit, RecycleBinItemState.None); - for (var i = 0; i < recycleBinItemCollection.Count; i++) - { - var recycleBinItems = recycleBinItemCollection[i]; - recycleBinItems.RestoreAll(); - ClientContext.ExecuteQueryRetry(); - } - } - } - else - { - if (Force || ShouldContinue(Resources.RestoreRecycleBinItems, Resources.Confirm)) - { - Connection.PnPContext.Site.RecycleBin.RestoreAll(); - } - } - } } } } diff --git a/src/Commands/Utilities/RecycleBinUtility.cs b/src/Commands/Utilities/RecycleBinUtility.cs index 8b1b9c9d3..09a7f7d9d 100644 --- a/src/Commands/Utilities/RecycleBinUtility.cs +++ b/src/Commands/Utilities/RecycleBinUtility.cs @@ -1,106 +1,247 @@ using Microsoft.SharePoint.Client; +using System; using System.Collections.Generic; using System.Linq; +using System.Management.Automation; using System.Net; +using System.Net.Http; -namespace PnP.PowerShell.Commands.Utilities +namespace PnP.PowerShell.Commands.Utilities; + +/// +/// Class containing utility methods for handling SharePoint Recycle Bin operations. +/// +internal static class RecycleBinUtility { - internal static class RecycleBinUtility + /// + /// Retrieves recycle bin items from the site collection with support for paging to handle large result sets. + /// Implements batching to avoid exceeding the List View Threshold (5000 items). + /// + /// The client context for the SharePoint site + /// Optional maximum number of items to retrieve + /// The recycle bin stage to query (first stage, second stage, or both) + /// A list of recycle bin items + internal static List GetRecycleBinItems(ClientContext ctx, int? rowLimit = null, RecycleBinItemState recycleBinStage = RecycleBinItemState.None) { - internal static List GetRecycleBinItems(ClientContext ctx, int? rowLimit = null, RecycleBinItemState recycleBinStage = RecycleBinItemState.None) + var recycleBinItems = new List(); + string pagingInfo = null; + RecycleBinItemCollection items; + + // This part is only here to make debugging easier if you ever run into issues with this code :) + //ctx.Load(ctx.Site.RecycleBin); + //ctx.ExecuteQueryRetry(); + //var totalRecyclebinContentsCount = ctx.Site.RecycleBin.Count; + + do { - var recycleBinItems = new List(); - string pagingInfo = null; - RecycleBinItemCollection items; + // We don't actually know what the List View Threshold for the Recycle Bin is, so we'll use the safe number (5000) and implement paging. + int iterationRowLimit; + if (rowLimit.HasValue && rowLimit.Value >= 5000) + { + // Subtract this page's count from the rowLimit (we don't want duplicates or go out of bounds) + if (rowLimit.HasValue) rowLimit -= 5000; + + iterationRowLimit = 5000; + } + else if (rowLimit.HasValue && rowLimit.Value > 0 && rowLimit.Value < 5000) + { + iterationRowLimit = rowLimit.Value; + } + else // rowLimit was not set, just fetch a "whole page" + { + iterationRowLimit = 5000; + } - // This part is only here to make debugging easier if you ever run into issues with this code :) - //ctx.Load(ctx.Site.RecycleBin); - //ctx.ExecuteQueryRetry(); - //var totalRecyclebinContentsCount = ctx.Site.RecycleBin.Count; + items = ctx.Site.GetRecycleBinItems(pagingInfo, iterationRowLimit, false, RecycleBinOrderBy.DefaultOrderBy, recycleBinStage); + ctx.Load(items); + ctx.ExecuteQueryRetry(); + recycleBinItems.AddRange(items.ToList()); - do + // Paging magic (if needed) + // Based on this work our good friends at Portiva did ❤ + // https://www.portiva.nl/portiblog/blogs-cat/paging-through-sharepoint-recycle-bin + if (items.Count > 0) { - // We don't actually know what the List View Threshold for the Recycle Bin is, so we'll use the safe number (5000) and implement paging. - int iterationRowLimit; - if (rowLimit.HasValue && rowLimit.Value >= 5000) - { - // Subtract this page's count from the rowLimit (we don't want duplicates or go out of bounds) - if (rowLimit.HasValue) rowLimit -= 5000; + var nextId = items.Last().Id; + //var nextTitle = items.Last().Title; + var nextTitle = WebUtility.UrlEncode(items.Last().Title); + //var deletionTime = items.Last().DeletedDate; + pagingInfo = $"id={nextId}&title={nextTitle}"; // &searchValue=${deletionTime} + } + } + while (items?.Count == 5000); // if items had 5000 items, there might be more since that's the page size we're using - iterationRowLimit = 5000; - } - else if (rowLimit.HasValue && rowLimit.Value > 0 && rowLimit.Value < 5000) - { - iterationRowLimit = rowLimit.Value; - } - else // rowLimit was not set, just fetch a "whole page" - { - iterationRowLimit = 5000; - } + return recycleBinItems; + } - items = ctx.Site.GetRecycleBinItems(pagingInfo, iterationRowLimit, false, RecycleBinOrderBy.DeletedDate, recycleBinStage); - ctx.Load(items); - ctx.ExecuteQueryRetry(); - recycleBinItems.AddRange(items.ToList()); + /// + /// Retrieves recycle bin item collections from the site collection with support for paging. + /// Similar to GetRecycleBinItems but returns collections instead of individual items. + /// + /// The client context for the SharePoint site + /// Optional maximum number of items to retrieve per collection + /// The recycle bin stage to query + /// A list of recycle bin item collections + internal static List GetRecycleBinItemCollection(ClientContext ctx, int? rowLimit = null, RecycleBinItemState recycleBinItemState = RecycleBinItemState.None) + { + string pagingInfo = null; + RecycleBinItemCollection items; + var recycleBinItems = new List(); - // Paging magic (if needed) - // Based on this work our good friends at Portiva did ❤ - // https://www.portiva.nl/portiblog/blogs-cat/paging-through-sharepoint-recycle-bin - if (items.Count > 0) - { - var nextId = items.Last().Id; - //var nextTitle = items.Last().Title; - var nextTitle = WebUtility.UrlEncode(items.Last().Title); - //var deletionTime = items.Last().DeletedDate; - pagingInfo = $"id={nextId}&title={nextTitle}"; // &searchValue=${deletionTime} - } + do + { + // We don't actually know what the List View Threshold for the Recycle Bin is, so we'll use the safe number (5000) and implement paging. + int iterationRowLimit; + if (rowLimit.HasValue && rowLimit.Value >= 5000) + { + // Subtract this page's count from the rowLimit (we don't want duplicates or go out of bounds) + if (rowLimit.HasValue) rowLimit -= 5000; + + iterationRowLimit = 5000; + } + else if (rowLimit.HasValue && rowLimit.Value > 0 && rowLimit.Value < 5000) + { + iterationRowLimit = rowLimit.Value; } - while (items?.Count == 5000); // if items had 5000 items, there might be more since that's the page size we're using + else + { + iterationRowLimit = 5000; + } + + items = ctx.Site.GetRecycleBinItems(pagingInfo, iterationRowLimit, false, RecycleBinOrderBy.DefaultOrderBy, recycleBinItemState); + ctx.Load(items); + ctx.ExecuteQueryRetry(); + recycleBinItems.Add(items); - return recycleBinItems; + if (items.Count > 0) + { + var nextId = items.Last().Id; + var nextTitle = WebUtility.UrlEncode(items.Last().Title); + pagingInfo = $"id={nextId}&title={nextTitle}"; + } } + while (items?.Count == 5000); - internal static List GetRecycleBinItemCollection(ClientContext ctx, int? rowLimit = null, RecycleBinItemState recycleBinItemState = RecycleBinItemState.None) - { - string pagingInfo = null; - RecycleBinItemCollection items; - var recycleBinItems = new List(); + return recycleBinItems; + } + + /// + /// Restores multiple recycle bin items by their IDs using the SharePoint REST API. + /// Attempts batch restore first, then falls back to individual restore if batch fails. + /// + /// HTTP client for making REST API calls + /// The client context for the SharePoint site + /// Array of recycle bin item IDs to restore + /// The cmdlet instance for logging verbose output + internal static void RestoreRecycleBinItemInBulk(HttpClient httpClient, ClientContext ctx, string[] idsList, RecycleBin.RestoreRecycleBinItem restoreRecycleBinItem) + { + //restoreRecycleBinItem provides us the reference to the instance of RestoreRecycleBinItem object. We use this object to log key information as verbose + Uri currentContextUri = new Uri(ctx.Url); + string apiCall = $"{currentContextUri}/_api/site/RecycleBin/RestoreByIds"; + + string idsString = string.Join("','", idsList); // Convert array to a comma-separated string - do + try + { + string requestBody = $"{{'ids':['{idsString}']}}"; + REST.RestHelper.Post(httpClient, apiCall, ctx, requestBody, "application/json", "application/json"); + restoreRecycleBinItem.WriteVerbose("Whole batch restored successfuly."); + } + catch (Exception ex) + { { - // We don't actually know what the List View Threshold for the Recycle Bin is, so we'll use the safe number (5000) and implement paging. - int iterationRowLimit; - if (rowLimit.HasValue && rowLimit.Value >= 5000) + //fall back logic + //Unable to process as batch because of an error in restoring one of the ids in batch, processing individually + restoreRecycleBinItem.WriteVerbose($"Unable to process as batch because of an error in restoring one of the ids in batch. Error:{ex.Message}"); + restoreRecycleBinItem.WriteVerbose($"Switching to individual restore of items ..."); + + foreach (string id in idsList) { - // Subtract this page's count from the rowLimit (we don't want duplicates or go out of bounds) - if (rowLimit.HasValue) rowLimit -= 5000; + try + { + string requestBody = $"{{'ids':['{id}']}}"; + REST.RestHelper.Post(httpClient, apiCall, ctx, requestBody, "application/json", "application/json"); + restoreRecycleBinItem.WriteVerbose($"Item - {id} restored successfuly."); - iterationRowLimit = 5000; + } + catch (Exception e) + { + var odataError = e.Message; + if (odataError != null) + { + if (odataError.Contains("Value does not fall within the expected range.")) + { + restoreRecycleBinItem.WriteVerbose($"Item - {id} already restored."); + } + else + { + //Most common reason is that an item with the same name already exists. To restore the item, rename the existing item and try again + restoreRecycleBinItem.WriteVerbose($"Item - {id} restore failed. Error:{odataError}"); + } + } + //Digest errors because we cannot do anything + } } - else if (rowLimit.HasValue && rowLimit.Value > 0 && rowLimit.Value < 5000) + } + } + } + + /// + /// Restores a single recycle bin item, multiple items by row limit, or all items. + /// Handles different scenarios: specific item by identity, limited batch by row count, or all items. + /// + /// The client context for the SharePoint site + /// The cmdlet instance containing parameters and for logging + internal static void RestoreRecycleBinItemSingle(ClientContext ctx, RecycleBin.RestoreRecycleBinItem restoreRecycleBinItem) + { + if (restoreRecycleBinItem.ParameterSpecified(nameof(restoreRecycleBinItem.Identity))) + { + // if Identity has item, use it + if (restoreRecycleBinItem.Identity.Item != null) + { + if (restoreRecycleBinItem.Force || restoreRecycleBinItem.ShouldContinue(string.Format(Properties.Resources.RestoreRecycleBinItem, restoreRecycleBinItem.Identity.Item.LeafName), Properties.Resources.Confirm)) { - iterationRowLimit = rowLimit.Value; + restoreRecycleBinItem.Identity.Item.Restore(); + ctx.ExecuteQueryRetry(); } - else + } + else + { + var recycleBinItem = restoreRecycleBinItem.Identity.GetRecycleBinItem(restoreRecycleBinItem.Connection.PnPContext); + + if (recycleBinItem == null) { - iterationRowLimit = 5000; + throw new PSArgumentException("Recycle bin item not found with the ID specified", nameof(restoreRecycleBinItem.Identity)); } - items = ctx.Site.GetRecycleBinItems(pagingInfo, iterationRowLimit, false, RecycleBinOrderBy.DeletedDate, recycleBinItemState); - ctx.Load(items); - ctx.ExecuteQueryRetry(); - recycleBinItems.Add(items); - - if (items.Count > 0) + if (restoreRecycleBinItem.Force || restoreRecycleBinItem.ShouldContinue(string.Format(Properties.Resources.RestoreRecycleBinItem, recycleBinItem.LeafName), Properties.Resources.Confirm)) + { + recycleBinItem.Restore(); + } + } + } + else + { + if (restoreRecycleBinItem.ParameterSpecified(nameof(restoreRecycleBinItem.RowLimit))) + { + if (restoreRecycleBinItem.Force || restoreRecycleBinItem.ShouldContinue(string.Format(Properties.Resources.Restore0RecycleBinItems, restoreRecycleBinItem.RowLimit), Properties.Resources.Confirm)) + { + var recycleBinItemCollection = GetRecycleBinItemCollection(ctx, restoreRecycleBinItem.RowLimit, RecycleBinItemState.None); + for (var i = 0; i < recycleBinItemCollection.Count; i++) + { + var recycleBinItems = recycleBinItemCollection[i]; + recycleBinItems.RestoreAll(); + ctx.ExecuteQueryRetry(); + } + } + } + else + { + if (restoreRecycleBinItem.Force || restoreRecycleBinItem.ShouldContinue(Properties.Resources.RestoreRecycleBinItems, Properties.Resources.Confirm)) { - var nextId = items.Last().Id; - var nextTitle = WebUtility.UrlEncode(items.Last().Title); - pagingInfo = $"id={nextId}&title={nextTitle}"; + restoreRecycleBinItem.Connection.PnPContext.Site.RecycleBin.RestoreAll(); } } - while (items?.Count == 5000); - - return recycleBinItems; } } -} +} \ No newline at end of file