<# .SYNOPSIS Builds a folder structure from an indented text file or a list of paths. Creates folders only. .DESCRIPTION Reads a plan and creates each folder under -Path. The plan can be: - an indented text file, one folder per line, where indentation (spaces or tabs) means "inside the line above"; - output pasted from the tree command, since the box-drawing characters are treated as indentation; - a plain list of relative paths such as 'Music\Albums', from -Folder or the pipeline. Blank lines and lines starting with # are ignored, and leading "- " or "* " bullets are stripped, so a Markdown outline works too. Existing folders are left alone. This script never moves, renames or deletes anything. Supports -WhatIf. .PARAMETER Path The folder to build the structure in. Created if it doesn't exist. .PARAMETER TemplatePath An indented text file describing the structure. .PARAMETER Folder One or more relative folder paths to create. Accepts pipeline input. .PARAMETER TabWidth How many spaces a tab counts as when working out indentation. Default: 4. .EXAMPLE .\New-FolderTree.ps1 -Path D:\Library -TemplatePath .\library-layout.txt -WhatIf .EXAMPLE 'Projects\Active', 'Projects\Archive', 'Admin\Receipts' | .\New-FolderTree.ps1 -Path D:\Work #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Template')] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Template')] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string]$TemplatePath, [Parameter(Mandatory, ParameterSetName = 'List', ValueFromPipeline)] [string[]]$Folder, [ValidateRange(1, 16)][int]$TabWidth = 4 ) begin { $relativePaths = [System.Collections.Generic.List[string]]::new() # Windows' rules, even on other systems, so a plan works everywhere. $badChars = [char[]]('<>:"|?*' + -join [char[]](0..31)) function Test-FolderName([string]$Name) { $Name -and $Name -notin '.', '..' -and $Name.IndexOfAny($badChars) -lt 0 -and $Name -notmatch '[. ]$' } if ($PSCmdlet.ParameterSetName -eq 'Template') { # A stack of (indent, path) pairs: pop until the top is shallower than this line, and that's the parent. $stack = [System.Collections.Generic.List[object]]::new() $lineNo = 0 foreach ($raw in Get-Content -LiteralPath $TemplatePath) { $lineNo++ # Treat tree drawing characters (Unicode, or tree /A's +--- and \---) as indentation, and expand tabs. $line = ($raw -replace '[+\\`]---', ' ' -replace '[\u2500-\u257F|]', ' ' -replace '\t', (' ' * $TabWidth)).TrimEnd() $text = $line.TrimStart() if (-not $text -or $text.StartsWith('#')) { continue } $indent = $line.Length - $text.Length $text = ($text -replace '^[-*+]\s+', '').Trim() if (-not $text) { continue } while ($stack.Count -and $stack[$stack.Count - 1].Indent -ge $indent) { $stack.RemoveAt($stack.Count - 1) } $parent = if ($stack.Count) { $stack[$stack.Count - 1].Path } else { '' } # A line can also hold a relative path of its own, like "Books\Fiction". $parts = $text -split '[\\/]+' | Where-Object { $_ } $badPart = $parts | Where-Object { -not (Test-FolderName $_) } | Select-Object -First 1 if ($badPart) { Write-Warning "Line ${lineNo}: '$badPart' isn't a valid folder name, skipped (along with anything indented under it)."; $stack.Add([pscustomobject]@{ Indent = $indent; Path = $null }); continue } if ($stack.Count -and $null -eq $parent) { $stack.Add([pscustomobject]@{ Indent = $indent; Path = $null }); continue } $full = (@($parent) + $parts | Where-Object { $_ }) -join [IO.Path]::DirectorySeparatorChar $relativePaths.Add($full) $stack.Add([pscustomobject]@{ Indent = $indent; Path = $full }) } } } process { foreach ($item in $Folder) { $parts = $item.Trim() -split '[\\/]+' | Where-Object { $_ } $badPart = $parts | Where-Object { -not (Test-FolderName $_) } | Select-Object -First 1 if (-not $parts -or $badPart) { Write-Warning "'$item' isn't a valid relative folder path, skipped."; continue } $relativePaths.Add($parts -join [IO.Path]::DirectorySeparatorChar) } } end { if (-not $relativePaths.Count) { Write-Warning 'Nothing to create.'; return } $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($relative in $relativePaths) { # Add every parent too, so the output lists each folder once, top-down. $parts = $relative -split '[\\/]' for ($i = 1; $i -le $parts.Count; $i++) { $sub = $parts[0..($i - 1)] -join [IO.Path]::DirectorySeparatorChar if (-not $seen.Add($sub)) { continue } $full = Join-Path $Path $sub $result = [ordered]@{ Folder = $sub; Status = $null; FullName = $full } try { if (Test-Path -LiteralPath $full -PathType Container) { $result.Status = 'Exists' } elseif (Test-Path -LiteralPath $full) { throw 'A file with that name is in the way.' } elseif ($PSCmdlet.ShouldProcess($full, 'Create folder')) { $null = New-Item -ItemType Directory -Path $full -Force -ErrorAction Stop $result.Status = 'Created' } else { $result.Status = 'WouldCreate' } } catch { $result.Status = "Failed: $($_.Exception.Message)" Write-Warning "${sub}: $($_.Exception.Message)" } [pscustomobject]$result } } }