<# .SYNOPSIS Sorts comic and manga files (or whole series folders) into genre or decade folders from a CSV you provide. .DESCRIPTION Looks at every comic file (.cbz, .cbr, .cb7 by default) and every series folder directly inside -Path, works out the series name, and looks it up in a CSV with Series, Genre and Year columns. Matching items are moved into \ (or \, like 1990s, with -GroupBy Decade). Series names come from the folder or file name, with the usual clutter removed: a trailing (2019), [scanlator] tags, and volume or issue numbers like v01, Vol. 3, #012, c045 or a bare 012. If there's no exact match, the longest series in the CSV that the name starts with wins, so "Series - Side Story" lands with "Series". For -GroupBy Decade, the year comes from the CSV, or from a (1998) in the name. Anything it can't place is reported with Status Uncategorized and left alone, unless you name an -UncategorizedFolder. When it's done, it counts comic files again and warns if the totals don't match. Supports -WhatIf. .PARAMETER Path The folder to sort. Only items directly inside it are moved. .PARAMETER MapCsv CSV with a Series column plus Genre and/or Year. Extra columns are ignored. .PARAMETER Destination Where the genre or decade folders go. Defaults to -Path. .PARAMETER GroupBy Genre (default) or Decade. .PARAMETER Extension Comic file extensions. Default: .cbz, .cbr, .cb7. .PARAMETER UncategorizedFolder Folder name to move unmatched items into. By default they stay where they are. .EXAMPLE .\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv -WhatIf .EXAMPLE .\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv | Where-Object Status -eq Uncategorized | Select-Object Name, Series #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })][string]$Path, [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })][string]$MapCsv, [string]$Destination, [ValidateSet('Genre', 'Decade')][string]$GroupBy = 'Genre', [string[]]$Extension = @('.cbz', '.cbr', '.cb7'), [ValidatePattern('^[^\\/:*?"<>|]+$')][string]$UncategorizedFolder ) $Path = (Resolve-Path -LiteralPath $Path).ProviderPath $Destination = if ($Destination) { $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Destination) } else { $Path } $Extension = @($Extension | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } }) function Get-MatchKey([string]$Text) { ($Text.ToLowerInvariant() -replace '&', ' and ' -replace '[^\p{L}\p{N}]+', ' ').Trim() } function Get-SeriesName([string]$Name) { $n = $Name -replace '\[[^\]]*\]', '' -replace '\((19|20)\d{2}\)', '' -replace '\((Digital|Complete|Ongoing|[^)]*Scan[^)]*)\)', '' -replace '_', ' ' # Volume, issue and chapter numbers at the end: v01, Vol. 3, Volume 03, #012, c045, Chapter 12, or a bare 012. $n = $n -replace '(?i)[\s\-]+(v|vol\.?|volume|c|ch\.?|chapter|#|issue)\s*\d+(\.\d+)?(\s*-\s*\d+)?\b.*$', '' $n = $n -replace '\s+\d{1,4}(\.\d+)?\s*$', '' ($n -replace '\s{2,}', ' ').Trim(' ', '-', '.') } function Get-ComicCount([string[]]$Roots) { $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($r in $Roots | Select-Object -Unique) { if (Test-Path -LiteralPath $r) { Get-ChildItem -LiteralPath $r -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $Extension -contains $_.Extension.ToLower() } | ForEach-Object { $null = $seen.Add($_.FullName) } } } $seen.Count } # Load the map. Keys are normalized so punctuation and case don't matter. $map = @{} foreach ($row in Import-Csv -LiteralPath $MapCsv) { if (-not $row.Series) { continue } $map[(Get-MatchKey $row.Series)] = [pscustomobject]@{ Series = $row.Series.Trim(); Genre = "$($row.Genre)".Trim(); Year = "$($row.Year)".Trim() } } if (-not $map.Count) { throw "No rows with a Series column found in $MapCsv." } $keysByLength = $map.Keys | Sort-Object Length -Descending Write-Verbose "Loaded $($map.Count) series from $MapCsv" # Category folders this run could create, so a re-run doesn't try to sort them. $categoryNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($entry in $map.Values) { if ($entry.Genre) { $null = $categoryNames.Add(($entry.Genre -replace '[\\/:*?"<>|]', '-')) } } if ($UncategorizedFolder) { $null = $categoryNames.Add($UncategorizedFolder) } $before = Get-ComicCount @($Path, $Destination) $items = Get-ChildItem -LiteralPath $Path | Where-Object { ($_.PSIsContainer -and -not $categoryNames.Contains($_.Name) -and $_.Name -notmatch '^\d{4}s$' -and $_.FullName -ne $Destination) -or (-not $_.PSIsContainer -and $Extension -contains $_.Extension.ToLower()) } foreach ($item in $items) { $baseName = if ($item.PSIsContainer) { $item.Name } else { $item.BaseName } $series = Get-SeriesName $baseName $key = Get-MatchKey $series $entry = $map[$key] if (-not $entry) { $prefix = $keysByLength | Where-Object { $key.StartsWith("$_ ") } | Select-Object -First 1 if ($prefix) { $entry = $map[$prefix] } } $category = $null if ($entry -and $GroupBy -eq 'Genre' -and $entry.Genre) { $category = $entry.Genre -replace '[\\/:*?"<>|]', '-' } elseif ($GroupBy -eq 'Decade') { $year = if ($entry -and $entry.Year -match '^\d{4}$') { [int]$entry.Year } elseif ($baseName -match '\(((19|20)\d{2})\)') { [int]$Matches[1] } if ($year) { $category = '{0}s' -f ([math]::Floor($year / 10) * 10) } } $result = [ordered]@{ Name = $item.Name; Type = $(if ($item.PSIsContainer) { 'Folder' } else { 'File' }); Series = $series; MatchedAs = $(if ($entry) { $entry.Series }); Category = $category; Destination = $null; Status = $null } if (-not $category) { $result.Status = 'Uncategorized' if (-not $UncategorizedFolder) { [pscustomobject]$result; continue } $category = $UncategorizedFolder $result.Category = $category } $catDir = Join-Path $Destination $category $target = Join-Path $catDir $item.Name $result.Destination = $target $leftBehind = 0 $fileCount = if ($item.PSIsContainer) { Get-ComicCount $item.FullName } else { 1 } try { if (-not $PSCmdlet.ShouldProcess($item.FullName, "Move to $catDir")) { if (-not $result.Status) { $result.Status = 'WhatIf' }; [pscustomobject]$result; continue } if (-not (Test-Path -LiteralPath $catDir)) { $null = New-Item -ItemType Directory -Path $catDir -ErrorAction Stop } if (-not (Test-Path -LiteralPath $target)) { Move-Item -LiteralPath $item.FullName -Destination $target -ErrorAction Stop } elseif ($item.PSIsContainer) { # The series folder already exists there (an earlier run, or a split library): merge into it. foreach ($child in Get-ChildItem -LiteralPath $item.FullName) { $childTarget = Join-Path $target $child.Name if (Test-Path -LiteralPath $childTarget) { Write-Warning "$($item.Name): '$($child.Name)' already exists in $target. Left in place."; $leftBehind++; continue } Move-Item -LiteralPath $child.FullName -Destination $childTarget -ErrorAction Stop } if (-not (Get-ChildItem -LiteralPath $item.FullName -Force)) { Remove-Item -LiteralPath $item.FullName -ErrorAction Stop } } else { throw "A file named '$($item.Name)' already exists in $catDir." } if ($leftBehind) { $result.Status = "PartlyMoved: $leftBehind item(s) already existed"; [pscustomobject]$result; continue } $moved = if ($item.PSIsContainer) { Get-ComicCount $target } else { [int](Test-Path -LiteralPath $target) } $result.Status = if ($moved -ge $fileCount) { if ($result.Status -eq 'Uncategorized') { 'MovedUncategorized' } else { 'Moved' } } else { "Check: $fileCount comic file(s) before, $moved after" } } catch { $result.Status = "Failed: $($_.Exception.Message)" Write-Warning "$($item.Name): $($_.Exception.Message)" } [pscustomobject]$result } if (-not $WhatIfPreference) { $after = Get-ComicCount @($Path, $Destination) if ($after -ne $before) { Write-Warning "Comic file count changed: $before before, $after after. Something went missing or got duplicated; check the Failed rows." } else { Write-Verbose "Verified: $after comic file(s) before and after." } }