trash.psm1

Set-StrictMode -Version Latest

if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {
    throw 'The trash module only supports Windows.'
}

if (-not ('TrashPs.Native' -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;

namespace TrashPs {
    public static class Native {
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        private struct SHFILEOPSTRUCT {
            public IntPtr hwnd;
            public uint wFunc;
            public string pFrom;
            public string pTo;
            public ushort fFlags;
            [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted;
            public IntPtr hNameMappings;
            public string lpszProgressTitle;
        }

        [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
        private static extern int SHFileOperation(ref SHFILEOPSTRUCT op);

        [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
        private static extern int SHEmptyRecycleBin(IntPtr hwnd, string root, uint flags);

        [DllImport("shell32.dll")]
        private static extern bool SHUpdateRecycleBinIcon();

        private const uint FO_DELETE = 3;
        private const ushort FOF_SILENT = 0x0004;
        private const ushort FOF_NOCONFIRMATION = 0x0010;
        private const ushort FOF_ALLOWUNDO = 0x0040;
        private const ushort FOF_NOERRORUI = 0x0400;
        // Ask before destroying a file that cannot be recycled (too large, network drive, ...).
        private const ushort FOF_WANTNUKEWARNING = 0x4000;

        private const uint SHERB_NOCONFIRMATION = 0x1;
        private const uint SHERB_NOPROGRESSUI = 0x2;
        private const uint SHERB_NOSOUND = 0x4;

        // Returns 0 on success, -1 if the user aborted, or the SHFileOperation error code.
        public static int Recycle(string fullPath) {
            var op = new SHFILEOPSTRUCT {
                wFunc = FO_DELETE,
                pFrom = fullPath + "\0\0",
                fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING,
            };
            int ret = SHFileOperation(ref op);
            if (ret == 0 && op.fAnyOperationsAborted) return -1;
            return ret;
        }

        public static int EmptyRecycleBin() {
            return SHEmptyRecycleBin(IntPtr.Zero, null,
                SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND);
        }

        public static void UpdateIcon() {
            SHUpdateRecycleBinIcon();
        }
    }
}
'@

}

Update-TypeData -TypeName 'TrashPs.Item' -DefaultDisplayPropertySet Id, DeletedAt, Size, OriginalPath -Force

#region Private helpers

# Parse a $I metadata file. Format version 1 (Vista/7) stores a fixed 260-char
# path, version 2 (Windows 10+) stores a length-prefixed path.
function Read-TrashInfo([System.IO.FileInfo] $InfoFile) {
    $bytes = [System.IO.File]::ReadAllBytes($InfoFile.FullName)
    if ($bytes.Length -lt 24) { return }
    $version = [BitConverter]::ToInt64($bytes, 0)
    $size = [BitConverter]::ToInt64($bytes, 8)
    $deletedAt = [DateTime]::FromFileTime([BitConverter]::ToInt64($bytes, 16))
    switch ($version) {
        1 { $raw = [Text.Encoding]::Unicode.GetString($bytes, 24, [Math]::Min(520, $bytes.Length - 24)) }
        2 {
            if ($bytes.Length -lt 28) { return }
            $chars = [BitConverter]::ToInt32($bytes, 24)
            $raw = [Text.Encoding]::Unicode.GetString($bytes, 28, [Math]::Min($chars * 2, $bytes.Length - 28))
        }
        default { return }
    }
    $originalPath = $raw.Split([char]0)[0]

    $dataPath = Join-Path $InfoFile.DirectoryName ('$R' + $InfoFile.Name.Substring(2))
    if (-not (Test-Path -LiteralPath $dataPath)) { return }

    [pscustomobject]@{
        PSTypeName   = 'TrashPs.Item'
        Id           = $InfoFile.BaseName.Substring(2)
        Name         = Split-Path $originalPath -Leaf
        OriginalPath = $originalPath
        DeletedAt    = $deletedAt
        Size         = $size
        IsDirectory  = Test-Path -LiteralPath $dataPath -PathType Container
        DataPath     = $dataPath
        InfoPath     = $InfoFile.FullName
    }
}

function Test-TrashItemMatch($Item, [string[]] $Patterns) {
    foreach ($p in $Patterns) {
        if ($Item.Id -eq $p -or $Item.Name -like $p -or $Item.OriginalPath -like $p) { return $true }
    }
    $false
}

# Expand wildcards, but prefer a literal match so names containing [] still work.
function Resolve-TargetPath([string] $InputPath, [switch] $Literal) {
    if (Test-Path -LiteralPath $InputPath) {
        return (Get-Item -LiteralPath $InputPath -Force).FullName
    }
    $resolved = @(if (-not $Literal) { Resolve-Path -Path $InputPath -ErrorAction SilentlyContinue })
    if ($resolved.Count -eq 0) {
        Write-Error -Message "Cannot find path '$InputPath' because it does not exist." `
            -Category ObjectNotFound -TargetObject $InputPath
        return
    }
    $resolved | ForEach-Object { $_.ProviderPath }
}

# Resolve the items a Restore/Remove call acts on, from pipeline objects or patterns.
function Resolve-TrashItem($InputObject, [string[]] $Filter) {
    if ($InputObject) { return $InputObject }
    $items = @(Get-TrashItem -Filter $Filter)
    if ($items.Count -eq 0) {
        Write-Warning "No Recycle Bin item matches: $($Filter -join ', ')"
    }
    $items
}

#endregion

<#
.SYNOPSIS
    Move files or directories to the Recycle Bin.
.EXAMPLE
    Move-ItemToTrash foo.txt, *.log
.EXAMPLE
    Get-ChildItem *.tmp | Move-ItemToTrash
#>

function Move-ItemToTrash {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)]
    param(
        # Paths to recycle. Wildcards are supported.
        [Parameter(ParameterSetName = 'Path', Mandatory, Position = 0, ValueFromPipeline)]
        [string[]] $Path,

        # Paths to recycle, used exactly as typed.
        [Parameter(ParameterSetName = 'LiteralPath', Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('PSPath', 'FullName', 'LP')]
        [string[]] $LiteralPath
    )

    process {
        $targets = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $Path | ForEach-Object { Resolve-TargetPath $_ }
        }
        else {
            $LiteralPath | ForEach-Object { Resolve-TargetPath (Convert-Path -LiteralPath $_) -Literal }
        }
        foreach ($target in $targets) {
            if (-not $PSCmdlet.ShouldProcess($target, 'Move to Recycle Bin')) { continue }
            $ret = [TrashPs.Native]::Recycle($target)
            if ($ret -eq -1) {
                Write-Warning "Aborted: $target"
            }
            elseif ($ret -ne 0) {
                Write-Error -Message ("Failed to move '{0}' to Recycle Bin (code 0x{1:X})." -f $target, $ret) `
                    -TargetObject $target
            }
        }
    }
}

<#
.SYNOPSIS
    List items in the Recycle Bin of the current user.
.EXAMPLE
    Get-TrashItem *.log
    List items whose name or original path matches *.log.
#>

function Get-TrashItem {
    [CmdletBinding()]
    [OutputType('TrashPs.Item')]
    param(
        # Patterns matched against the Id, name or original path. Lists everything when omitted.
        [Parameter(Position = 0)]
        [string[]] $Filter
    )

    $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    $items = foreach ($drive in [System.IO.DriveInfo]::GetDrives()) {
        if (-not $drive.IsReady) { continue }
        $bin = Join-Path $drive.RootDirectory.FullName "`$Recycle.Bin\$sid"
        if (-not (Test-Path -LiteralPath $bin)) { continue }
        Get-ChildItem -LiteralPath $bin -Force -File -Filter '$I*' -ErrorAction SilentlyContinue |
            ForEach-Object { Read-TrashInfo $_ }
    }
    if ($Filter) { $items = $items | Where-Object { Test-TrashItemMatch $_ $Filter } }
    $items | Sort-Object DeletedAt -Descending
}

<#
.SYNOPSIS
    Restore Recycle Bin items to their original location.
.EXAMPLE
    Restore-TrashItem 'C:\work\*.txt'
.EXAMPLE
    Get-TrashItem report.docx | Select-Object -First 1 | Restore-TrashItem
    Restore the most recently deleted report.docx.
#>

function Restore-TrashItem {
    [CmdletBinding(DefaultParameterSetName = 'Filter', SupportsShouldProcess)]
    param(
        # Patterns matched against the Id, name or original path.
        [Parameter(ParameterSetName = 'Filter', Mandatory, Position = 0)]
        [string[]] $Filter,

        # Items from Get-TrashItem.
        [Parameter(ParameterSetName = 'InputObject', Mandatory, ValueFromPipeline)]
        [PSTypeName('TrashPs.Item')]
        [object[]] $InputObject
    )

    process {
        $restored = $false
        foreach ($item in Resolve-TrashItem $InputObject $Filter) {
            $dest = $item.OriginalPath
            if (Test-Path -LiteralPath $dest) {
                Write-Error -Message "Cannot restore '$dest' because it already exists." `
                    -Category ResourceExists -TargetObject $dest
                continue
            }
            if (-not $PSCmdlet.ShouldProcess($dest, 'Restore from Recycle Bin')) { continue }
            $parent = Split-Path $dest -Parent
            if (-not (Test-Path -LiteralPath $parent)) {
                New-Item -ItemType Directory -Path $parent -Force | Out-Null
            }
            Move-Item -LiteralPath $item.DataPath -Destination $dest -ErrorAction Stop
            Remove-Item -LiteralPath $item.InfoPath -Force
            $restored = $true
        }
        if ($restored) { [TrashPs.Native]::UpdateIcon() }
    }
}

<#
.SYNOPSIS
    Permanently delete items from the Recycle Bin.
.EXAMPLE
    Remove-TrashItem 'C:\tmp\*' -Force
.EXAMPLE
    Get-TrashItem | Where-Object DeletedAt -lt (Get-Date).AddDays(-30) | Remove-TrashItem
#>

function Remove-TrashItem {
    [CmdletBinding(DefaultParameterSetName = 'Filter', SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        # Patterns matched against the Id, name or original path.
        [Parameter(ParameterSetName = 'Filter', Mandatory, Position = 0)]
        [string[]] $Filter,

        # Items from Get-TrashItem.
        [Parameter(ParameterSetName = 'InputObject', Mandatory, ValueFromPipeline)]
        [PSTypeName('TrashPs.Item')]
        [object[]] $InputObject,

        # Do not ask for confirmation.
        [switch] $Force
    )

    begin {
        if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' }
    }

    process {
        $removed = $false
        foreach ($item in Resolve-TrashItem $InputObject $Filter) {
            if (-not $PSCmdlet.ShouldProcess($item.OriginalPath, 'Permanently delete from Recycle Bin')) { continue }
            Remove-Item -LiteralPath $item.DataPath -Recurse -Force
            Remove-Item -LiteralPath $item.InfoPath -Force
            $removed = $true
        }
        if ($removed) { [TrashPs.Native]::UpdateIcon() }
    }
}

<#
.SYNOPSIS
    Empty the Recycle Bin on all drives.
#>

function Clear-Trash {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        # Do not ask for confirmation.
        [switch] $Force
    )

    if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' }
    if (-not $PSCmdlet.ShouldProcess('Recycle Bin', 'Empty')) { return }
    $ret = [TrashPs.Native]::EmptyRecycleBin()
    # E_UNEXPECTED (0x8000FFFF) is returned when the Recycle Bin is already empty.
    if ($ret -ne 0 -and $ret -ne -2147418113) {
        Write-Error ("Failed to empty Recycle Bin (HRESULT 0x{0:X8})." -f $ret)
    }
}

<#
.SYNOPSIS
    Short command for everyday Recycle Bin operations.

.DESCRIPTION
    By default every given path is moved to the Recycle Bin. The switches select
    another operation, each forwarding to the matching command of this module:

        trash <path...> Move-ItemToTrash
        trash -Permanent <path...> Remove-Item -Recurse -Force
        trash -List [pattern...] Get-TrashItem
        trash -Restore <pattern...> Restore-TrashItem
        trash -Purge <pattern...> Remove-TrashItem
        trash -Empty Clear-Trash

    Patterns are matched against the Id, name or original path of Recycle Bin items.

.EXAMPLE
    trash foo.txt bar\ *.log

.EXAMPLE
    trash -List *.log | trash -Restore
#>

function trash {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Paths to recycle, or patterns for -List, -Restore and -Purge.
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, ValueFromRemainingArguments)]
        [Alias('FullName', 'Id')]
        [string[]] $Path,

        # Delete directly instead of moving to the Recycle Bin.
        [switch] $Permanent,

        # List Recycle Bin items.
        [Alias('l')]
        [switch] $List,

        # Restore matching items to their original location.
        [Alias('r')]
        [switch] $Restore,

        # Permanently delete matching items from the Recycle Bin.
        [switch] $Purge,

        # Empty the Recycle Bin.
        [Alias('e')]
        [switch] $Empty,

        # Do not ask for confirmation with -Purge and -Empty.
        [switch] $Force
    )

    # A single parameter set keeps `trash a b c` and `trash a -Permanent b` working;
    # multiple sets combined with ValueFromRemainingArguments confuse the binder.
    begin {
        $mode = 'Recycle'
        $modes = @(foreach ($m in 'List', 'Restore', 'Purge', 'Empty') { if ($PSBoundParameters[$m]) { $m } })
        if ($modes.Count -gt 1) { throw "-$($modes -join ', -') cannot be used together." }
        if ($modes.Count -eq 1) { $mode = $modes[0] }
        if ($Permanent -and $mode -ne 'Recycle') { throw "-Permanent cannot be used with -$mode." }
        if ($mode -eq 'Empty' -and $Path) { throw '-Empty does not take any path.' }
        $patterns = [System.Collections.Generic.List[string]]::new()
    }

    process {
        if ($mode -eq 'Recycle' -and $Path) {
            if ($_ -and $_.PSObject.TypeNames -contains 'TrashPs.Item') {
                throw 'Recycle Bin items cannot be recycled again; use -Restore or -Purge.'
            }
            if ($Permanent) {
                foreach ($target in $Path | ForEach-Object { Resolve-TargetPath $_ }) {
                    if ($PSCmdlet.ShouldProcess($target, 'Delete permanently')) {
                        Remove-Item -LiteralPath $target -Recurse -Force
                    }
                }
            }
            else {
                Move-ItemToTrash -Path $Path
            }
        }
        # Collect patterns from all pipeline input first, then act on them in end{}.
        if ($Path) { $patterns.AddRange([string[]]$Path) }
    }

    end {
        if ($mode -in 'Restore', 'Purge' -and $patterns.Count -eq 0) {
            throw "-$mode requires at least one Id or pattern."
        }
        switch ($mode) {
            'Recycle' { if ($patterns.Count -eq 0) { throw 'No path specified. Run "Get-Help trash" for usage.' } }
            'List' { Get-TrashItem -Filter $patterns }
            'Restore' { Restore-TrashItem -Filter $patterns }
            'Purge' { Remove-TrashItem -Filter $patterns -Force:$Force }
            'Empty' { Clear-Trash -Force:$Force }
        }
    }
}

Export-ModuleMember -Function Move-ItemToTrash, Get-TrashItem, Restore-TrashItem, Remove-TrashItem, Clear-Trash, trash