Copy folders and files between OneDrives using PowerShell

The challenge

Following a tenant-to-tenant migration, I encountered the issue of certain users possessing active accounts in both the source and destination tenants. While not inherently problematic, the duplicate users in the target tenant were suspected to be quite inactive and was to be deleted before migrating the user from the source tenant. However, the complication arose when it was discovered that several users were actively using both accounts, some even containing substantial data within their respective OneDrive storage. The customer requested that the content in the target tenants OneDrive would be copied into the OneDrive of the account which was migrated from the source.

We proceeded with the plan to remove these duplicate accounts in the target tenant and then transfer the data from this account to the OneDrive associated with the user’s account that had been migrated.

While M365 lets you grant access to another user when a OneDrive is removed, this could quickly become a manual task and it would be up to the user to find and copy the files needed from their old OneDrive. Additionally, a deleted OneDrive is kept in the SharePoint online recycle bin for 93 days (by default) before it’s permanently deleted. This presents a hard time limit for the end-users to get the data they need to preserve. So we wanted to find another solution to this issue.

PowerShell to the rescue!

First of all, I’ll openly acknowledge that managing files and folders in SharePoint Online via PowerShell isn’t exactly my forte. However, a quick search online led me to a blog post (thank you for sharing) featuring a script meant for the task. Although the script was significantly outdated, it contained all the info I needed to write a functional script of my own. The post also mentions several limitations which are no longer valid:

It uses your connection
It downloads the files before uploading them to the other user’s OneDrive. If your connection is slow, and you’re moving large files you might want to leave this running on a cloud hosted server.
 
NOW: This solution does not seem use your local connection. A file on 1 MB and 1 GB both take a second or so, which to me indicates that the copy happens in the back-end.

It can’t move files larger than 250MB
A limitation of this PowerShell module is that it can’t send files larger than 250MB to SharePoint. This script will make a note of these files and export a list of them to c:\temp\largefiles.txt in case you want to move them manually.
NOW: The file limit is not 250MB, the largest file I copied was over 10GB.

No Two Factor Authentication
This script doesn’t work with multifactor authentication on the admin account. You may want to create a temporary admin without MFA for this purpose.
NOW: MFA is not supported but that’s why I always go for certificate-based authentication which is even better for this task.

Step 1: Get the URLs for source and target OneDrive

Initially, we require the specific URLs for the source and the target, particularly the last segment which is unique for each user. Typically, this segment corresponds to the user’s UPN, substituting underscores for spaces and special characters. However, there can be deviations from this norm due to, for example, closely resembling UPNs. Transferring personal files to an incorrect OneDrive can have serious repercussions, so I STRONGLY RECOMMEND confirming each OneDrive URL meticulously. But before you panic: This task is much less daunting than it might appear, as we have a script to simplify this job as well. 😉

The script named “Get-OneDriveSites.ps1” connects to SharePoint Online using certificates (see this post for details) and retrieves a list of all OneDrive URLs. Then it collects the Name, URL and the “NameUnderscore” (which are the values we need to provide as source and target for the main script) into the variable $export. You can display the output by just entering $export or you can save it to a .csv file or any other format you like. Make sure to check the region in the script that requires customization (line 28-35) and also read the Disclaimer carefully before you use it. You are free to use and modify this script as you wish if you respect said Disclaimer. The script below is available for download here.

<#
.SYNOPSIS
    Get all OneDrive sites in the tenant and list them in a table with the Name, Owner, and NameUnderscore properties.
.DESCRIPTION
    This script gets all OneDrive sites in the tenant and lists them in a table with the Name, Owner, and NameUnderscore properties.
    The script uses the PnP PowerShell module to connect to the SharePoint Admin site and get all OneDrive sites.
    The script then creates a custom object with the Name, Owner, and NameUnderscore properties and adds it to an array.
    The script outputs the array to the console.
    The script also clears the console and writes a message to the console.
.NOTES
    File Name      : Get-OneDriveSites.ps1
    Author         : Per-Torben Sørensen - agderinthe.cloud
    Tested on      : PowerShell 7.4.2 and pnp.powershell 2.4.0
#>
<#
.DISCLAIMER
    This script is provided AS IS without warranty of any kind. The author further disclaims all implied warranties including, 
    without limitation, any implied warranties of merchantability or of fitness for a particular purpose.

    The entire risk arising out of the use or performance of this script remains with you. 
    
    In no event shall the author be held liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, 
    or other pecuniary loss) arising out of the use of or inability to use this script, even if the author has been advised of the possibility of such damages.
    
    The use of this script carries no support from the author, unless otherwise specified. By using this script, you agree to these terms.
#>

#region Set these parameters to the correct values
$InitialDomain = "M365x32391902"
$connectionsParams = @{
    ClientId = 'db69ee7c-a859-4000-8d99-111111111111'
    Thumbprint = 'DB1E8A01D12628F6FB2704D6BC12111111111111'
    Tenant = '31e7505b-5cfa-47e8-a66f-1111111111111'
}
#endregion

# Connect to the SharePoint Admin site
$AdminSiteURL = "https://$($InitialDomain)-admin.sharepoint.com/"
Connect-PnPOnline -Url $adminSiteurl @connectionsParams

# Get All OneDrive Sites output to variable: $export
$AllOneDrives = Get-PnPTenantSite -IncludeOneDriveSites -Filter "Url -like '-my.sharepoint.com/personal/'"
$export = @()
$AllOneDrives | foreach {
    $output = [PSCustomObject]@{
        Name = $_.Title
        Owner = $_.Owner
        NameUnderscore = $_.Url.Split('/')[-1]
    }
    $export += $output
}
Clear-Host
Write-Host
Write-host -ForegroundColor Green 'Type $export to see the list'
Write-Host

Below is a sample output from the script, where the required value is located at the far right.

Step 2: Prepare the main script

From step 1, I will now use the example of copying the content from Adele Vance’s OneDrive into a folder in Miriam Graham’s OneDrive.

The main script named “Copy-OneDriveFiles.ps1” also uses certificates to logon to the OneDrives in order to get the content. Then it creates the folder structure under a specified folder in the target (the folder will be created automatically if it doesn’t exist), and finally it copies over all the files. The same rules applies as with the previous script: Pay attention to the region in the script that needs customization (line 33-51) and please review the Disclaimer as well before you use it. You are free to use and modify this script as you wish as long as you respect said Disclaimer. The script below is available for download here.

<#
.SYNOPSIS
    This script copies files and folders from one OneDrive to another within the same tenant.
.DESCRIPTION
    This script copies files and folders from one OneDrive to another within the same tenant. 
    It uses the PnP PowerShell module to connect to the source and target OneDrive sites. 
    It then collects the content from the source and target sites and copies the files and folders from the source to the target. 
    The script also creates the folder structure in the target site before copying the files. 
    The script also handles errors and logs them to a CSV file.
.EXAMPLE
    .\Copy-OneDriveFiles.ps1 -SourceUserUnderscore "adelev_m365x32391902_onmicrosoft_com" -DestinationUserUnderscore "miriamg_m365x32391902_onmicrosoft_com"
.NOTES
    File Name      : Copy-OneDriveFiles.ps1
    Author         : Per-Torben Sørensen - agderinthe.cloud
    Tested on      : PowerShell 7.4.2 and pnp.powershell 2.4.0 
#>
<#
.DISCLAIMER
    This script is provided AS IS without warranty of any kind. The author further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. 
    The entire risk arising out of the use or performance of this script remains with you. 
    In no event shall the author be held liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, 
    or other pecuniary loss) arising out of the use of or inability to use this script, even if the author has been advised of the possibility of such damages. 
    The use of this script carries no support from the author, unless otherwise specified. By using this script, you agree to these terms.
#>
param(
[Parameter(Mandatory=$true)]
[string]$SourceUserUnderscore,

[Parameter(Mandatory=$true)]
[string]$DestinationUserUnderscore
)

#region Set these parameters to the correct values

    # Connection parameters to connect to the source and target OneDrive sites using certificate authentication
    $connectionsParams = @{
        ClientId = 'db69ee7c-a859-4000-8d99-111111111111'
        Thumbprint = 'DB1E8A01D12628F6FB2704D6BC12111111111111'
        Tenant = '31e7505b-5cfa-47e8-a66f-111111111111'
    }

    # The Tenant inital domain
    $InitialDomain = "M365x32391902"
    
    # The name of the folder where the files will be copied in the target OneDrive
    $targetfoldername = "Files from another OneDrive"
    
    # The path to save the error files
    $csvpath = "C:\scripts"

#endregion

#region Do not change anything in this region
    # Build the URLs for the source and target OneDrive sites
    $TenantURL = "$($InitialDomain)-my.sharepoint.com"
    $urlsource = "https://$($TenantURL)/personal/$($sourceuserunderscore)/"
    $urltarget = "https://$($TenantURL)/personal/$($destinationUserUnderscore)/"
    $destinationOneDriveSiteRelativePath = "Documents/$targetfoldername"
    $destinationOneDrivePath = "/personal/$destinationUserUnderscore/Documents/$targetfoldername"
    $departingOneDrivePath = "/personal/$($sourceuserunderscore)/Documents"
#endregion

#region Collect content
    # Verify errorlog folder is valid
    try {
        Get-ChildItem $csvpath -ErrorAction Stop | Out-Null
    }
    catch {
        Write-Host "Caught an error: $_" -ForegroundColor Red
        # Stop the script
        break
    }
    # Collecting content from source and target
    Connect-PnPOnline -Url $urlsource @connectionsParams
    $Content = Get-PnPListItem -List Documents -PageSize 1000

    Connect-PnPOnline -Url $urltarget @connectionsParams
    $Content2 = Get-PnPListItem -List Documents -PageSize 1000
#endregion

#region folders
    # Etablish folder structure in target
    $folders = $Content| Where-Object {$_.FileSystemObjectType -contains "Folder"}
    Write-Host "`nCreating Folder Structure in Target" -ForegroundColor Blue

    $foldererrors = @()
    $count = $folders.Count
    $i=0
    foreach ($folder in $folders) {
        # Write progress bar on screen
        $i++
        $percentprogress = [math]::Round((($i/$count) *100),2)
        Write-Progress -Activity "Creating $count folders" -Status "Folder $($i) of $($count), $($percentprogress)%" -PercentComplete $percentprogress
            
        $path = ('{0}{1}' -f $destinationOneDriveSiteRelativePath, $folder.fieldvalues.FileRef).Replace($departingOneDrivePath, '')
        Try {
            $newfolder = Resolve-PnPFolder -SiteRelativePath $path -ErrorAction Stop
        }
        catch {
            $fileerrors += [PSCustomObject]@{
                ErrorMessage = $_.Exception.Message
                Folder = $path
            }
        }
    }

    if ($foldererrors.count -gt 0) {
        $now = Get-Date -Format "yyyy-MM-dd-HH-mm-ss"
        $foldererrors | Export-Csv -Path "$($csvpath)\$($now)_foldererrors.csv" -NoTypeInformation
        Write-Host "Folder errors detected, see $($csvpath)\$($now)_foldererrors.csv" -ForegroundColor Red
    }
    else {
        Write-Host "No folder errors detected" -ForegroundColor Green
    }
#endregion

#region files
# Copy files from source to target
$files = $Content | Where-Object {$_.FileSystemObjectType -contains "File"}
Write-Host "`nCopying Files" -ForegroundColor Blue

$fileerrors = @()
$count = $files.Count
$i=0
foreach ($file in $files) {
    # Write progress bar on screen
    $i++
    $percentprogress = [math]::Round((($i/$count) *100),2)
    Write-Progress -Activity "Copying $count files" -Status "File $($i) of $($count), $($percentprogress)%" -PercentComplete $percentprogress
      
    $destpath = ("$destinationOneDrivePath$($file.fieldvalues.FileDirRef)").Replace($departingOneDrivePath, "")
    if ($Content2.fieldvalues.FileLeafRef -notcontains $file.fieldvalues.FileLeafRef) {
        try {
            $newfile = Copy-PnPFile -SourceUrl $file.fieldvalues.FileRef -TargetUrl $destpath -Force -ErrorVariable errors -ErrorAction Stop
        }
        catch {
            $fileerrors += [PSCustomObject]@{
                ErrorMessage = $_.Exception.Message
                SourceFile = $file.fieldvalues.FileRef
                TargetFile = $destpath
            }
        }
    }
}

if ($fileerrors.count -gt 0) {
    $now = Get-Date -Format "yyyy-MM-dd-HH-mm-ss"
    $fileerrors | Export-Csv -Path "$($csvpath)\$($now)_fileerrors.csv" -NoTypeInformation
    Write-Host "File errors detected, see $($csvpath)\$($now)_fileerrors.csv" -ForegroundColor Red
}
else {
    Write-Host "No file errors detected" -ForegroundColor Green
}
#endregion

Here’s a nice trick in PowerShell: I really like to use #region and #endregion as it’s incredibly helpful for collapsing sections of code, which makes navigation much easier and enhances readability. Like so:

After you’ve configured the connection to Entra ID for this script, you can update the parameters in line 33-51. All values there are mandatory.

Step 3: Run the script

When the script is modified with the mandatory values, you are ready to run it. There are 2 input parameters which you need to provide: “SourceUserUnderscore” and “DestinationUserUnderscore” which we collected in Step 1. A nice benefit of this method is how easily you can use a “foreach” loop for copying multiple OneDrives. All you need to do is to create a .csv file containing “SourceUserUnderscore” and “DestinationUserUnderscore” columns, and deploy the script to loop through each entry. This streamlines the process significantly when dealing with numerous OneDrive accounts.

To run the script, just follow the example as written in the scripts’s synopsis:
.\Copy-OneDriveFiles.ps1 -SourceUserUnderscore "adelev_m365x32391902_onmicrosoft_com" -DestinationUserUnderscore "miriamg_m365x32391902_onmicrosoft_com"

The script will display a progress bar for both folder creation and file copying. It will also display if any errors was encountered and provide the path to the error log files.

How long the job takes depends primarely on the number of items, not the size in total. When the script is done, I look into Miriam’s OneDrive and there I see a new folder with the same name as provided in the script. Because I use certificates to authenticate the script, “SharePoint App” is set in the Modified By column. Inside this folder I find the content from Adele’s OneDrive.

And there you go. A nice, easy and secure way if you ever need to copy data between OneDrives in your tenant.

I hope you found this helpful and if you do please share this blog post. Also please check out the rest of this blog for many more tips and tricks. Thank you for reading.

Author

  • Per-Torben Sørensen

    Per-Torben Sørensen has 25 years experience in IT and Microsoft infrastructure. He is currently an MCT and works as a Technical Architect within M365 at Crayon. His passion is Entra ID and Identity and access management and helps customers become "copilot-ready". He's also a engaged speaker and is always eager to share his knowledge and learn from others.

    View all posts

Discover more from Agder in the cloud

Subscribe to get the latest posts sent to your email.

By Per-Torben Sørensen

Per-Torben Sørensen has 25 years experience in IT and Microsoft infrastructure. He is currently an MCT and works as a Technical Architect within M365 at Crayon. His passion is Entra ID and Identity and access management and helps customers become "copilot-ready". He's also a engaged speaker and is always eager to share his knowledge and learn from others.

Related Post

Leave a Reply