<# .SYNOPSIS Renames game backups to "(GENRE) Title (REGION) (NOTES).ext", looking the genre up in a CSV you keep. .DESCRIPTION For every file it: - takes the title from the part of the name before the first bracket - looks the title up in a CSV of title patterns and genre codes (columns: Title, Genre). A row matches when its Title appears anywhere in the game's title, ignoring case and punctuation, and the longest match wins, so a "Mega Man X" row beats a "Mega Man" row - turns the region tag into a short code: (USA) or (U) becomes USA, (Japan) becomes JPN, (Europe) becomes EUR, and (World) becomes "USA EUR JPN" - turns tags like (Beta), (Proto), (Demo), (Unl), (Hack) and [T+Eng] into note codes - skips files that already start with a genre code, unless you pass -Retag Titles with no match are reported with Status NoGenre and left alone. Pass -DefaultGenre to use a catch-all code, or -Prompt to be asked for each one (answers can be saved back to the CSV with -SaveMap). Every file gets an object back with the old and new names. Supports -WhatIf. .PARAMETER Path One or more folders (or single files). Defaults to the current folder. .PARAMETER MapCsv CSV with Title and Genre columns. Genre codes are 3 or 4 capital letters. .PARAMETER Extension File extensions to consider. Default: every file. .PARAMETER Recurse Include subfolders. .PARAMETER DefaultGenre Code to use when nothing in the CSV matches, for example MISC. .PARAMETER DefaultRegion Region code for files with no recognizable region tag. Default: USA. .PARAMETER Retag Also process files that already start with a genre code, replacing the old code. .PARAMETER Prompt Ask for a genre code for each unmatched title. .PARAMETER SaveMap With -Prompt, append your answers to the CSV so the next run knows them. .EXAMPLE .\Add-RomGenreTag.ps1 -Path D:\Games\nes -MapCsv .\nes-genres.csv -WhatIf .EXAMPLE .\Add-RomGenreTag.ps1 -Path D:\Games\dreamcast -MapCsv .\dc-genres.csv -Extension .chd -Prompt -SaveMap #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string[]]$Path = '.', [Parameter(Mandatory)] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string]$MapCsv, [string[]]$Extension, [switch]$Recurse, [ValidatePattern('^[A-Z]{3,4}$')] [string]$DefaultGenre, [ValidatePattern('^[A-Z]{3}( [A-Z]{3})*$')] [string]$DefaultRegion = 'USA', [switch]$Retag, [switch]$Prompt, [switch]$SaveMap ) begin { function Get-MatchKey([string]$Text) { ' ' + ($Text.ToLowerInvariant() -replace '[\u0027\u2019]', '' -replace '&', 'and' -replace '[^a-z0-9]+', ' ').Trim() + ' ' } $map = [System.Collections.Generic.List[object]]::new() foreach ($row in Import-Csv -LiteralPath $MapCsv) { if (-not $row.Title -or -not $row.Genre) { continue } $code = $row.Genre.Trim().ToUpperInvariant() if ($code -notmatch '^[A-Z]{3,4}$') { Write-Warning "Skipping CSV row '$($row.Title)': '$($row.Genre)' isn't a 3 or 4 letter code."; continue } $map.Add([pscustomobject]@{ Key = (Get-MatchKey $row.Title); Genre = $code }) } Write-Verbose "Loaded $($map.Count) title patterns from $MapCsv" $regionCodes = @{ 'usa' = 'USA'; 'us' = 'USA'; 'u' = 'USA'; 'japan' = 'JPN'; 'jp' = 'JPN'; 'j' = 'JPN'; 'europe' = 'EUR'; 'eu' = 'EUR'; 'e' = 'EUR' 'france' = 'FRA'; 'f' = 'FRA'; 'germany' = 'GER'; 'g' = 'GER'; 'spain' = 'ESP'; 's' = 'ESP'; 'italy' = 'ITA'; 'i' = 'ITA' 'world' = 'USA EUR JPN'; 'w' = 'USA EUR JPN'; 'ue' = 'USA EUR'; 'ju' = 'JPN USA'; 'jue' = 'USA EUR JPN' } $noteCodes = [ordered]@{ '^beta( \d+)?$' = 'BETA'; '^(proto|prototype)$' = 'PROT'; '^demo$' = 'DEMO'; '^(hack|h\d*\w*)$' = 'HACK' '^(unl|unlicensed)$' = 'UNLS'; '^t[+-]\w+' = 'TRAD'; '^(translated|fan translation)$' = 'TRAD'; '^re-?release$' = 'RELN' } $extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } }) $newRows = [System.Collections.Generic.List[object]]::new() function Get-Genre([string]$Title) { $key = Get-MatchKey $Title $best = $map | Where-Object { $key.Contains($_.Key) } | Sort-Object { $_.Key.Length } -Descending | Select-Object -First 1 if ($best) { return $best.Genre } if ($Prompt) { do { $answer = (Read-Host "Genre code for '$Title' (3-4 letters, Enter to skip)").Trim().ToUpperInvariant() } until (-not $answer -or $answer -match '^[A-Z]{3,4}$') if ($answer) { $map.Add([pscustomobject]@{ Key = $key; Genre = $answer }) $newRows.Add([pscustomobject]@{ Title = $Title; Genre = $answer }) return $answer } } $DefaultGenre } } 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) { $result = [ordered]@{ Folder = $file.DirectoryName; OldName = $file.Name; NewName = $file.Name; Genre = $null; Status = 'Unchanged' } $base = $file.BaseName if ($base -match '^\(([A-Z]{3,4})\)\s*(.*)$') { if (-not $Retag) { $result.Status = 'AlreadyTagged'; [pscustomobject]$result; continue } $base = $Matches[2] } $title = ($base -replace '\s*[\(\[].*$', '').Trim() if (-not $title) { $result.Status = 'NoTitle'; [pscustomobject]$result; continue } $regions = [System.Collections.Generic.List[string]]::new() $notes = [System.Collections.Generic.List[string]]::new() foreach ($m in [regex]::Matches($base.Substring($base.IndexOf($title) + $title.Length), '[\(\[]([^\)\]]+)[\)\]]')) { $tag = $m.Groups[1].Value.Trim() $parts = @($tag -split '\s*,\s*' | ForEach-Object { $_.ToLowerInvariant() }) if (@($parts | Where-Object { $regionCodes.ContainsKey($_) }).Count -eq $parts.Count) { foreach ($p in $parts) { foreach ($c in ($regionCodes[$p] -split ' ')) { if (-not $regions.Contains($c)) { $regions.Add($c) } } } continue } foreach ($pattern in $noteCodes.Keys) { if ($tag -match $pattern -and -not $notes.Contains($noteCodes[$pattern])) { $notes.Add($noteCodes[$pattern]); break } } } $region = if ($regions.Count) { $regions -join ' ' } else { $DefaultRegion } $genre = Get-Genre $title $result.Genre = $genre if (-not $genre) { $result.Status = 'NoGenre' Write-Verbose "No genre for '$title'" [pscustomobject]$result; continue } $newName = "($genre) $title ($region)" if ($notes.Count) { $newName += " ($($notes -join ' '))" } $newName = ($newName -replace '[\\/:*?"<>|]', '-') + $file.Extension $result.NewName = $newName 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) { $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 } } } end { if ($SaveMap -and $newRows.Count -and $PSCmdlet.ShouldProcess($MapCsv, "Add $($newRows.Count) new title(s)")) { $newRows | Export-Csv -LiteralPath $MapCsv -Append -NoTypeInformation } }