Restore-OrphanedAppxFolders.ps1

<#
.SYNOPSIS
    Puts folders removed by Remove-OrphanedAppxFolders.ps1 back from the
    Recycle Bin.
 
.DESCRIPTION
    Reads a delete log (WinPkgScan-DeleteLog-*.csv) and restores every
    folder with the action "Recycled" to where it was.
 
    - Folders deleted with -Permanent cannot be restored.
    - A folder is skipped if something already exists at its original path,
      or if it is no longer in the Recycle Bin.
    - Only folders from your own %LOCALAPPDATA%\Packages are restored.
 
.PARAMETER LogPath
    The delete log to read. If not given, the newest
    WinPkgScan-DeleteLog-*.csv on your Desktop or in your Downloads folder
    is used.
 
.PARAMETER SearchFolder
    Folders to look in when -LogPath is not given. Defaults to your Desktop
    and Downloads folders.
 
.PARAMETER Name
    Only restore folders whose name matches. Wildcards allowed.
 
.PARAMETER NoHistory
    Do not record this run in the scan history
    (%LOCALAPPDATA%\WinPkgScan\history.json), which keeps the total
    space freed with WinPkgScan (restored folders are subtracted).
 
.PARAMETER NoPause
    Never pause at the end. The script only pauses when it opened its own
    window (a batch file or "Run with PowerShell"), not when run from an
    open PowerShell prompt.
 
.EXAMPLE
    .\Restore-OrphanedAppxFolders.ps1 -WhatIf
    Shows what would be restored from the newest delete log.
 
.EXAMPLE
    .\Restore-OrphanedAppxFolders.ps1 -Name 'Contoso.*'
 
.NOTES
    Version: 1.8.0
#>


[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
    [Parameter(Position = 0)]
    [string]$LogPath,

    [string[]]$SearchFolder,

    [string[]]$Name,

    [switch]$NoHistory,

    [switch]$NoPause
)

$ScriptVersion = "1.8.0"
# Pause at the end only when this script opened its own console window
# (a batch file or "Run with PowerShell"), so you can read the result before
# it closes. Never from an open PowerShell prompt, the module, the app, or
# with redirected input.
$PauseAtEnd = $false
if (-not $NoPause.IsPresent) {
    try {
        $PauseAtEnd = (-not [Console]::IsInputRedirected) -and
            ([Environment]::CommandLine.IndexOf([System.IO.Path]::GetFileName($PSCommandPath), [StringComparison]::OrdinalIgnoreCase) -ge 0)
    }
    catch {
        $PauseAtEnd = $false
    }
}

function Exit-Script {
    param([int]$Code = 0)
    if ($PauseAtEnd) {
        Write-Host ""
        Read-Host "Press Enter to exit" | Out-Null
    }
    exit $Code
}

function Get-DesktopFolder {
    $path = [Environment]::GetFolderPath("Desktop")
    if ([string]::IsNullOrWhiteSpace($path)) { $path = Join-Path $env:USERPROFILE "Desktop" }
    return $path
}

function Get-DownloadsFolder {
    # The Downloads folder can be moved, so ask Windows where it is.
    $key  = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders'
    $name = '{374DE290-123F-4565-9164-39C4925E467B}'
    try {
        $value = (Get-ItemProperty -Path $key -Name $name -ErrorAction Stop).$name
        if ($value) { return $value }
    }
    catch {
        Write-Verbose "Downloads folder not found in the registry: $($_.Exception.Message)"
    }
    $base = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
    return Join-Path $base "Downloads"
}

function Find-NewestFile {
    param([string[]]$Folders, [string[]]$Patterns)
    $found = foreach ($folder in $Folders) {
        if ($folder -and (Test-Path -LiteralPath $folder -PathType Container)) {
            foreach ($pattern in $Patterns) {
                Get-ChildItem -LiteralPath $folder -Filter $pattern -File -ErrorAction SilentlyContinue
            }
        }
    }
    return $found | Sort-Object LastWriteTime -Descending | Select-Object -First 1
}

function Read-CsvFile {
    # Reads a CSV saved by WinPkgScan, a browser or Excel. Detects the text
    # encoding (UTF-8 with or without BOM, UTF-16, or Excel's ANSI code page)
    # and the separator ("," or ";").
    param([string]$Path)
    $bytes = [System.IO.File]::ReadAllBytes($Path)
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $text = [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
    }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        $text = [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
    }
    else {
        try {
            $text = (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
        }
        catch {
            $codePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage
            $text = [System.Text.Encoding]::GetEncoding($codePage).GetString($bytes)
        }
    }
    $rows = @($text | ConvertFrom-Csv)
    if ($rows.Count -gt 0 -and -not ($rows[0].PSObject.Properties.Name -contains 'FullPath')) {
        $rows = @($text | ConvertFrom-Csv -Delimiter ';')
    }
    return , $rows
}

function Get-LongPath {
    # Expands Windows 8.3 short names (e.g. C:\Users\RUNNER~1) so two
    # spellings of the same path compare equal. Other systems: unchanged.
    param([string]$Path)
    if ([Environment]::OSVersion.Platform -ne 'Win32NT' -or [string]::IsNullOrWhiteSpace($Path)) {
        return $Path
    }
    try {
        if (-not ('WinPkgScanNative.PathApi' -as [type])) {
            Add-Type -Namespace WinPkgScanNative -Name PathApi -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)]
public static extern uint GetLongPathName(string shortPath, System.Text.StringBuilder longPath, uint size);
'@

        }
        $full = [System.IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
        # Expand the deepest part that exists, then add the rest back.
        $existing = $full
        $rest = @()
        while ($existing -and -not (Test-Path -LiteralPath $existing)) {
            $rest = @([System.IO.Path]::GetFileName($existing)) + $rest
            $existing = [System.IO.Path]::GetDirectoryName($existing)
        }
        if (-not $existing) { return $full }
        $buffer = New-Object System.Text.StringBuilder 1024
        $length = [WinPkgScanNative.PathApi]::GetLongPathName($existing, $buffer, [uint32]$buffer.Capacity)
        $long = if ($length -gt 0 -and $length -lt $buffer.Capacity) { $buffer.ToString() } else { $existing }
        foreach ($part in $rest) { $long = Join-Path $long $part }
        return $long.TrimEnd('\', '/')
    }
    catch {
        Write-Verbose "Could not expand $Path : $($_.Exception.Message)"
        return $Path
    }
}

function Get-HistoryPath {
    return Join-Path (Join-Path $env:LOCALAPPDATA 'WinPkgScan') 'history.json'
}

function Read-History {
    # Scan history: past scans (with folder names), removals and restores.
    param([string]$Path)
    $history = [PSCustomObject]@{ Scans = @(); Removals = @(); Restores = @() }
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $history }
    try {
        $text = [System.IO.File]::ReadAllText($Path)
        if ($text.Trim()) {
            $data = $text | ConvertFrom-Json -ErrorAction Stop
            if ($data.Scans)    { $history.Scans    = @($data.Scans) }
            if ($data.Removals) { $history.Removals = @($data.Removals) }
            if ($data.Restores) { $history.Restores = @($data.Restores) }
        }
    }
    catch {
        Write-Host "WARNING: The scan history could not be read, so it starts again: $($_.Exception.Message)" -ForegroundColor Yellow
        try {
            Copy-Item -LiteralPath $Path -Destination "$Path.bad" -Force -ErrorAction Stop -WhatIf:$false -Confirm:$false
        }
        catch {
            Write-Verbose "Could not keep a copy of the unreadable history: $($_.Exception.Message)"
        }
    }
    return $history
}

function Save-History {
    param($History, [string]$Path)
    $dir = Split-Path -Path $Path -Parent
    if (-not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false -Confirm:$false | Out-Null
    }
    $data = [ordered]@{
        Version  = 1
        Scans    = @(@($History.Scans)    | Select-Object -Last 50)
        Removals = @(@($History.Removals) | Select-Object -Last 1000)
        Restores = @(@($History.Restores) | Select-Object -Last 1000)
    }
    $json = ConvertTo-Json -InputObject $data -Depth 6
    [System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($true)))
}

function Get-FreedMB {
    # Space freed with WinPkgScan: removals minus restores.
    param($History)
    $freed = 0.0
    foreach ($r in @($History.Removals)) { if ($r) { $freed += [double]$r.FreedMB } }
    foreach ($r in @($History.Restores)) { if ($r) { $freed -= [double]$r.SizeMB } }
    if ($freed -lt 0) { $freed = 0 }
    return [math]::Round($freed, 2)
}

function Get-RecycleBinFolder {
    # Every folder in the Recycle Bin, with where it came from.
    $shell = New-Object -ComObject Shell.Application
    $bin   = $shell.Namespace(10)
    foreach ($entry in @($bin.Items())) {
        $from = $entry.ExtendedProperty('System.Recycle.DeletedFrom')
        if (-not $from) { $from = $bin.GetDetailsOf($entry, 1) }
        if (-not $from) { continue }
        $when = $entry.ExtendedProperty('System.Recycle.DateDeleted')
        [PSCustomObject]@{
            OriginalPath = Get-LongPath (Join-Path $from $entry.Name)
            DateDeleted  = if ($when) { [datetime]$when } else { [datetime]::MinValue }
            Entry        = $entry
        }
    }
}

$PackagesPath = [System.IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA "Packages"))
$PackagesLong = Get-LongPath $PackagesPath

$LogPicked = $false
if ([string]::IsNullOrWhiteSpace($LogPath)) {
    if (-not $SearchFolder) {
        $SearchFolder = @((Get-DesktopFolder), (Get-DownloadsFolder)) | Select-Object -Unique
    }
    $newest = Find-NewestFile -Folders $SearchFolder -Patterns 'WinPkgScan-DeleteLog-*.csv'
    if ($newest) {
        $LogPath   = $newest.FullName
        $LogPicked = $true
    }
}

Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " AppData\Local\Packages Folder Restore v$ScriptVersion" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host ""

if ([string]::IsNullOrWhiteSpace($LogPath) -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) {
    Write-Host "ERROR: No delete log was found." -ForegroundColor Red
    Write-Host "Delete logs are named WinPkgScan-DeleteLog-<date-time>.csv and are saved"
    Write-Host "next to the CSV you deleted from. Use -LogPath to choose one."
    Exit-Script 1
}

Write-Host "Log: $LogPath"
if ($LogPicked) {
    Write-Host " (newest delete log found; use -LogPath to choose another)" -ForegroundColor Gray
}
if ($WhatIfPreference) {
    Write-Host "WhatIf: on (nothing will be restored)" -ForegroundColor Yellow
}
Write-Host ""

try {
    $Rows = Read-CsvFile -Path $LogPath
}
catch {
    Write-Host "ERROR: Could not read the delete log: $($_.Exception.Message)" -ForegroundColor Red
    Exit-Script 1
}

if ($Rows.Count -gt 0 -and -not ($Rows[0].PSObject.Properties.Name -contains 'Action')) {
    Write-Host "ERROR: This is not a WinPkgScan delete log (no Action column)." -ForegroundColor Red
    Exit-Script 1
}

$Recycled  = @($Rows | Where-Object { $_.Action -eq 'Recycled' })
$Permanent = @($Rows | Where-Object { $_.Action -eq 'Deleted' })
if ($Name) {
    $Recycled = @($Recycled | Where-Object { $n = $_.FolderName; @($Name | Where-Object { $n -like $_ }).Count -gt 0 })
}

Write-Host "Moved to Recycle Bin in this log: $($Recycled.Count)"
if ($Permanent.Count -gt 0) {
    Write-Host "Permanently deleted (cannot be restored): $($Permanent.Count)" -ForegroundColor Yellow
}
Write-Host ""

if ($Recycled.Count -eq 0) {
    Write-Host "Nothing to restore." -ForegroundColor Green
    Exit-Script 0
}

try {
    $InBin = @(Get-RecycleBinFolder)
}
catch {
    Write-Host "ERROR: Could not open the Recycle Bin: $($_.Exception.Message)" -ForegroundColor Red
    Write-Host "Restore needs Windows. You can also restore the folders by hand from the Recycle Bin."
    Exit-Script 1
}

$Results = New-Object System.Collections.Generic.List[object]

foreach ($Row in $Recycled) {
    $target = Get-LongPath ([System.IO.Path]::GetFullPath("$($Row.FullPath)").TrimEnd('\', '/'))
    $action = $null
    $reason = ""

    if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($target), $PackagesLong, [StringComparison]::OrdinalIgnoreCase)) {
        $action = "Skipped"; $reason = "Not in your Packages folder"
    }
    elseif (Test-Path -LiteralPath $target) {
        $action = "Skipped"; $reason = "Something already exists at the original path"
    }

    if (-not $action) {
        # If the same folder was recycled more than once, restore the newest.
        $match = $InBin |
            Where-Object { [string]::Equals($_.OriginalPath.TrimEnd('\', '/'), $target, [StringComparison]::OrdinalIgnoreCase) } |
            Sort-Object DateDeleted -Descending |
            Select-Object -First 1
        if (-not $match) {
            $action = "Skipped"; $reason = "Not in the Recycle Bin (emptied or already restored?)"
        }
        elseif ($PSCmdlet.ShouldProcess($target, "Restore from Recycle Bin")) {
            try {
                $match.Entry.InvokeVerb('undelete')
                for ($i = 0; $i -lt 20 -and -not (Test-Path -LiteralPath $target); $i++) {
                    Start-Sleep -Milliseconds 250
                }
                if (Test-Path -LiteralPath $target) { $action = "Restored" }
                else { $action = "Failed"; $reason = "Folder did not reappear" }
            }
            catch {
                $action = "Failed"; $reason = $_.Exception.Message
            }
        }
        elseif ($WhatIfPreference) {
            $action = "WhatIf"
        }
        else {
            $action = "Skipped"; $reason = "Not confirmed"
        }
    }

    $colour = switch ($action) {
        'Restored' { 'Green' }
        'WhatIf'   { 'Yellow' }
        'Failed'   { 'Red' }
        default    { 'Gray' }
    }
    $line = "{0,-9} {1}" -f $action, $Row.FolderName
    if ($reason) { $line += " ($reason)" }
    Write-Host $line -ForegroundColor $colour
    $sizeValue = 0.0
    [void][double]::TryParse("$($Row.SizeMB)", [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$sizeValue)
    $Results.Add([PSCustomObject]@{ Action = $action; FolderName = $Row.FolderName; SizeMB = $sizeValue })
}

$restored = @($Results | Where-Object Action -eq 'Restored').Count
$failed   = @($Results | Where-Object Action -eq 'Failed').Count
$skipped  = @($Results | Where-Object Action -eq 'Skipped').Count

Write-Host ""
Write-Host "Restored: $restored Skipped: $skipped Failed: $failed"

# Restored folders take space again: record that in the scan history.
if (-not $NoHistory -and $restored -gt 0) {
    try {
        $restoredMB = [math]::Round((($Results | Where-Object Action -eq 'Restored' | Measure-Object -Property SizeMB -Sum).Sum), 2)
        $HistoryPath = Get-HistoryPath
        $History = Read-History -Path $HistoryPath
        $History.Restores = @(@($History.Restores) + [PSCustomObject]@{
            Time    = (Get-Date).ToString('yyyy-MM-dd HH:mm')
            Folders = $restored
            SizeMB  = $restoredMB
        })
        Save-History -History $History -Path $HistoryPath
    }
    catch {
        Write-Host "WARNING: Could not update the scan history: $($_.Exception.Message)" -ForegroundColor Yellow
    }
}

if ($failed -gt 0) { Exit-Script 1 }
Exit-Script 0