<# .SYNOPSIS Cleans up game file names: strips dump flags, normalizes region and revision tags, and optionally adds a year. .DESCRIPTION Works on names in the "Title (Region) (Rev x) [flags]" style used by No-Intro, Redump and GoodTools sets. For each file it: - removes square-bracket dump flags such as [!], [b1], [a2], [o1], [h1C], [f1] (keep them with -KeepFlags) - removes round-bracket tags that match -StripTag, such as (Unl), (Alt 1) and language lists like (En,Fr,De) - turns short region codes like (U), (E), (J), (UE), (US), (JP) into full names like (USA) and (Japan), or the other way round with -RegionStyle Short - tidies revision tags: (REV01) and (Rev 01) become (Rev 1), (V1.1) becomes (v1.1) - optionally moves a trailing article ("Legend of Zelda, The") to the front, and title-cases the title - optionally adds a release year looked up by title in a CSV you provide (columns: Title, Year) Every file gets an object back with the old and new names, so you can pipe the lot to Export-Csv. Supports -WhatIf. Case-only renames work on Windows too. .PARAMETER Path One or more folders (or single files). Defaults to the current folder. .PARAMETER Extension File extensions to consider. Default: every file. .PARAMETER Recurse Include subfolders. .PARAMETER StripTag Regular expressions for round-bracket tags to remove. Default: Unl, Unlicensed, Alt and language lists. .PARAMETER KeepFlags Keep square-bracket flags like [!] and [T+Eng] instead of removing them. .PARAMETER RegionStyle Full (default) writes (USA), (Europe), (Japan), (World). Short writes (US), (EU), (JP), (W). .PARAMETER MoveArticle Turn "Title, The" into "The Title" (also A and An). .PARAMETER TitleCase Title-case the game title. All-caps words like NBA or WWF are left alone. .PARAMETER YearCsv CSV with Title and Year columns. Matching ignores case and punctuation. .PARAMETER YearStyle Leading (default) writes "1991 - Title (USA).ext". Tag writes "Title (USA) (1991).ext". .EXAMPLE .\Rename-RomFile.ps1 -Path D:\Games\snes -WhatIf .EXAMPLE .\Rename-RomFile.ps1 -Path D:\Games\genesis -MoveArticle -YearCsv .\genesis-years.csv | Export-Csv renames.csv -NoTypeInformation #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string[]]$Path = '.', [string[]]$Extension, [switch]$Recurse, [string[]]$StripTag = @('^Unl$', '^Unlicensed$', '^Alt( \d+)?$', '^(En|Ja|Fr|De|Es|It|Nl|Pt|Sv|No|Da|Fi|Zh|Ko|Pl|Ru)(,(En|Ja|Fr|De|Es|It|Nl|Pt|Sv|No|Da|Fi|Zh|Ko|Pl|Ru))*$'), [switch]$KeepFlags, [ValidateSet('Full', 'Short')][string]$RegionStyle = 'Full', [switch]$MoveArticle, [switch]$TitleCase, [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })][string]$YearCsv, [ValidateSet('Leading', 'Tag')][string]$YearStyle = 'Leading' ) begin { $toFull = @{ 'U' = 'USA'; 'US' = 'USA'; 'E' = 'Europe'; 'EU' = 'Europe'; 'J' = 'Japan'; 'JP' = 'Japan'; 'W' = 'World'; 'WW' = 'World' 'UE' = 'USA, Europe'; 'JU' = 'Japan, USA'; 'JUE' = 'World'; 'A' = 'Australia'; 'B' = 'Brazil'; 'K' = 'Korea'; 'F' = 'France' 'G' = 'Germany'; 'S' = 'Spain'; 'I' = 'Italy'; 'C' = 'China'; 'UK' = 'UK' } $toShort = @{ 'USA' = 'US'; 'Europe' = 'EU'; 'Japan' = 'JP'; 'World' = 'W'; 'Australia' = 'AU'; 'Brazil' = 'BR'; 'Korea' = 'KR'; 'France' = 'FR'; 'Germany' = 'DE'; 'Spain' = 'ES'; 'Italy' = 'IT'; 'China' = 'CN'; 'UK' = 'UK' } $fullNames = @($toShort.Keys) + 'Asia', 'Canada', 'Hong Kong', 'Netherlands', 'Sweden', 'Taiwan', 'Russia', 'Scandinavia' $extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } }) $smallWords = 'a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'vs', 'with' function Get-MatchKey([string]$Text) { ($Text.ToLowerInvariant() -replace '&', 'and' -replace '[^a-z0-9]+', ' ').Trim() } $years = @{} if ($YearCsv) { foreach ($row in Import-Csv -LiteralPath $YearCsv) { if ($row.Title -and $row.Year -match '^\d{4}$') { $years[(Get-MatchKey $row.Title)] = $row.Year } } Write-Verbose "Loaded $($years.Count) titles from $YearCsv" } function Format-Region([string]$Tag) { $parts = if ($toFull.ContainsKey($Tag)) { $toFull[$Tag] -split ',\s*' } else { $Tag -split '\s*,\s*' } $parts = foreach ($p in $parts) { if ($toFull.ContainsKey($p)) { $toFull[$p] } else { $p } } if (@($parts | Where-Object { $fullNames -contains $_ }).Count -ne @($parts).Count) { return $null } if ($RegionStyle -eq 'Short') { $parts = foreach ($p in $parts) { if ($toShort.ContainsKey($p)) { $toShort[$p] } else { $p } } } ($parts | Select-Object -Unique) -join ', ' } function Get-CleanName([string]$BaseName) { $title = ($BaseName -replace '\s*[\(\[].*$', '').Trim() $rest = $BaseName.Substring([Math]::Min($BaseName.Length, $BaseName.IndexOf($title) + $title.Length)) if (-not $title) { return $BaseName } $tags = foreach ($m in [regex]::Matches($rest, '\(([^\)]+)\)|\[([^\]]+)\]')) { if ($m.Groups[1].Success) { $tag = $m.Groups[1].Value.Trim() if (@($StripTag | Where-Object { $tag -match $_ }).Count) { continue } if ($tag -match '^\d{4}$' -and $years.Count) { continue } # re-added from the CSV below $region = Format-Region $tag if ($region) { "($region)"; continue } if ($tag -match '^rev\s*0*([0-9]+|[a-z])$') { "(Rev $($Matches[1].ToUpper()))"; continue } if ($tag -match '^v\s*(\d+(\.\d+)*)$') { "(v$($Matches[1]))"; continue } "($tag)" } elseif ($KeepFlags) { "[$($m.Groups[2].Value.Trim())]" } } if ($MoveArticle -and $title -match '^(?
.+?), (?The|A|An)(?\s*-\s*.*)?$') { $title = "$($Matches.art) $($Matches.main)$($Matches.tail)" } if ($TitleCase) { $words = $title -split ' ' $title = (& { for ($i = 0; $i -lt $words.Count; $i++) { $w = $words[$i] if ($w -cmatch '^[A-Z0-9\W]+$' -and $w -match '[A-Z]{2}') { $w; continue } # NBA, WWF, II $lower = $w.ToLowerInvariant() if ($i -gt 0 -and $smallWords -contains $lower -and $words[$i - 1] -ne '-') { $lower; continue } if ($lower.Length) { $lower.Substring(0, 1).ToUpperInvariant() + $lower.Substring(1) } else { $w } } }) -join ' ' } $year = $null if ($years.Count) { $year = $years[(Get-MatchKey $title)] if (-not $year -and $title -match '^(The|A|An) (.+)$') { $year = $years[(Get-MatchKey "$($Matches[2]), $($Matches[1])")] } if (-not $year -and $title -match '^(.+), (The|A|An)$') { $year = $years[(Get-MatchKey "$($Matches[2]) $($Matches[1])")] } if ($title -match '^\d{4} - ') { $year = $null } } $name = (@($title) + @($tags | Select-Object -Unique)) -join ' ' if ($year -and $YearStyle -eq 'Leading') { $name = "$year - $name" } elseif ($year) { $name = "$name ($year)" } ($name -replace '\s{2,}', ' ').Trim().TrimEnd('.') } } process { foreach ($item in $Path) { $files = if (Test-Path -LiteralPath $item -PathType Container) { Get-ChildItem -LiteralPath $item -File -Recurse:$Recurse | Where-Object { -not $extFilter -or $extFilter -contains $_.Extension.ToLower() } } elseif (Test-Path -LiteralPath $item -PathType Leaf) { Get-Item -LiteralPath $item } else { Write-Warning "Not found: $item"; continue } $planned = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($file in $files) { $newName = (Get-CleanName $file.BaseName) + $file.Extension $result = [ordered]@{ Folder = $file.DirectoryName; OldName = $file.Name; NewName = $newName; Status = 'Unchanged' } if ($newName -ceq $file.Name) { [pscustomobject]$result; continue } $target = Join-Path $file.DirectoryName $newName $caseOnly = $newName -eq $file.Name if (-not $planned.Add($target) -or (-not $caseOnly -and (Test-Path -LiteralPath $target))) { $result.Status = 'SkippedExists' Write-Warning "$($file.Name): '$newName' already exists (or another file would get that name). Left alone." [pscustomobject]$result; continue } $result.Status = 'WhatIf' if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) { try { if ($caseOnly) { # Windows won't rename to a name that differs only by case, so go through a temporary name. $temp = $file.Name + '.' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.tmp' Rename-Item -LiteralPath $file.FullName -NewName $temp -ErrorAction Stop Rename-Item -LiteralPath (Join-Path $file.DirectoryName $temp) -NewName $newName -ErrorAction Stop } else { Rename-Item -LiteralPath $file.FullName -NewName $newName -ErrorAction Stop } $result.Status = 'Renamed' } catch { $result.Status = "Failed: $($_.Exception.Message)" Write-Warning "$($file.Name): $($_.Exception.Message)" } } [pscustomobject]$result } } }