Repair-SharePointNames.ps1

<#PSScriptInfo

.VERSION 1.0.0

.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
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
    Processes only items flagged as INVALID_SHAREPOINT_NAME in a consolidated
    migration-warnings.csv generated from SPMT assessment scan logs. 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.

.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