修正文件名/目录名的PowerShell脚本

计划写一系列整理文件用的脚本。例如根据id3来对mp3文件归档、根据exif信息来对照片归档、根据verycd上的资源名称对下载的文件归档……
这时候会遇到一个问题:Windows的文件系统是不允许某些特殊字符,以及设备文件名的。详细的限制请参见:http://zh.wikipedia.org/wiki/%E6%AA%94%E6%A1%88%E5%90%8D%E7%A8%B1。

这个PowerShell脚本帮助你避开这些坑。具体的做法是将特殊字符替换成’.’,对于恰好是设备名称的主文件名或扩展名之前添加’_’。

function Get-ValidFileSystemName
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$FileSystemName
    )

    process{
        $deviceFiles = 'CON', 'PRN', 'AUX', 'CLOCK$', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
        $fileName = [System.IO.Path]::GetFileNameWithoutExtension($FileSystemName)
        $extension = [System.IO.Path]::GetExtension($FileSystemName)
        if ($extension.StartsWith('.'))
        {
            $extension = $extension.Substring(1)
        }

        if ($deviceFiles -contains $fileName)
        {
            $fileName = "_$fileName"
        }

        if ($deviceFiles -contains $extension)
        {
            $extension = "_$extension"
        }

        if ($extension -eq '')
        {
            $FileSystemName = "$fileName$extension"
        }
        else
        {
            $FileSystemName = "$fileName.$extension"
        }

        $FileSystemName = $FileSystemName -creplace '[\\/|?"*:<>\x00\x1F\t\r\n]', '.'
        return $FileSystemName
    }
}

用脚本批量下载www.cheat-sheets.org中的所有pdf文件

流水不腐,户枢不蠹。虽然批量下载有很多工具能做到,但是为了提高,我们尽量动手编写脚本吧。
http://www.cheat-sheets.org 里有很多好东西,我们把它批量下载下来。

下载的PDF截图

PowerShell代码:

Add-Type -AssemblyName System.Web
$baseUrl = 'http://www.cheat-sheets.org'
$result = Invoke-WebRequest $baseUrl
$result.Links |
    ? {$_.href -Like '*.pdf'} |
    select -ExpandProperty href |
    sort |
    % {
        if ($_ -like '/*')
        {
            $baseUrl + $_
        } else {
            $_
        }
      } |
     % {
        echo "Downloading $_"
        $fileName = $_.Substring($_.LastIndexOf("/") + 1)
        $localFileName = [System.Web.HttpUtility]::UrlDecode($fileName)

        if (Test-Path $localFileName) {
            return
        }
        Invoke-WebRequest -Uri $_ -OutFile $localFileName
        if (Test-Path $localFileName) {
            Unblock-File $localFileName
        }
     }

新手从 0 开始学习 PowerShell 的路线

以下是我个人的PowerShell学习路线,也许可以节省您一些走弯路的时间,供参考。

入门材料

  • 微软虚拟学院 快速入门: PowerShell 3.0 - 有讲座视频、中文字幕、pdf 讲义,轻松起步。我把它们都传到百度云上了,下载地址请参见 PowerShell 微软虚拟学院课程
  • Windows Powershell入门指南(中文)-微软资料.pdf - 篇幅很短,仅 29 页,入门找感觉,一天即可读完。
  • pstips.chm - 中文电子书,篇幅适中,涵盖了语法要点,重点推荐!

速查表

推荐打印出来放案头备用。

  • Windows PowerShell 语言快速参考.rtf - 只有三页纸的速查表(中文)
  • powershell_reference_Apr2010.doc - 只有两页纸的速查表(英文)

中文手册,用于查阅

  • WindowsPowerShellHelp_官方手册.chm - 微软官方,浏览一下有哪些内容,日后备用。

官方网站

Microsoft PowerShell

英文电子书

  • [Windows.PowerShell.Pocket.Reference(2nd,2012.12)].Lee.Holmes.文字版.pdf - 系统学习用,可以一周读完,后几章可跳过,100 来页。
  • [Windows.Powershell.实战(第2版)].(Windows.Powershell.in.Action).Bruce.Payette.文字版.pdf - 经典,深入学习用,900 来页,可以用来当工具书查阅。

邮件列表

  • Power Tips - 右侧有个 Tips via Email,在这儿通过电子邮件订阅,每天会发给你一封短小的邮件。一点一滴学习,内容都很精致。

电子书合集

所有 PowerShell 电子书的合集,自动更新:《PowerShell 电子书合集

个人博客

补充说明

批量更改csdn下载的文件名(UrlDecode)

例如csdn下载的一个文件名字为 %5B大家网%5DWindows.PowerShell应用手册%5Bwww.TopSage.com%5D.pdf,我们通过两行PowerShell脚本把它转化为正常的 [大家网]Windows.PowerShell应用手册[www.TopSage.com].pdf。量大的时候特别好用。

方法如下:

Add-Type -AssemblyName System.Web
dir | % { ren -LiteralPath $_ ([System.Web.HttpUtility]::UrlDecode($_)) }

以GTD的思想整理目录的PowerShell脚本

这是花了一个晚上写的PowerShell脚本,可以把你的目录以GTD的思想整理得井井有条。
但是GTD功能已经完整并通过测试。github地址:victorwoo/Get-ThingsDone

不了解GTD的同学请参考以下材料:
[《搞定》中文版] 1、[《Get Things Done》英文版] 2

#========================================================================
# Created on:   2013-3-7 16:21
# Created by:   Victor.Woo
# Organization: www.vichamp.com
# Filename:  Get-ThingsDone.ps1
#========================================================================

function Check-Enviroment
{
  $gtdPath = "HKCU:\Software\Vichamp\GTD"
  if ((Get-ItemProperty $gtdPath -ErrorAction SilentlyContinue).AutoStart -eq "False")
  {
    return
  }

  $runPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
  $run = Get-ItemProperty $runPath
  if ($run.GTD -eq $null)
  {
    $title = '自动执行请求'
    $message = '在当前 Windows 用户登录时自动运行此脚本,可以自动帮助您整理、规划当日的工作内容。如果您选择了“是”,但将来不希望自动启动,请执行 uninstall.cmd。是否在当前用户登录时自动执行脚本?'
    $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes","Windows 用户登录时自动运行此脚本。"
    $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No","Windows 用户登录时不运行此脚本,并且不再提示。"
    $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes,$no)
    $result = $Host.UI.PromptForChoice($title,$message,$options,0)
    switch ($result)
    {
      0 {
        Set-ItemProperty -Path $runPath -Name GTD -Value $gtdCmd
      }
      1 {
        md $gtdPath -Force
        Set-ItemProperty -Path $gtdPath -Name AutoStart -Value "False"
      }
    }
  }
}

function TryCreate-Directory ([Parameter(Mandatory = $True)] [string]$dirName)
{
  $private:dir = Join-Path $baseDir $dirName
  if (-not (Test-Path $dir))
  {
    Write-Output "$dir 不存在,正在创建。"
    mkdir $dir | Out-Null
  }
}

function TryCreate-Directories ()
{
  Write-Output "正在检查目录完整性"
  $dirNames |
  % {
    TryCreate-Directory $_
  }
}

function Remove-Directories ()
{
  $dirNames |
  % {
    $private:dir = Join-Path $baseDir $_
    if (Test-Path $dir)
    {
      Write-Warning "正在移除$dir"
      rm $dir -Recurse
    }
  }
}

function MoveTo-WithRenamming (
  [Parameter(Mandatory = $True)] [System.IO.FileSystemInfo]$item,
  [Parameter(Mandatory = $True)] [string]$targetDir)
{
  function Get-NextFilePath ([string]$dir,[System.IO.FileInfo]$fileInfo)
  {
    $Private:targetFilePath = Join-Path $dir $fileInfo.Name
    if (Test-Path $Private:targetFilePath)
    {
      $Private:index = 1
      do {
        $Private:targetFilePath = Join-Path $dir "$($fileInfo.BaseName) ($index)$($fileInfo.Extension)"
        $Private:index++
      }
      while (Test-Path $Private:targetFilePath)
    }
    return [System.IO.FileInfo]$Private:targetFilePath
  }

  function Get-NextDirectoryPath ([string]$dir,[System.IO.DirectoryInfo]$directoryInfo)
  {
    $Private:targetDirectoryPath = Join-Path $dir $directoryInfo.Name
    if (Test-Path $Private:targetDirectoryPath)
    {
      $Private:index = 1
      do {
        $Private:targetDirectoryPath = Join-Path $dir "$($directoryInfo.Name) ($index)"
        $Private:index++
      }
      while (Test-Path $Private:targetDirectoryPath)
    }
    return [System.IO.DirectoryInfo]$Private:targetDirectoryPath
  }

  Write-Output "正在移动 $item 至 $targetDir 目录"
  if ($item -is [System.IO.FileInfo])
  {
    # 待移动的是文件
    [System.IO.FileInfo]$item = [System.IO.FileInfo]$item
    $Private:targetFilePath = Join-Path $targetDir $item.Name
    if (Test-Path $Private:targetFilePath)
    {
      # 目标文件已存在
      $targetFileInfo = [System.IO.FileInfo]$Private:targetFilePath
      $Private:targetFilePath = Get-NextFilePath $targetDir $item

      if ($item.LastWriteTime -eq $targetFileInfo.LastWriteTime -and $item.Length -eq $targetFileInfo.Length)
      {
        # 文件时间和大小相同
        Write-Warning "源文件 $item.FullName 与目标文件 $targetFileInfo.FullName 相同,删除源文件"
        Remove-Item $item.FullName
      }
      else
      {
        Write-Warning "目标文件已存在,自动改名为$($Private:targetFilePath.Name)"
        Move-Item $item.FullName $Private:targetFilePath | Out-Null
      }
    }
    else
    {
      # 目标文件不存在
      if (!(Test-Path $targetDir))
      {
        # 目标目录不存在,创建目标目录
        md $targetDir | Out-Null
      }
      Move-Item $item.FullName $Private:targetFilePath | Out-Null
    }
  } elseif ($item -is [System.IO.DirectoryInfo])
  {
    # 待移动的是目录
    [System.IO.DirectoryInfo]$item = [System.IO.DirectoryInfo]$item
    $Private:targetDirectoryPath = Join-Path $targetDir $item.Name
    if (Test-Path $Private:targetDirectoryPath)
    {
      $Private:targetDirectoryPath = Get-NextDirectoryPath $targetDir $item
      Write-Warning "目标文件夹已存在,自动改名为$($Private:targetDirectoryPath.Name)"
    }
    Move-Item $item.FullName $Private:targetDirectoryPath | Out-Null
  }
}

function Process-IsolatedItems
{
  Write-Output "正在将游离内容移至 [STUFF] 目录"
  Get-ChildItem $baseDir -Exclude ($dirNames + $reservedDirs + $reservedFiles) |
  % {
    MoveTo-WithRenamming $_ $stuffDir
  }
}

function Process-TomorrowDir
{
  Write-Output "正在处理 [TOMORROW] 目录"
  Get-ChildItem $tomorrowDir |
  % {
    MoveTo-WithRenamming $_ $todayDir
  }
}

function Process-CalendarDir
{
  Write-Output "正在处理 [CALENDAR] 目录"
  Get-ChildItem $calendarDir -File |
  % {
    MoveTo-WithRenamming $_ $stuffDir
  }

  Get-ChildItem $calendarDir -Directory |
  % {
    $regex = [regex]'(?m)^(?<year>19|20\d{2})[-_.](?<month>\d{1,2})[-_.](?<day>\d{1,2})$'
    $match = $regex.Match($_.Name)
    if ($match.Success)
    {
      $Private:year = $regex.Match($_.Name).Groups['year'].Value;
      $Private:month = $regex.Match($_.Name).Groups['month'].Value;
      $Private:day = $regex.Match($_.Name).Groups['day'].Value;
      $Private:date = New-Object System.DateTime $Private:year,$Private:month,$Private:day
      $now = (Get-Date)
      $today = $now.Subtract($now.TimeOfDay)
      if ($date -lt $today)
      {
        Write-Output "移动过期任务 $($_.Name) 到 [STUFF] 目录"
        MoveTo-WithRenamming $_ $stuffDir
      }
      elseif ($date -eq $today)
      {
        Write-Output "移动今日任务 $($_.Name) 到 [TODAY] 目录"
        MoveTo-WithRenamming $_ $todayDir
      }
      elseif ($date -eq $today.AddDays(1))
      {
        Write-Output "移动明日任务 $($_.Name) 到 [TOMORROW] 目录"
        MoveTo-WithRenamming $_ $tomorrowDir
      }
    }
    else
    {
      Write-Output "[CALENDAR] 目录下,$($_.Name) 名字不符合规范,将移动至 [STUFF] 目录"
      MoveTo-WithRenamming $_ $stuffDir
    }
  }
}

function Process-ArchiveDir
{
  Write-Output "正在检查 [ARCHIVE] 目录"

  # 创建本月目录
  $nowString = "{0:yyyy.MM}" -f (Get-Date)
  $thisMonthDir = Join-Path $archiveDir $nowString
  if (-not (Test-Path $thisMonthDir))
  {
    Write-Output "正在创建本月目录"
    md $thisMonthDir
  }

  # 移除除本月之外的空目录
  Get-ChildItem $archiveDir -Exclude $nowString -Recurse |
  Where { $_.PSIsContainer -and @( Get-ChildItem -LiteralPath $_.FullName -Recurse | Where { !$_.PSIsContainer }).Length -eq 0 } |
  % {
    Write-Output "正在删除空目录$($_.FullName)"
    Remove-Item -Recurse
  }

  # 移动所有文件到 本月存档 目录
  Get-ChildItem $archiveDir -File |
  % {
    $lastWriteTime = "{0:yyyy.MM}" -f $_.LastWriteTime
    $lastWriteDir = Join-Path $archiveDir $lastWriteTime
    Write-Output "移动 [ARCHIVE] 目录下,$($_.Name) 游离文件至 $lastWriteDir 存档目录"
    MoveTo-WithRenamming $_ $lastWriteDir
  }

  # 检查目录命名是否符合规范。
  Get-ChildItem $archiveDir -Directory |
  % {
    $regex = [regex]'(?m)^(?<year>19|20\d{2})[-_.](?<month>\d{1,2})$'
    $match = $regex.Match($_.Name)
    if ($match.Success)
    {
      # Archive目录下的名字符合格式
      $year = $regex.Match($_.Name).Groups['year'].Value;
      $month = $regex.Match($_.Name).Groups['month'].Value;
      $date = New-Object System.DateTime $year,$month,1
      if ($date -gt (Get-Date))
      {
        Write-Output "[ARCHIVE] 目录下,$($_.Name) 名字不符合规范(存档日期超出当前时间),将移动至 [STUFF] 目录"
        MoveTo-WithRenamming $_ $stuffDir
      }
      else
      {
        $formattedDate = "{0:yyyy.MM}" -f $date
        if ($_.Name -ne $formattedDate)
        {
          $targetDirectory = [System.IO.DirectoryInfo](Join-Path $_.Parent.FullName $formattedDate)
          Write-Warning "将 [ARCHIVE] 下的目录名 $($_.Name) 处理为规范格式 $($targetDirectory.Name)"
          Move-Item $_.FullName $targetDirectory.FullName
        }
      }
    } else
    {
      # Archive目录下的名字符不合格式
      $lastWriteTime = $nowString = "{0:yyyy.MM}" -f $_.LastWriteTime
      $lastWriteDir = Join-Path $archiveDir $lastWriteTime
      Write-Output "移动 [ARCHIVE] 目录下,$($_.Name) 游离文件夹至 $lastWriteDir 存档目录"
      MoveTo-WithRenamming $_ $lastWriteDir
    }
  }
}

function Explore-Dirs
{
  if ((Get-ChildItem $stuffDir) -ne $null)
  {
    explorer $stuffDir
  }

  if ((Get-ChildItem $todayDir) -ne $null)
  {
    explorer $todayDir
  }
}

$STUFF = "1.STUFF"
$TODAY = "2.TODAY"
$TOMORROW = "3.TOMORROW"
$UPCOMING = "4.UPCOMING"
$CALENDAR = "5.CALENDAR"
$SOMEDAY = "6.SOMEDAY"
$ARCHIVE = "7.ARCHIVE"

$dirNames = $STUFF,$TODAY,$TOMORROW,$UPCOMING,$CALENDAR,$SOMEDAY,$ARCHIVE
$reservedDirs = ".git","_gsdata_"
$reservedFiles = ".gitignore","Get-ThingsDone.ps1","README*.md","gtd_logo.png","LICENSE.md","GTD.cmd","uninstall.cmd"

$baseDir = Split-Path $MyInvocation.MyCommand.Path
$stuffDir = Join-Path $baseDir $STUFF
$todayDir = Join-Path $baseDir $TODAY
$tomorrowDir = Join-Path $baseDir $TOMORROW
$calendarDir = Join-Path $baseDir $CALENDAR
$archiveDir = Join-Path $baseDir $ARCHIVE
$gtdCmd = Join-Path $baseDir "GTD.cmd"

Get-Date | Write-Output

Check-Enviroment
TryCreate-Directories

Process-IsolatedItems
Process-TomorrowDir
Process-CalendarDir
Process-ArchiveDir

Explore-Dirs

######################### 开发临时用(在 ISE 中选中并按 F8 执行) #########################
return

{
  return
  # 创建游离内容。
  $null | Set-Content (Join-Path $baseDir "to.del.file.txt")
  md (Join-Path $baseDir "to.del.dir") | Out-Null
}

{
  return
  # 对代码排版。
  Import-Module D:\Dropbox\script\DTW.PS.PrettyPrinterV1\DTW.PS.PrettyPrinterV1.psd1
  Edit-DTWCleanScript D:\Dropbox\vichamp\GTD\Get-ThingsDone.ps1
}

{
  return
  # 移除所有目录
  Remove-Directories
}