Repair-SharePointNames.ps1
|
<#PSScriptInfo .VERSION 1.0.1 .GUID 84adfaf5-1f73-44d4-9e30-5b811468ed26 .AUTHOR TrogdorTheMan .COPYRIGHT (c) 2026 TrogdorTheMan. Licensed under GPL-3.0. .TAGS SharePoint SPMT Migration FileShare Rename SharePointOnline Windows .LICENSEURI https://github.com/TrogdorTheMan/Repair-SharePointNames/blob/main/LICENSE .PROJECTURI https://github.com/TrogdorTheMan/Repair-SharePointNames .RELEASENOTES 1.0.1 - Expanded the description and release notes shown on the PowerShell Gallery; no functional changes. 1.0.0 - First stable release: native -WhatIf/-Confirm, transaction log, collision-safe renames and quarantine moves, 60-test Pester suite, CI on Windows PowerShell 5.1 and PowerShell 7. Full changelog: https://github.com/TrogdorTheMan/Repair-SharePointNames/blob/main/CHANGELOG.md #> <# .SYNOPSIS Repairs file and folder names flagged by the SharePoint Migration Tool (SPMT) before migrating Windows file shares to SharePoint Online. .DESCRIPTION Cleans up file and folder names that block a SharePoint Migration Tool (SPMT) migration of Windows file shares to SharePoint Online. Feed it a consolidated migration-warnings.csv exported from SPMT assessment scan logs and it repairs every item SPMT flagged as INVALID_SHAREPOINT_NAME under the root you specify. It never scans the filesystem on its own, so only items SPMT actually flagged are touched. Temp/system files (Office lock files, desktop.ini, InDesign lock files) are moved to a quarantine folder, preserving their relative folder structure, rather than deleted. All other flagged items are renamed, replacing characters SharePoint Online does not allow (" * : < > ? / \ |) with '-'. Entries are processed deepest-path-first so a flagged folder is never renamed before the flagged items inside it are handled. If a cleaned name collides with an existing item, a numeric suffix is appended, e.g. "report (2).txt". Quarantine moves never overwrite: an existing quarantined file at the same relative path also gets a numeric suffix. Every attempted change is recorded in a transaction log CSV (see -LogPath), giving an audit trail and a manual undo path. Quick start: 1. Run an SPMT assessment scan, export the scan logs, and consolidate the INVALID_SHAREPOINT_NAME rows into migration-warnings.csv. 2. Preview: .\Repair-SharePointNames.ps1 -CsvPath .\migration-warnings.csv -RootPath \\server\share\HR -QuarantinePath C:\SPMigration\Quarantine -WhatIf 3. Repair: the same command without -WhatIf, then re-run the SPMT scan to confirm the warnings are gone. Full documentation, a sample warnings CSV, and the changelog: https://github.com/TrogdorTheMan/Repair-SharePointNames .PARAMETER CsvPath Path to the migration-warnings.csv generated from SPMT scan logs. Must contain at least the columns SourcePath, Name, and Issue. .PARAMETER RootPath The source share root to process (e.g. \\server\share\HR). Only CSV rows whose SourcePath falls under this root are processed. Matching is literal and boundary-aware: \\server\share\BIN does not match \\server\share\BINGO. .PARAMETER QuarantinePath Local or UNC path where quarantined files are moved, preserving relative folder structure. Created automatically if it doesn't exist. Required unless running with -WhatIf. .PARAMETER LogPath Path of the transaction log CSV. Every attempted change (move or rename, success or failure) is appended as a row with the old and new path, giving an audit trail and a manual undo path. Defaults to Repair-SharePointNames-log_<timestamp>.csv in the current directory. Not written in -WhatIf mode. .EXAMPLE .\Repair-SharePointNames.ps1 -CsvPath .\migration-warnings.csv -RootPath "\\server\share\HR" -QuarantinePath "C:\SPMigration\Quarantine" -WhatIf Preview every action without changing anything. .EXAMPLE .\Repair-SharePointNames.ps1 -CsvPath .\migration-warnings.csv -RootPath "\\server\share\HR" -QuarantinePath "C:\SPMigration\Quarantine" Perform the repairs. .NOTES Compatible with Windows PowerShell 3.0+ and PowerShell 7. Exit code is 1 if the CSV is missing/invalid or any item fails to process, otherwise 0. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [string]$CsvPath, [Parameter(Mandatory = $true)] [string]$RootPath, [string]$QuarantinePath = "", [string]$LogPath = "" ) # Characters invalid in SharePoint Online $InvalidCharsPattern = '["*:<>?/\\|]' # Replacement character $Replacement = '-' # Files that should be quarantined rather than renamed $QuarantinePatterns = @( '^~\$', # Office lock files (~$filename) '^~of', # Office temp files (~ofXXXX.tmp) '^desktop\.ini$', '\.idlk$', # InDesign lock files '^~.*\.tmp$' # Temp files starting with ~ ) function Test-QuarantineTarget { param([string]$Name) foreach ($pattern in $QuarantinePatterns) { if ($Name -match $pattern) { return $true } } return $false } # Splits a name into base and extension without System.IO.Path, whose methods # throw on characters like " < > | under Windows PowerShell (.NET Framework). function Split-BaseName { param([string]$Name) $dot = $Name.LastIndexOf('.') if ($dot -gt 0) { return @($Name.Substring(0, $dot), $Name.Substring($dot)) } return @($Name, '') } function Get-CleanName { param([string]$Name) $parts = Split-BaseName -Name $Name $cleanBase = ($parts[0] -replace $InvalidCharsPattern, $Replacement).Trim(' ').TrimEnd('.') $cleanExt = $parts[1] -replace $InvalidCharsPattern, $Replacement # SharePoint also rejects names ending in a dot or space return ("$cleanBase$cleanExt").TrimEnd(' .') } # Returns a path in $Directory for $Name that does not collide with an existing # item, appending " (2)", " (3)", ... before the extension if needed. function Get-UniqueDestination { param([string]$Directory, [string]$Name) $candidate = Join-Path $Directory $Name if (-not (Test-Path -LiteralPath $candidate)) { return $candidate } $parts = Split-BaseName -Name $Name for ($i = 2; $i -le 1000; $i++) { $candidate = Join-Path $Directory ("{0} ({1}){2}" -f $parts[0], $i, $parts[1]) if (-not (Test-Path -LiteralPath $candidate)) { return $candidate } } throw "Could not find an available name for '$Name' in '$Directory'." } # Appends one row per attempted change to the transaction log CSV, giving an # audit trail and a manual undo path. Never called in -WhatIf mode. function Write-ActionLog { param([string]$Action, [string]$OldPath, [string]$NewPath, [string]$Result, [string]$Message = '') try { [pscustomobject]@{ Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' Action = $Action OldPath = $OldPath NewPath = $NewPath Result = $Result Message = $Message } | Export-Csv -Path $LogPath -Append -NoTypeInformation $script:logWrites++ } catch { Write-Warning "Failed to write transaction log '$LogPath': $_" $script:errors++ } } if (-not (Test-Path -LiteralPath $CsvPath)) { Write-Error "CSV not found: $CsvPath" exit 1 } $normalizedRoot = $RootPath.TrimEnd('\') if ($normalizedRoot -eq '') { Write-Error "RootPath cannot be empty." exit 1 } $rootPrefix = $normalizedRoot + '\' if ($WhatIfPreference) { Write-Output "*** WHATIF MODE - No changes will be made ***" Write-Output "" if ($QuarantinePath -eq "") { $QuarantinePath = "C:\SPMigration\Quarantine (example)" } } else { if ($QuarantinePath -eq "") { Write-Error "You must specify -QuarantinePath when not running in -WhatIf mode." exit 1 } if (-not (Test-Path -LiteralPath $QuarantinePath)) { New-Item -ItemType Directory -Path $QuarantinePath -Force | Out-Null Write-Output "Created quarantine directory: $QuarantinePath" } if ($LogPath -eq "") { $LogPath = Join-Path (Get-Location) ("Repair-SharePointNames-log_{0}.csv" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) } $logDir = Split-Path -Path $LogPath -Parent if ($logDir -and -not (Test-Path -LiteralPath $logDir)) { Write-Error "Transaction log directory does not exist: $logDir" exit 1 } } $rows = @(Import-Csv -Path $CsvPath) if ($rows.Count -eq 0) { Write-Output "CSV contains no data rows. Nothing to do." exit 0 } # Fail loudly on a malformed CSV instead of silently matching zero rows $requiredColumns = @('SourcePath', 'Name', 'Issue') $csvColumns = @($rows[0].PSObject.Properties | ForEach-Object { $_.Name }) $missingColumns = @($requiredColumns | Where-Object { $csvColumns -notcontains $_ }) if ($missingColumns.Count -gt 0) { Write-Error ("CSV is missing required column(s): {0}. Columns found: {1}" -f ($missingColumns -join ', '), ($csvColumns -join ', ')) exit 1 } # Load only INVALID_SHAREPOINT_NAME rows under the specified root path. # Boundary-aware literal prefix match: no wildcards, and \BIN never matches \BINGO. $entries = @($rows | Where-Object { $_.Issue -eq 'INVALID_SHAREPOINT_NAME' -and $_.SourcePath -and $_.SourcePath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) }) # Deepest paths first, so items inside a flagged folder are handled before the # folder itself is renamed (which would make their CSV paths stale) $entries = @($entries | Sort-Object -Property @{ Expression = { @($_.SourcePath -split '\\').Count } } -Descending) Write-Output "Found $($entries.Count) flagged item(s) under $normalizedRoot" Write-Output "" $moved = 0 $renamed = 0 $skipped = 0 $errors = 0 $logWrites = 0 foreach ($entry in $entries) { $fullPath = $entry.SourcePath $name = $entry.Name if (-not (Test-Path -LiteralPath $fullPath)) { Write-Warning "Not found (may already be resolved): $fullPath" $skipped++ continue } if (Test-QuarantineTarget -Name $name) { # Build mirror path under the quarantine directory $relativePath = $fullPath.Substring($normalizedRoot.Length).TrimStart('\') try { $destPath = Join-Path $QuarantinePath $relativePath $destDir = Split-Path -Path $destPath -Parent $destPath = Get-UniqueDestination -Directory $destDir -Name (Split-Path -Path $destPath -Leaf) } catch { Write-Warning "Failed to resolve quarantine destination for '$fullPath': $_" if (-not $WhatIfPreference) { Write-ActionLog -Action 'Move' -OldPath $fullPath -NewPath '' -Result 'Failed' -Message "$_" } $errors++ continue } if ($PSCmdlet.ShouldProcess($fullPath, "Move to quarantine: $destPath")) { try { if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } # These are often hidden/system/read-only, which would block the move attrib -s -h -r "$fullPath" Move-Item -LiteralPath $fullPath -Destination $destPath -ErrorAction Stop Write-Output "Moved: $fullPath" Write-Output " To: $destPath" Write-ActionLog -Action 'Move' -OldPath $fullPath -NewPath $destPath -Result 'Success' $moved++ } catch { Write-Warning "Failed to move '$fullPath': $_" Write-ActionLog -Action 'Move' -OldPath $fullPath -NewPath $destPath -Result 'Failed' -Message "$_" $errors++ } } elseif ($WhatIfPreference) { Write-Output "MOVE: $fullPath" Write-Output " TO: $destPath" Write-Output "" $moved++ } else { # -Confirm declined $skipped++ } } else { $newName = Get-CleanName -Name $name if ($newName -eq $name) { Write-Output "SKIP (name already valid): $name" $skipped++ continue } if ([string]::IsNullOrWhiteSpace($newName)) { Write-Warning "Cleaning '$name' produces an empty name; rename it manually: $fullPath" if (-not $WhatIfPreference) { Write-ActionLog -Action 'Rename' -OldPath $fullPath -NewPath '' -Result 'Failed' -Message 'Cleaned name is empty; rename manually' } $errors++ continue } try { $parentDir = Split-Path -Path $fullPath -Parent $targetPath = Get-UniqueDestination -Directory $parentDir -Name $newName $targetName = Split-Path -Path $targetPath -Leaf } catch { Write-Warning "Failed to resolve rename target for '$fullPath': $_" if (-not $WhatIfPreference) { Write-ActionLog -Action 'Rename' -OldPath $fullPath -NewPath '' -Result 'Failed' -Message "$_" } $errors++ continue } if ($PSCmdlet.ShouldProcess($fullPath, "Rename to: $targetName")) { try { Rename-Item -LiteralPath $fullPath -NewName $targetName -ErrorAction Stop Write-Output "Renamed: $name -> $targetName" Write-ActionLog -Action 'Rename' -OldPath $fullPath -NewPath $targetPath -Result 'Success' $renamed++ } catch { Write-Warning "Failed to rename '$fullPath': $_" Write-ActionLog -Action 'Rename' -OldPath $fullPath -NewPath $targetPath -Result 'Failed' -Message "$_" $errors++ } } elseif ($WhatIfPreference) { Write-Output "RENAME: $fullPath" Write-Output " TO: $targetPath" Write-Output "" $renamed++ } else { # -Confirm declined $skipped++ } } } Write-Output "" Write-Output "--- Summary ---" if ($WhatIfPreference) { Write-Output "Would move to quarantine: $moved" Write-Output "Would rename: $renamed" Write-Output "Skipped: $skipped" Write-Output "Errors: $errors" Write-Output "Quarantine path: $QuarantinePath" } else { Write-Output "Moved to quarantine: $moved" Write-Output "Renamed: $renamed" Write-Output "Skipped: $skipped" Write-Output "Errors: $errors" Write-Output "Quarantine path: $QuarantinePath" if ($logWrites -gt 0) { Write-Output "Transaction log: $LogPath" } else { Write-Output "Transaction log: not created (no changes attempted)" } } if ($errors -gt 0) { exit 1 } exit 0 |