<# .SYNOPSIS Sorts the loose files in a folder (Downloads, say) into category subfolders by extension. .DESCRIPTION Looks at the files sitting directly in -Path, works out a category for each one from its extension (or from a name pattern in your own rules file), and moves it into a subfolder of that name, creating the folder if it has to. Nothing is ever overwritten: if a file with the same name is already there, the moved one gets " (2)", " (3)" and so on. Optionally deletes stale partial downloads (.part, .crdownload and friends) and removes empty subfolders afterwards. Everything goes through ShouldProcess, so -WhatIf shows the whole plan. .PARAMETER Path The messy folder. Default: the Downloads folder in your profile. .PARAMETER Destination Where the category folders go. Default: inside -Path. .PARAMETER RulePath A JSON file of extra rules, checked before the built-in list. Each key is a folder name and each value is a list of extensions (".stl") or wildcard name patterns ("DRV_*", "*invoice*"). .PARAMETER OtherFolder Folder for files no rule matches. Default: Other. Use -SkipUnknown to leave them alone instead. .PARAMETER SkipUnknown Leave files with no matching rule where they are. .PARAMETER Exclude Wildcard patterns for files to leave alone, such as '*.ini' or 'keep-*'. .PARAMETER CleanPartial Delete partial downloads (.part, .crdownload, .partial, .download, .opdownload) older than -PartialAgeHours. .PARAMETER PartialAgeHours Only partial downloads this old are deleted, so anything still downloading is left alone. Default: 24. .PARAMETER RemoveEmptyFolders Remove empty subfolders under -Path once the moves are done. -Path itself is never removed. .EXAMPLE .\Move-FileByType.ps1 -WhatIf .EXAMPLE .\Move-FileByType.ps1 -Path D:\Inbox -RulePath .\my-rules.json -CleanPartial -RemoveEmptyFolders #> [CmdletBinding(SupportsShouldProcess)] param( [ValidateNotNullOrEmpty()] [string]$Path = (Join-Path $HOME 'Downloads'), [string]$Destination, [string]$RulePath, [ValidateNotNullOrEmpty()][string]$OtherFolder = 'Other', [switch]$SkipUnknown, [string[]]$Exclude = @('desktop.ini', 'Thumbs.db', '.DS_Store'), [switch]$CleanPartial, [ValidateRange(0, 8760)][int]$PartialAgeHours = 24, [switch]$RemoveEmptyFolders ) # Built-in categories. First match wins, so ambiguous extensions (.ts, .bin, .key) go to the first folder listed. $categories = [ordered]@{ 'Archives' = '.zip', '.rar', '.7z', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.zst' 'Documents' = '.doc', '.docx', '.odt', '.rtf', '.pages' 'PDFs' = '.pdf' 'Spreadsheets' = '.xls', '.xlsx', '.xlsm', '.ods', '.csv', '.numbers' 'Presentations' = '.ppt', '.pptx', '.odp' 'Text' = '.txt', '.md', '.log', '.nfo' 'eBooks' = '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu' 'Comics' = '.cbz', '.cbr', '.cb7' 'Images' = '.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.bmp', '.tif', '.tiff', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng' 'Design' = '.psd', '.ai', '.eps', '.svg', '.xcf', '.afdesign', '.afphoto', '.fig', '.sketch' 'Audio' = '.mp3', '.flac', '.wav', '.m4a', '.aac', '.ogg', '.opus', '.wma', '.aiff' 'Video' = '.mp4', '.mkv', '.avi', '.mov', '.wmv', '.webm', '.m4v', '.mpg', '.mpeg', '.ts' 'Subtitles' = '.srt', '.sub', '.idx', '.vtt', '.ass' 'Installers' = '.exe', '.msi', '.msix', '.msixbundle', '.appx', '.appxbundle', '.dmg', '.pkg', '.deb', '.rpm', '.apk' 'Disk Images' = '.iso', '.img', '.bin', '.cue', '.vhd', '.vhdx', '.vmdk', '.ova' 'Scripts' = '.ps1', '.psm1', '.bat', '.cmd', '.sh', '.py', '.reg', '.vbs' 'Code' = '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.html', '.css', '.js', '.sql' 'Fonts' = '.ttf', '.otf', '.woff', '.woff2' '3D and CAD' = '.stl', '.3mf', '.obj', '.step', '.stp', '.gcode', '.dwg', '.dxf', '.blend' 'Torrents' = '.torrent' 'Shortcuts' = '.url', '.lnk', '.webloc' 'Certificates' = '.cer', '.crt', '.pem', '.pfx', '.p12', '.key' } $partialExtensions = '.part', '.crdownload', '.partial', '.download', '.opdownload' try { $source = (Get-Item -LiteralPath $Path -ErrorAction Stop).FullName } catch { throw "Can't open '$Path': $($_.Exception.Message)" } if (-not $Destination) { $Destination = $source } # Your own rules come first: name patterns, then extensions. $namePatterns = [System.Collections.Generic.List[object]]::new() $extensionMap = @{} if ($RulePath) { try { $custom = Get-Content -LiteralPath $RulePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { throw "Couldn't read rules from '$RulePath': $($_.Exception.Message)" } foreach ($prop in $custom.PSObject.Properties) { foreach ($rule in @($prop.Value)) { if ($rule -match '^\.[^*?]+$') { if (-not $extensionMap.ContainsKey($rule.ToLower())) { $extensionMap[$rule.ToLower()] = $prop.Name } } else { $namePatterns.Add([pscustomobject]@{ Pattern = $rule; Folder = $prop.Name }) } } } } foreach ($folder in $categories.Keys) { foreach ($ext in $categories[$folder]) { if (-not $extensionMap.ContainsKey($ext)) { $extensionMap[$ext] = $folder } } } # Track names already claimed in each target folder, so -WhatIf shows the same renames a real run would make. $claimed = @{} function Get-FreeName([string]$Folder, [string]$Name) { if (-not $claimed.ContainsKey($Folder)) { $claimed[$Folder] = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) } $base = [IO.Path]::GetFileNameWithoutExtension($Name) $ext = [IO.Path]::GetExtension($Name) $candidate = $Name $n = 1 while ($claimed[$Folder].Contains($candidate) -or (Test-Path -LiteralPath (Join-Path $Folder $candidate))) { $n++ $candidate = "$base ($n)$ext" } $null = $claimed[$Folder].Add($candidate) $candidate } $cutoff = (Get-Date).AddHours(-$PartialAgeHours) $files = Get-ChildItem -LiteralPath $source -File | Where-Object { $file = $_ $file.FullName -ne $PSCommandPath -and -not ($Exclude | Where-Object { $file.Name -like $_ }) } foreach ($file in $files) { $ext = $file.Extension.ToLower() $result = [ordered]@{ Name = $file.Name; Category = $null; Action = $null; Destination = $null } try { if ($ext -in $partialExtensions) { $result.Category = 'Partial download' if (-not $CleanPartial) { continue } if ($file.LastWriteTime -gt $cutoff) { $result.Action = 'SkippedRecentPartial' } elseif ($PSCmdlet.ShouldProcess($file.FullName, 'Delete partial download')) { Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop $result.Action = 'DeletedPartial' } else { $result.Action = 'WouldDelete' } [pscustomobject]$result continue } $category = ($namePatterns | Where-Object { $file.Name -like $_.Pattern } | Select-Object -First 1).Folder if (-not $category) { $category = $extensionMap[$ext] } if (-not $category) { if ($SkipUnknown) { continue } $category = $OtherFolder } $result.Category = $category $targetDir = Join-Path $Destination $category $newName = Get-FreeName -Folder $targetDir -Name $file.Name $target = Join-Path $targetDir $newName $result.Destination = $target if ($PSCmdlet.ShouldProcess($file.FullName, "Move to $target")) { if (-not (Test-Path -LiteralPath $targetDir -PathType Container)) { $null = New-Item -ItemType Directory -Path $targetDir -Force -ErrorAction Stop } Move-Item -LiteralPath $file.FullName -Destination $target -ErrorAction Stop $result.Action = if ($newName -ne $file.Name) { 'MovedRenamed' } else { 'Moved' } } else { $result.Action = 'WouldMove' } } catch { $result.Action = "Failed: $($_.Exception.Message)" Write-Warning "$($file.Name): $($_.Exception.Message)" } [pscustomobject]$result } if ($RemoveEmptyFolders) { # Deepest first, so a folder that only held empty folders goes too. $dirs = Get-ChildItem -LiteralPath $source -Directory -Recurse -Force -ErrorAction SilentlyContinue | Sort-Object { $_.FullName.Length } -Descending # Folders removed (or that -WhatIf would remove) count as gone, so their parents show up too. $gone = [System.Collections.Generic.HashSet[string]]::new() foreach ($dir in $dirs) { if (Get-ChildItem -LiteralPath $dir.FullName -Force -ErrorAction SilentlyContinue | Where-Object { -not $gone.Contains($_.FullName) } | Select-Object -First 1) { continue } $result = [ordered]@{ Name = $dir.Name; Category = 'Empty folder'; Action = $null; Destination = $dir.FullName } try { if ($PSCmdlet.ShouldProcess($dir.FullName, 'Remove empty folder')) { Remove-Item -LiteralPath $dir.FullName -ErrorAction Stop $result.Action = 'RemovedEmptyFolder' } else { $result.Action = 'WouldRemove' } $null = $gone.Add($dir.FullName) } catch { $result.Action = "Failed: $($_.Exception.Message)" Write-Warning "$($dir.FullName): $($_.Exception.Message)" } [pscustomobject]$result } }