在 PowerShell 中利用正则表达式来解析文本块

需求

给定一段文本,如:

1, abcd [xxxx]
vkjl gas kje asld
gew wef
2, bbb [wefs]
oioias wmfjalkjs
3, ccc [wegas]
kzxlj kjlwiewe ii

要求分割成多段以数字开头的文本块,如:

第一块:

1, abcd [xxxx]
vkjl gas kje asld
gew wef

第二块:

2, bbb [wefs]
oioias wmfjalkjs

第三块:

3, ccc [wegas]
kzxlj kjlwiewe ii

思路

  • 定义我们要东西为 n 个“block”。
  • 每个“block”的特征是:
    • 以数字开头
    • block 之前可能是整段文本的起始也有可能是一个回车符。
    • block 之后可能是一个回车符+下一行的数字也有可能是整段文本的结束。
  • block 之前和之后的回车符是不需要的
  • block 应该尽可能“非贪婪”,遇到下一个符合条件的,算作一个新的 block 开始。

其中,“block 之前和之后的回车符是不需要的”可以用正则表达式的“零宽断言”来解决。

代码

$subject = @'
1, abcd [xxxx]
vkjl gas kje asld
gew wef
2, bbb [wefs]
oioias wmfjalkjs
3, ccc [wegas]
kzxlj kjlwiewe ii
'@

$resultlist = new-object System.Collections.Specialized.StringCollection
$regex = [regex]@'
(?snx)(^|(?<=\n))
(?<block>\d, .*?)
((?=\n\d, )|$)
'@
$match = $regex.Match($subject)
while ($match.Success) {
    $resultlist.Add($match.Groups['block'].Value) | out-null
    $match = $match.NextMatch()
}

$resultlist | ForEach-Object {
    echo $_
    echo ---
}

输出结果

1, abcd [xxxx]
vkjl gas kje asld
gew wef
---
2, bbb [wefs]
oioias wmfjalkjs
---
3, ccc [wegas]
kzxlj kjlwiewe ii
---

用 PowerShell 解析 eD2k 链接

电骡的 eD2k 链接包含了丰富的信息。例如这个:

ed2k://|file|BingPinyinSetup_1.5.24.02.exe|31485072|C8C9282E6112455E624EE82941E5BA00|p=79A822E1788353E0B289D2ADD5DA3BDE:FB9BB40DEDB1D2307E9D734A6416704B:0732B122C4ECF70065B181C92BF72400:437958DF590D764DE1694F91AC085225|h=HLXRQSANEO5MHIVOYNM5FNQOHJG3D5MP|s=http://blog.vichamp.com|s=http://www.baidu.com|/|sources,127.0.0.1:1234,192.168.1.1:8888|/

这给我们的第一感觉是可以用正则表达式来解析。我们观察一下它的规律,发现它是用 | 分割的字符串:

ed2k://
file
BingPinyinSetup_1.5.24.02.exe
31485072
C8C9282E6112455E624EE82941E5BA00
p=79A822E1788353E0B289D2ADD5DA3BDE:FB9BB40DEDB1D2307E9D734A6416704B:0732B122C4ECF70065B181C92BF72400:437958DF590D764DE1694F91AC085225
h=HLXRQSANEO5MHIVOYNM5FNQOHJG3D5MP
s=http://www.abc.com/def.zip
s=http://www.vichamp.com/qq.zip
/
sources,127.0.0.1:1234,192.168.1.1:8888
/

还有一些规律:

  • p= 开始,后面的段都是可选的。
  • p=xxxh=xxxs=xxx看起来像键值对。
  • s= 可以有多个,sources 后面的 IP 和端口可以有多对。

根据这个规律,我们可以很容易地构造出正则表达式,并用 PowerShell 解析它。

function Get-Ed2kLink {
    Param(
        [string]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, HelpMessage = 'Enter an ed2k:// url')]
        $Link
    )

    $regex = [regex]@'
(?x)
\bed2k://
\|file\|(?<FILE_NAME>[^|]+)
\|(?<FILE_SIZE>\d+)
\|(?<FILE_HASH>[0-9a-fA-F]+)
(?:\|p=(?:(?<HASH_SET>[0-9a-fA-F]+):?)+)?
(?:\|h=(?<ROOT_HASH>[0-9a-zA-Z]+))?
(?:\|s=(?<HTTP_SOURCE>[^|]+))*
\|\/
\|sources(?:,(?<SOURCES_HOST>[0-9a-zA-Z.]+):(?<SOURCES_PORT>\d+))*
|\/\b
'@
    $match = $regex.Match($Link)
    if ($match.Success) {
        $sourcesHost = $match.Groups['SOURCES_HOST'].Captures | Select-Object -ExpandProperty Value
        $sourcesPort = $match.Groups['SOURCES_PORT'].Captures | Select-Object -ExpandProperty Value
        $sources = @()
        for ($i = 0; $i -lt $sourcesHost.Length; $i++) {
            $sources += [PSCustomObject][Ordered]@{
                Host = $sourcesHost[$i]
                Port = $sourcesPort[$i]
            }
        }

        $result = [PSCustomObject][Ordered]@{
            File = $match.Groups['FILE_NAME'].Value;
            FileSize = $match.Groups['FILE_SIZE'].Value;
            FileHash = $match.Groups['FILE_HASH'].Value;
            HashSet = $match.Groups['HASH_SET'].Captures | Select-Object -ExpandProperty Value
            RootHash = $match.Groups['ROOT_HASH'].Value;
            HttpSource = $match.Groups['HTTP_SOURCE'].Captures | Select-Object -ExpandProperty Value
            Sources = $sources;
        }
    } else {
        $result = $null
    }

    return $result
}

Get-Ed2kLink 'ed2k://|file|BingPinyinSetup_1.5.24.02.exe|31485072|C8C9282E6112455E624EE82941E5BA00|p=79A822E1788353E0B289D2ADD5DA3BDE:FB9BB40DEDB1D2307E9D734A6416704B:0732B122C4ECF70065B181C92BF72400:437958DF590D764DE1694F91AC085225|h=HLXRQSANEO5MHIVOYNM5FNQOHJG3D5MP|s=http://www.abc.com/def.zip|s=http://www.vichamp.com/qq.zip|/|sources,127.0.0.1:1234,192.168.1.1:8888|/'

执行结果如下:

File       : BingPinyinSetup_1.5.24.02.exe
FileSize   : 31485072
FileHash   : C8C9282E6112455E624EE82941E5BA00
HashSet    : {79A822E1788353E0B289D2ADD5DA3BDE, FB9BB40DEDB1D2307E9D734A6416704B, 0732B122C4ECF70065B181C92BF72400, 437958DF590D764DE1694F91AC085225}
RootHash   : HLXRQSANEO5MHIVOYNM5FNQOHJG3D5MP
HttpSource : {http://www.abc.com/def.zip, http://www.vichamp.com/qq.zip}
Sources    : {@{Host=127.0.0.1; Port=1234}, @{Host=192.168.1.1; Port=8888}}

注意一下,由于 s=sources 节包含循环体,所以不能直接用 PowerShell 的 -cmatch 表达式和 $Matches 变量,必须用 .NET 的 [regex] 类来处理。

参考材料:

您也可以在这里下载完整的源代码。

自动生成 PowerShell 技能连载

Tobias Weltner 博士每个工作日都在 www.powershell.com 发布一篇 PowerShell 技能。这个系列在 PowerShell 的技术社区里已是家喻户晓,成为关注 PowerShell 技术动态的一扇窗口。

这个套技能连载在中国有两个版本的翻译。一套是由荔非苔创立的 Powershell小技巧,现由荔非苔和 CodeCook 两人维护另一套是本站的 PowerShell 技能连载

本站是采用 JekyllBootstrap 系统搭建,使用 markdown 编辑文章,并使用 git 发布到 GitHub Pages 上。采用这些略带 geek 感的技术来做这个站点,是因为:

  • 用 markdown 可以使我关注文案的内容,而不是格式。并且它十分适合编写技术文章。
  • git 很酷,可以多人协作,可以像写代码一样写文章。
  • 可以自由地调整源代码,加入新奇的功能,甚至可以用脚本来做站点搬家。
  • GitHub Pages 可以为我们提供免费的服务器。
  • 一切都是开源的,您可以查看到这个站点的一切源代码、文章源文件,甚至所有的维护脚本。
  • 我本身是一个 geek。

我已翻译了PowerShell 技能连载的 200 多篇文章。与此同时,我也采用各种技术方法提升翻译工作的效率。翻译一篇文章所占用的时间已从最初的 30 分钟缩短到现在的 10 分钟。以下部分将演示我是如何做到的。翻译一篇文章,需要经历以下步骤:

  • www.powershell.com 搜集新文章。
  • 创建新的 markdown 文件。文件名对应原文的 url 地址。
  • 编辑文件头的元数据。
  • 将英文的内容转换成 markdown 格式,贴到文件正文部分。
  • 在文本编辑器中,将英文内容逐段翻译成中文,并将原文逐段删除。
  • 处理文章中的图片。
    • 将它下载到本地。
    • 以文章的文件名作为前缀,并加上序号作为文件名。
    • 修正文章中的图片地址。
  • 在浏览器中打开原文,以供对照参考。

我们可以用 PowerShell 逐一解决这些问题:

搜集新文章

获取更新列表

浏览 www.powershell.com 的源代码,发现该网站有提供 PowerShell tips 的 RSS 订阅:

<link rel="alternate" type="application/atom+xml" title="Power Tips (Atom 1.0)" href="http://powershell.com/cs/blogs/tips/atom.aspx"  />

通过 RSS 服务,我们就可以通过编程的方式,自动化地获取文章的内容。

$atomUrl = 'http://powershell.com/cs/blogs/tips/atom.aspx'
$feed = Invoke-RestMethod $atomUrl

获取到的 $feed 变量大概是这样的一个数组:

title     : Using Profile Scripts
link      : link
id        : /cs/blogs/tips/archive/2014/06/13/using-profile-scripts.aspx
published : 2014-06-13T11:00:00Z
updated   : 2014-06-13T11:00:00Z
content   : content
author    : author

title     : Be Aware of Side Effects
link      : link
id        : /cs/blogs/tips/archive/2014/06/12/be-aware-of-side-effects.aspx
published : 2014-06-12T11:00:00Z
updated   : 2014-06-12T11:00:00Z
content   : content
author    : author

其中 linkidcontent 对我们有用。contentXmlElement 类型的对象,我们一会儿会用到。

从更新列表中提取文章信息

由于 id 的变化部分是以 yyyy/mm/dd 开头的,所以我们可以放心地用 Sort-Object 直接进行字符串排序。这个 cmdlet 有个常用的别名,叫做 sort

接下来用正则表达式提取 yearmonthdayname 这几个元素。通过这些元素,我们就可以组织目标文件名:

$feed | sort { $_.id } | foreach {
    $entry = $_

    if ($entry.id -cmatch '^/cs/blogs/tips/archive/(?<year>\d{4})/(?<month>\d{2})/(?<day>\d{2})/(?<name>.+)\.aspx$') {
        $year = $matches['year']
        $month = $matches['month']
        $day = $matches['day']
        $name = $matches['name']
    }

    $targetFile = Join-Path $folder "$year-$month-$day-$name.md"

跳过已有的文件

由于 RSS 中返回的是最后 15 篇文章,也就是近 3 周来的文章列表,如果遇到已翻译过的文章,需要自动跳过:

if (Test-Path $targetFile) {
    echo "[文件已存在] $year-$month-$day-$name.md"
} else {

生成 markdown 文件

模板方法

每篇文章对应一个 markdown 文件。在文件的头部,有一段用 yaml 描述的元数据,也就是用两条 --- 分隔的部分:

function Get-Post ($enty) {
    $postTemplate = @'
layout: post
title: "PowerShell 技能连载 - ___"
description: "PowerTip of the Day - {0}"
categories: [powershell, tip]
tags: [powershell, tip, powertip, series, translation]
{1}

<!--more-->
本文国际来源:[{2}]({3})
'@
    $entryUrl = 'http://powershell.com' + $entry.link.href
    $htmlContent = $entry.content.'#text'

    $htmlDoc = Get-Document $htmlContent
    $htmlContent = $htmlDoc.documentElement.innerHTML
    $htmlDoc.Close()

    $markdown = Get-Markdown $htmlContent
    return $postTemplate -f $entry.title, $markdown, $entry.title, $entryUrl
}

代码中的 $postTemplate 是一个用 here string 描述的文件模板,其中所有的变量都用 {x} 占位符来代替。而 title 部分的 ___ 是为中文名称预留的位置。由于程序无法自动填充中文名,所以这个位置需要在人工翻译阶段手动填充。

函数尾部的 $postTemplate -f $entry.title, $markdown, $entry.title, $entryUrl 是采用字符串的 -f 运算符进行格式化,将各个变量填充到 {x} 占位符处。-f 的本质是调用了 String 类的 Format() 静态方法。

解析 DOM 结构

上述代码调用了一个 Get-Document 函数,将 $htmlContent 字符串转换为一个 DOM 对象。它的实现方法如下:

function Get-Document($text) {
    $htmlDoc= New-Object -com "HTMLFILE"
    if ($htmlDoc.IHTMLDocument2_write) {
        $htmlDoc.IHTMLDocument2_write($text)
    } else {
        $htmlDoc.write($text)
    }

    return $htmlDoc
}

这里采用了 HTMLFILE COM 对象的 IHTMLDocument2_write()write() 方法,来返回一个 HtmlDocument 对象。因为我发现不同的机器上,存在不同的版本。很遗憾关于这块的 MSDN 文档不太好找,这是我自创的方式,有效果,不知有没有更好的方法实现。

拿到 HtmlDocument 以后,就可以进行 DOM 操作了。这里我们简单地获取 Document 对象的 innerHTML 属性,并且注意及时关闭 COM 对象以释放资源:

$htmlContent = $htmlDoc.documentElement.innerHTML
$htmlDoc.Close()

将 HTML 转换为 markdown

编写 Node.js 程序

在 PowerShell 和 .NET 的世界里,目前没有很理想的 HTML 转 markdown 库可用。不过 Node.js 中有一个不错的库 html2markdown

由于 node.exe 传入太长的参数可能会有意想不到的问题,所以直接向 Node.js 程序传递 HTML 字符串不可靠。更可靠的方式是将 HTML 字符串保存到临时文件中,将临时文件的文件名传递给 Node.js 程序。

我们可以写一个 Node.js 的小程序,目的是让 PowerShell 以这种方式调用:

node.exe index.js htmlFilePath markdownFilePath

其中 htmlFilePath 为输入的 HTML 文件路径,markdownFilePath 为输出的 markdown 文件路径。

接下来用 npm 快速创建一个 Node.js 工程(暂时也叫 html2markdown,虽然和库的名字相同,不过影响使用):

npm init html2markdown

对向导的提示一路回车即可。然后添加 html2markdown 库的引用:

npm install html2markdown --save

生成好的 package.json 文件如下:

{
  "name": "html2markdown",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "html2markdown": "~1.1.0"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

接下来编写 index.js 脚本。这个脚本的原理是:

  • 解析 node.exe 进程的参数,提取输入的 HTML 文件路径和输出的 markdown 文件路径
  • 读入 HTML 文件内容
  • 调用 html2markdown 将 HTML 转换为 markdown
  • 写入 markdown 文件内容

index.js 脚本的内容如下:

var html2markdown = require('html2markdown'),
    fs = require('fs'),
    args = process.argv.splice(2),
    htmlFile = args[0],
    markdownFile = args[1],
    html,
    markdown;

html = fs.readFileSync(htmlFile, { encoding: 'utf8' });
markdown = html2markdown(html, { inlineStyle: true });
fs.writeFileSync(markdownFile, markdown);

通过 PowerShell 调用 Node.js

以下代码是生成 $htmlFile$markdownFile 两个临时文件,然后通过 node .\html2markdown\index.js $htmlFile $markdownFile 调用 Node.js 程序。当 Node.js 程序执行完毕以后,通过 gc 命令从输出的临时文件中获取 markdown 内容字符串,然后删除所有临时文件。

代码的第一行用正则表达把 HTML 中的 Twitter 回访链接给清理掉:

function Get-Markdown ($html) {
    $html = $html -creplace '(?sm)^<P><A href="http://twitter\.com/home/\?status=.*$', ''
    $htmlFile = [System.IO.Path]::GetTempFileName()
    $markdownFile = [System.IO.Path]::GetTempFileName()
    sc $htmlFile $html
    node .\html2markdown\index.js $htmlFile $markdownFile
    $markdown = gc -Raw $markdownFile
    del $htmlFile
    del $markdownFile
    $markdown = $markdown.Trim()
    return $markdown
}

到此为止,markdown 形式的文章内容已经生成好了。

下载文章中的图片

到目前为止,文章中的图片都可以正常显示,不过它们都是位于 www.powershell.com 服务器上。从翻译的角度来说,图片最好保存一份到自己的服务器上,图片跟着文章走。这样就不会因为源网站服务器宕机或其它原因导致译文中的图片出问题。

markdown 中的图片是这样表示的:

![description](http://www.xxx.com/yyy.png)

我们假设文章的文件名是 aaa.md,那么需要做的事情是把 http://www.xxx.com/yyy.png 下载下来,保存在网站的 ..\assets\post_img 目录下,并且按 aaa-001.pngaaa-002.png 这样的方式重命名。这样就能很清楚地体现哪个图片是属于哪篇文章。

分析文章中的图片

代码中采用这个正则表达式来提取图片的描述和 URL:

[regex] '!\[(?<desc>.*?)\]\((?<url>.*?)\)'

这句代码是用来生成新的图片文件名:

$targetPath = "$fileBaseName-{0:d3}$extension" -f $index++

我们可以用一个泛型的哈希表(字典)来保存源路径和替换后的目标路径:

[System.Collections.Generic.Dictionary[[string],[string]]] $dict = New-Object 'System.Collections.Generic.Dictionary[[string], [string]]'

它等效于 C# 中的这行代码:

var dict = new Dictionary<string, string>();

这里的 PowerShell 代码就显得不如 C# 直观了。

以下是实现代码:

function Get-Picture($file) {
    $index = 1
    $fileBaseName = ([System.IO.FileInfo]$file).BaseName
    [System.Collections.Generic.Dictionary[[string],[string]]] $dict = New-Object 'System.Collections.Generic.Dictionary[[string], [string]]'

    cat $file -Encoding UTF8 -Raw | % {
        $regex = [regex] '!\[(?<desc>.*?)\]\((?<url>.*?)\)'
        $matches = $regex.Matches($_)

        if ($matches.Count) {
            $matches.ForEach({
                $fullMatch = $_.Value
                $desc = $_.Groups['desc'].Value
                $url = $_.Groups['url'].Value
                if ($url -like 'http*') {
                    $extension = [System.IO.Path]::GetExtension($url)
                    $targetPath = "$fileBaseName-{0:d3}$extension" -f $index++
                    $result = Download-Picture $url $targetPath
                    if ($result) {
                        $dict.Add($url, $targetPath)
                    }
                }
            })
        }
    }

    $newContent = cat $file -Encoding UTF8 -Raw | % {
        $line = $_
        $dict.Keys | % {
            $url = $_
            $newPath = $relateUrl + $dict[$url]
            $line = [string]$line.Replace($url, $newPath)
        }
        $line
    }

    $bytes = [System.Text.Encoding]::UTF8.GetBytes($newContent)
    sc $file $bytes -Encoding Byte
    #[IO.File]::WriteAllText($file, $newContent, [System.Text.Encoding]::UTF8)
}

这里为什么用字典来保存源地址和目标地址呢?主要是因为我们不仅需要把每个匹配成功的结果替换成新的文本,还需要针对每个结果执行一段自定义的代码。利用字典先把匹配的结果保存起来,然后遍历字典项,对它们进行进一步的处理。

实际上有个更简单的办法。C# 中 Regex 对象的 Replace() 方法支持 lambda 表达式,对应的 PowerShell 方法支持用代码块的方式。MSDN 链接为 Regex.Replace 方法 (String, MatchEvaluator)。可以一边匹配一边做处理。

只是由于我先写出了文中的版本,所以没有去改进。

下载文章中的图片

Invoke-WebRequest 可以方便地下载 HTTP 文件,这和 Linux 系统中的 wget 命令十分相似。实际上,Invoke-WebRequest 命令有个别名,就叫做 wget。下载图片的代码如下:

function Download-Picture($url, $fileName) {
    echo "downloading $url to $fileName"
    $fullPath = Join-Path $downloadPath $fileName
    Invoke-WebRequest -Uri $url -OutFile $fullPath
    return $?
}

综述

到此为止,我已完整地介绍了《PowerShell 技能连载》翻译工作中采用脚本进行自动化的全部原理。相应的代码可以在 这里 找到。

读者也许会觉得为这些小小的功能用手工操作也可以完成。不过,简单计算一下就能够体现出产生的红利。目前已翻译了 200 多篇文章,按照每篇文章节约 3 分钟计算,自动化操作总共为我节约了 600 分钟,也就是节约了 10 个小时的机械劳动。用这宝贵的一个多工作日来开发脚本、撰写心得,做些动脑筋的事情,不亦是一种提升么?

用 PowerShell 处理纯文本 - 1

原始文本:”data1”:111,”data2”:22,”data3”:3,”data4”:4444444,”data5”:589
要求:转换成对象

方法一,采用字符串运算及 ConvertFrom-StringData 命令:

$rawTxt='"data1":111,"data2":22,"data3":3,"data4":4444444'
$rawTxt -split ',' | ForEach-Object {
   $temp= $_ -split ':'
   "{0}={1}" -f $temp[0].Substring(1,$temp[0].Length-2),$temp[1]
} | ConvertFrom-StringData

方法二,采用正则表达式,使用.NET的方法:

$rawTxt = '"data1":111,"data2":22,"data3":3,"data4":4444444,"data5":589'
$regex = [regex] '"(?<name>\w*)":(?<value>\d*),?'
$match = $regex.Match($rawTxt)
while ($match.Success) {
    [PSCustomObject]@{
        Name = $match.Groups['name'].Value
        Value = $match.Groups['value'].Value
    }
    $match = $match.NextMatch()
}

方法三,采用正则表达式,使用 Select-String Cmdlet:

Select-String -InputObject $rawTxt -Pattern $regex -AllMatches | % {
    $_.Matches
} | % {
   [PSCustomObject]@{
        Name = $_.Groups['name'].Value
        Value = $_.Groups['value'].Value
    }
}

三者的执行结果都是这样:

Name          Value
----          -----
data1         111
data2         22
data3         3
data4         4444444
data5         589

原命题参见:[PowerShell 文本处理实例(三)] 1

以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
}