Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Build a Folder Structure from a Text Outline with PowerShell

Write the folders you want as an indented outline, or paste the output of tree, and create the whole structure in one go without moving a single file.

AT A GLANCENew-FolderTree.ps1
What it does
Reads an indented text outline (or tree output, or a plain list of relative paths) and creates every folder in it under a root you choose. Reports what it created and what was already there. Never moves, renames or deletes anything.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
Permissions
Write access to the folder where the structure goes. No admin rights.
Runs on
Windows 10/11 (and PowerShell 7 on macOS or Linux)
Tested
Parse-checked and run in PowerShell 7.4 with -WhatIf and for real, using an outline with mixed tabs and spaces, Unicode tree output, tree /A-style output, a Markdown bullet list, invalid names, pipeline input and a second run over folders that already existed

Part 6 of the thread File wrangling

Before I reorganize anything big, I like to build the empty shelves first. Make the folders, live with them for a day, drag a few files in by hand, and only then let a script start shoveling things around.

The first version of this did exactly that for my own library, with the whole structure typed into the script as a few hundred quoted paths. Every change meant editing code, and the shape of the thing was buried in string literals where you couldn't really see it.

So the plan moves out into a plain text file. Indent a line and it goes inside the one above. Paste the output of tree and that works too, box-drawing characters and all. The script creates whatever's missing, tells you what was already there, and never touches a file.

New-FolderTree.ps1Download
<#
.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
        }
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring—The folder to build the structure in. Required. Created if it doesn't exist yet.
-TemplatePathstring—An indented text file describing the structure. Blank lines and lines starting with "#" are ignored.
-Folderstring[]—One or more relative folder paths, like Projects\Active. Takes pipeline input. Use this or TemplatePath.
-TabWidthint4How many spaces a tab counts as, for outlines that mix tabs and spaces.

Run it

Preview a structure from an outline file.

.\New-FolderTree.ps1 -Path D:\Library -TemplatePath .\library-layout.txt -WhatIf

Build it for real, and show only the folders that were new.

.\New-FolderTree.ps1 -Path D:\Library -TemplatePath .\library-layout.txt | Where-Object Status -eq 'Created'

Copy the shape of an existing folder (folders only, no files) to another drive. The first three lines of tree's output are a header, so skip them.

tree D:\Projects /A | Select-Object -Skip 3 | Set-Content .\projects-layout.txt; .\New-FolderTree.ps1 -Path E:\Projects -TemplatePath .\projects-layout.txt

A few folders straight from the pipeline.

'Projects\Active', 'Projects\Archive', 'Admin\Receipts' | .\New-FolderTree.ps1 -Path D:\Work

What you'll see

Example outputvalues are illustrative
Folder                          Status  FullName
------                          ------  --------
Books                           Exists  D:\Library\Books
Books\Fiction                   Created D:\Library\Books\Fiction
Books\Fiction\Science Fiction   Created D:\Library\Books\Fiction\Science Fiction
Books\Fiction\Mystery           Created D:\Library\Books\Fiction\Mystery
Books\Non-Fiction               Exists  D:\Library\Books\Non-Fiction
Books\Non-Fiction\History       Created D:\Library\Books\Non-Fiction\History
Comics                          Created D:\Library\Comics
Comics\Publishers               Created D:\Library\Comics\Publishers
Comics\Publishers\Independent   Created D:\Library\Comics\Publishers\Independent
Magazines                       Created D:\Library\Magazines

How it works

A layout file looks like this. Indent with spaces or tabs, use bullets if you like, and put a relative path on one line when you don't need the levels in between:

# Library layout
Books
    Fiction
        Science Fiction
        Mystery
    Non-Fiction
        History
Comics
  - Publishers\Independent
Magazines
  1. Read the plan line by line. Blank lines and # comments are skipped. Tabs become spaces, the box-drawing characters from tree (and +---, \--- and | from tree /A) become spaces too, and a leading - , * or + bullet is stripped.
  2. Work out the parent from the indent. The script keeps a stack of the lines above and pops it until it finds one that's indented less than the current line. That one's the parent. It's the same trick an outliner uses, and it's why the exact number of spaces doesn't matter.
  3. Check every name. Each part is tested against Windows' rules for folder names. A bad one is reported with its line number and skipped, along with everything under it.
  4. Create top-down, once each. Every parent is listed before its children and only once, even when several lines share it. Each one comes back as Exists, Created, WouldCreate under -WhatIf, or Failed with the reason.

Take it further

  • Keep a project template. Save your standard layout (docs, source, exports, archive) as a text file and run it against every new project folder, so they all start out the same.
  • Build the shelves, then move the books. Once the structure looks right, move whole folders into place with Robocopy or sort a flat media folder into letter buckets.
  • Share it. A text outline is easy to review in a pull request or paste into a message. Much easier than a screenshot of Explorer.

Things that'll trip you up

  • Indentation is relative, not fixed. A line that's indented further than the one above goes inside it, whether that's two spaces or eight. A line indented the same as an earlier one becomes its sibling. Mixing tabs and spaces works as long as -TabWidth matches your editor, which is 4 by default.
  • Windows naming rules apply everywhere. Names with < > colon quote pipe ? or *, and names ending in a dot or a space, are rejected with the line number, even on macOS or Linux, so an outline works on any machine. Anything indented under a bad line is skipped too, rather than being created in the wrong place.
  • It only ever adds. Deleting a line from your outline doesn't delete the folder. That's on purpose. Clearing out folders you no longer want is a separate, more careful job.
  • Very deep paths on Windows PowerShell 5.1. Paths longer than 260 characters can fail on 5.1 unless long paths are enabled in Windows. PowerShell 7 handles them fine. A failure only affects that folder; the rest are still created.