更新頻度の高いファイルの自動世代管理

#目的
よく更新するファイルを、更新日時を名前に付して自動的にコピー(バックアップ)し、世代管理します。

#環境
OS        : Windows 7 以降
PowerShell: Version 3 以降

#使用方法
タスクスケジューラやスタートアップに登録して常時実行したままの状態にします。

#コード
PowerShellスクリプトですが、Windows バッチファイルとして実行できる特殊な構造になっています。PowerShellスクリプトとして実行するには、1行目をコメントアウトまたは削除して拡張子を.ps1としてファイルに保存してください。

- 特定の１ファイルのみを対象とするスクリプト

```PowerShell:backup_file_on_update.bat
@PowerShell -NoP -C "&([ScriptBlock]::Create((Get-Content '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit
# by earthdiver1
###################################################################################################################################
$src_file = "C:\:somedir\important_file.doc"
$dst_dir  = "D:\backup"
$num_copy = 20
$interval_1  = 180
$interval_2  =  15
###################################################################################################################################
$ErrorActionPreference = "Stop"
while ( -not (Test-Path $dst_dir) ) { Write-Output "$(Get-Date) Waiting..."; Start-Sleep 15 }
$old_mtime = ""
$old_hash  = ""
while ( $true ) {
    try { 
        $file = Get-Item -Path $src_file
        $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
        if ( $new_mtime -ne $old_mtime ) {
            $new_hash = $file.GetHashCode()
            if ( $new_hash -ne $old_hash ) {
                $dst_file = Join-Path $dst_dir ($file.BaseName + "_" + $new_mtime + $file.Extension)
                if ( -not ( Test-Path $dst_file ) ) {
                    Copy-Item -LiteralPath $file.FullName -Destination $dst_file -Force
                    $filter =  $file.BaseName + "_????????????" + $file.Extension
                    Get-ChildItem -LiteralPath $dst_dir -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                }
                $old_hash  = $new_hash
            }
            $old_mtime = $new_mtime
        }
        $interval = $interval_1
    } catch [System.Exception] { 
        Write-Output $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
        $interval = $interval_2
    }
    Start-Sleep $interval
}
```

- 指定したフォルダに含まれる全ファイルを対象とするスクリプト

```PowerShell:backup_files_on_update.bat
@PowerShell -NoP -C "&([ScriptBlock]::Create((Get-Content '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit
# by earthdiver1
###################################################################################################################################
$src_root = "C:\somedir_containing_important_files"
$dst_root = "D:\backup"
$num_copy =  20
$interval_1  = 180
$interval_2  =  15
###################################################################################################################################
$ErrorActionPreference = "Stop"
while (-not (Test-Path $dst_root)) { Write-Output "$(Get-Date) Waiting..."; Start-Sleep 15 }
$MD5       = New-Object System.Security.Cryptography.MD5CryptoServiceProvider
$src_root  = Convert-Path -LiteralPath $src_root
$dst_root  = Convert-Path -LiteralPath $dst_root
$old_mtime = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$old_hash  = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$old_name  = New-Object 'System.Collections.Generic.Dictionary[String,String]'
# Restore $old_hash of the last session from the backup.hash file.
if ( Test-Path -LiteralPath "$dst_root\backup.hash" ) {
    try{
        $sr = [IO.StreamReader]::new( "$dst_root\backup.hash", [Text.Encoding]::Default )
        while ( -not $sr.EndOfStream ) {
            $key,$value,$src_file = $sr.ReadLine() -split ","
            $old_hash.$key = $value
            $old_name.$key = $src_file
        }
    } catch {
        throw
    } finally {
        $sr.Close()
    }
    # Regenerate backup.hash for filtering out the updated records.
    try {
        $sw = [IO.StreamWriter]::new( "$dst_root\backup.hash_", $false, [Text.Encoding]::Default )
        $old_hash.GetEnumerator() | & { process{ $sw.WriteLine( "$($_.Key),$($_.Value),$($old_name.($_.Key))" ) } }
    } catch {
        throw
    } finally {
        $sw.Close()
    }
    if ( Test-Path "$dst_root\backup.hash_" ) {
        Move-Item -LiteralPath "$dst_root\backup.hash_" -Destination "$dst_root\backup.hash" -Force
    }
}
while ( $true ) {
    try {
        $sw = [IO.StreamWriter]::new( "$dst_root\backup.hash", $true, [Text.Encoding]::Default )
        Get-ChildItem -LiteralPath $src_root -File -Recurse | ForEach-Object {
            $file = $_
            $src_file = $file.FullName
            if ( $src_file -Like "$dst_root\*" ) { return }

#           if ( $file.Length -gt 100MB ) { return }

            $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
            $idx = [Bitconverter]::ToString($MD5.ComputeHash([Text.Encoding]::Default.GetBytes( $src_file ))).Replace("-","")
            # First, pick up candidates by file modification time.
            if ( $new_mtime -ne $old_mtime.$idx ) {
                $stream = try {
                    [IO.FileStream]::new( $src_file, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read )
                } catch {
                    [IO.FileStream]::new( $src_file, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite )
                }
                if ( $stream ) {
                    try {
                        $new_hash = ( Get-FileHash -InputStream $stream -Algorithm MD5 ).Hash
                    } catch {
                        throw
                    } finally {
                        $stream.Close()
                    }
                } else { throw }
                # Then, use the hash value to determine the target.
                if ( $new_hash -ne $old_hash.$idx ) {
                    $dst_dir = $dst_root + $file.DirectoryName.Substring($src_root.Length, $file.DirectoryName.Length - $src_root.Length)
                    if ( -not ( Test-Path -LiteralPath $dst_dir ) ) {
                        New-Item -ItemType Directory -Force -Path $dst_dir | Out-Null
                    }
                    $dst_file = Join-Path $dst_dir ($file.BaseName + "_" + $new_mtime + $file.Extension)
                    if ( -not ( Test-Path $dst_file ) ) {
                        Copy-Item -LiteralPath $src_file -Destination $dst_file -Force
                        $filter =  $file.BaseName + "_????????????" + $file.Extension
                        Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                    }
                    $old_hash.$idx = $new_hash
                    $sw.WriteLine( "$idx,$new_hash,$src_file" )
                }
                $old_mtime.$idx = $new_mtime
            }
        }
        $sw.Close()
        $interval = $interval_1
    } catch [System.Exception] {
        Write-Output $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
        $interval = $interval_2
    } finally {
        if ( $sw -and $sw.BaseStream ) { $sw.Close() }
    }
    Start-Sleep $interval
}
```

#備考
- 3分毎に更新をチェックします（異常発生時には15秒後）。
- ファイル毎のバックアップの最大数（保存する世代の最大数）は20です。
- `$dst_root`に、`$src_root`と同一のフォルダは指定できません。
- 2番目の例で53行目(`$new_mtime =`の行の前)の後に `if ($file.Extension -eq ".tmp") {return}` などを挿入することで監視対象から一部のファイルを除外することが可能です。
- ファイルの同一性をチェックするためのハッシュ値のアルゴリズムに MD5 を使用しています（SHA1やSHA256などに容易に変更可能）。
- バックアップ済みの`$src_root`上のファイルのハッシュ値を `$dst_root\backup.hash` ファイル に保管します（スクリプトの再実行時に読み込んで前回のセッションの状態を復元するため）。`$dst_root`上にコピーされたファイルを移動／削除しても、再度のバックアップは実行されません。リセットするには `backup.hash` ファイルを削除してください。
- タスクトレイ常駐版[^1]は<details><summary>こちらをクリック</summary><div>

```powershell:backup_files_on_update.bat
@PowerShell -NoP -W Hidden -C "$PSCP='%~f0';$PSSR='%~dp0'.TrimEnd('\');&([ScriptBlock]::Create((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit/b
# by earthdiver1
if ($PSCommandPath) {
    $PSCP = $PSCommandPath
    $PSSR = $PSScriptRoot
    $code = '[DllImport("user32.dll")]public static extern bool ShowWindowAsync(IntPtr hWnd,int nCmdShow);'
    $type = Add-Type -MemberDefinition $code -Name Win32ShowWindowAsync -PassThru
    [void]$type::ShowWindowAsync((Get-Process -PID $PID).MainWindowHandle,0) }
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
$menuItem = New-Object System.Windows.Forms.MenuItem "Exit"
$menuItem.add_Click({$notifyIcon.Visible=$False;while(-not $status.IsCompleted){Start-Sleep 1};$appContext.ExitThread()})
$contextMenu = New-Object System.Windows.Forms.ContextMenu
$contextMenu.MenuItems.AddRange($menuItem)
$notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$notifyIcon.ContextMenu = $contextMenu
$notifyIcon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($PSCP)
$notifyIcon.Text = (Get-ChildItem $PSCP).BaseName
$notifyIcon.Visible = $True
$_syncHash = [hashtable]::Synchronized(@{})
$_syncHash.NI   = $notifyIcon
$_syncHash.PSCP = $PSCP
$_syncHash.PSSR = $PSSR
$runspace = [RunspaceFactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions  = "ReuseThread"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("_syncHash",$_syncHash)
$scriptBlock = Get-Content $PSCP | ?{ $on -or $_[1] -eq "!" }| %{ $on=1; $_ } | Out-String
$action=[ScriptBlock]::Create(@'
#   param($Param1, $Param2)
    Start-Transcript -LiteralPath ($_syncHash.PSCP -Replace '\..*?$',".log") -Append
    Function Start-Sleep { [CmdletBinding(DefaultParameterSetName="S")]
        param([parameter(Position=0,ParameterSetName="M")][Int]$Milliseconds,
              [parameter(Position=0,ParameterSetName="S")][Int]$Seconds,[Switch]$NoExit)
        if ($PsCmdlet.ParameterSetName -eq "S") {
            $int = 5
            for ($i = 0; $i -lt $Seconds; $i += $int) {
                if (-not($NoExit -or $_syncHash.NI.Visible)) { exit }
                Microsoft.PowerShell.Utility\Start-Sleep -Seconds $int }
        } else {
            $int = 100
            for ($i = 0; $i -lt $Milliseconds; $i += $int) {
                if (-not($NoExit -or $_syncHash.NI.Visible)) { exit }
                Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds $int }}}
    $script:PSCommandPath = $_syncHash.PSCP
    $script:PSScriptRoot  = $_syncHash.PSSR
'@ + $scriptBlock)
$PS = [PowerShell]::Create().AddScript($action) #.AddArgument($Param1).AddArgument($Param2)
$PS.Runspace = $runspace
$status = $PS.BeginInvoke()
$appContext = New-Object System.Windows.Forms.ApplicationContext
[void][System.Windows.Forms.Application]::Run($appContext)
exit
#! ---------- ScriptBlock (Line No. 28) begins here ---------- DO NOT REMOVE THIS LINE
###################################################################################################################################
$src_root = "C:\somedir_containing_important_files"
$dst_root = "D:\backup"
$num_copy =  20
$interval_1  = 180
$interval_2  =  15
###################################################################################################################################
$ErrorActionPreference = "Stop"
while (-not (Test-Path $dst_root)) { Write-Output "$(Get-Date) Waiting..."; Start-Sleep 15 }
$MD5       = New-Object System.Security.Cryptography.MD5CryptoServiceProvider
$src_root  = Convert-Path -LiteralPath $src_root
$dst_root  = Convert-Path -LiteralPath $dst_root
$old_mtime = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$old_hash  = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$old_name  = New-Object 'System.Collections.Generic.Dictionary[String,String]'
# Restore $old_hash of the last session from the backup.hash file.
if ( Test-Path -LiteralPath "$dst_root\backup.hash" ) {
    try{
        $sr = [IO.StreamReader]::new( "$dst_root\backup.hash", [Text.Encoding]::Default )
        while ( -not $sr.EndOfStream ) {
            $key,$value,$src_file = $sr.ReadLine() -split ","
            $old_hash.$key = $value
            $old_name.$key = $src_file
        }
    } catch {
        throw
    } finally {
        $sr.Close()
    }
    # Regenerate backup.hash for filtering out the updated records.
    try {
        $sw = [IO.StreamWriter]::new( "$dst_root\backup.hash_", $false, [Text.Encoding]::Default )
        $old_hash.GetEnumerator() | & { process{ $sw.WriteLine( "$($_.Key),$($_.Value),$($old_name.($_.Key))" ) } }
    } catch {
        throw
    } finally {
        $sw.Close()
    }
    if ( Test-Path "$dst_root\backup.hash_" ) {
        Move-Item -LiteralPath "$dst_root\backup.hash_" -Destination "$dst_root\backup.hash" -Force
    }
}
while ( $true ) {
    try {
        $sw = [IO.StreamWriter]::new( "$dst_root\backup.hash", $true, [Text.Encoding]::Default )
        Get-ChildItem -LiteralPath $src_root -File -Recurse | ForEach-Object {
            $file = $_
            $src_file = $file.FullName
            if ( $src_file -Like "$dst_root\*" ) { return }

#           if ( $file.Length -gt 100MB ) { return }

            $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
            $idx = [Bitconverter]::ToString($MD5.ComputeHash([Text.Encoding]::Default.GetBytes( $src_file ))).Replace("-","")
            # First, pick up candidates by file modification time.
            if ( $new_mtime -ne $old_mtime.$idx ) {
                $stream = try {
                    [IO.FileStream]::new( $src_file, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read )
                } catch {
                    [IO.FileStream]::new( $src_file, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite )
                }
                if ( $stream ) {
                    try {
                        $new_hash = ( Get-FileHash -InputStream $stream -Algorithm MD5 ).Hash
                    } catch {
                        throw
                    } finally {
                        $stream.Close()
                    }
                } else { throw }
                # Then, use the hash value to determine the target.
                if ( $new_hash -ne $old_hash.$idx ) {
                    $dst_dir = $dst_root + $file.DirectoryName.Substring($src_root.Length, $file.DirectoryName.Length - $src_root.Length)
                    if ( -not ( Test-Path -LiteralPath $dst_dir ) ) {
                        New-Item -ItemType Directory -Force -Path $dst_dir | Out-Null
                    }
                    $dst_file = Join-Path $dst_dir ($file.BaseName + "_" + $new_mtime + $file.Extension)
                    if ( -not ( Test-Path $dst_file ) ) {
                        Copy-Item -LiteralPath $src_file -Destination $dst_file -Force
                        $filter =  $file.BaseName + "_????????????" + $file.Extension
                        Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                    }
                    $old_hash.$idx = $new_hash
                    $sw.WriteLine( "$idx,$new_hash,$src_file" )
                }
                $old_mtime.$idx = $new_mtime
            }
        }
        $sw.Close()
        $interval = $interval_1
    } catch [System.Exception] {
        Write-Output $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
        $interval = $interval_2
    } finally {
        if ( $sw -and $sw.BaseStream ) { $sw.Close() }
    }
    Start-Sleep $interval
}
```
</div></details>

[^1]: https://qiita.com/earthdiver1/items/a0a016f636de0ba3bbc7


# おまけ（2018.8.26追記）
Remove-Item コマンドレットが含まれる行を

```powershell:
Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object -Descending | Retention3221 | Remove-Item -Force
```

に置き換えることにより、下記のバックアップファイルを保持するリテンションポリシーを適用できます。

- 過去72時間(3日間)の全更新ファイル（上限 `$num_copy` 個）
- 過去2週間の各日の最終更新ファイル
- 過去2ヶ月間の各週の最終更新ファイル
- 過去1年間の各月の最終更新ファイル 
- 各年の最終更新ファイル

Retention3221 関数の定義は以下の通り（`while ( $true )`の前に挿入します）。
注1：パイプライン内でフィルターとして動作します。
注2：入力されるオブジェクトの並びが更新日時で降順にソートされている必要があります。

```powershell:
Function Retention3221 {
    begin {
        $now = Get-Date
        $b3d = $now.AddDays(-3)   # 3 days
        $b2w = $now.AddDays(-7*2) # 2 weeks
        $b2m = $now.AddMonths(-2) # 2 months
        $b1y = $now.AddYears(-1)  # 1 year
        $last_year  = $now.Year
        $last_month = $now.Month
        $last_day   = $now.Day
        $jan1st2000 = [datetime]::ParseExact("2000/01/01", "yyyy/MM/dd", $Null)
        $last_week  = [Math]::Ceiling(($now-$jan1st2000).Days/7)
        $n3d = 0
    }
    process {
        $LastWriteTime = $_.BaseName.SubString($_.BaseName.Length-12,12)
        $lwt   = [datetime]::ParseExact($LastWriteTime, "yyyyMMddHHmm", $Null) # $_.LastWriteTime
        $year  = $lwt.Year
        $month = $lwt.Month
        $day   = $lwt.Day
        $week  = [Math]::Ceiling(($lwt-$jan1st2000).Days/7)

        $keep = $False

        if ($lwt -gt $b3d -and $n3d -lt $num_copy) {
            $n3d++
            $keep = $True
        } elseif ($lwt -gt $b2w) {
            if ($day -ne $last_day)     { $keep = $True }
        } elseif ($lwt -gt $b2m) {
            if ($week -ne $last_week)   { $keep = $True }
        } elseif ($lwt -gt $b1y) {
            if ($month -ne $last_month) { $keep = $True }
        } else {
            if ($year -ne $last_year)   { $keep = $True }
        }

        $last_year  = $year
        $last_month = $month
        $last_day   = $day
        $last_week  = $week

        if (-not $keep) { return $_ }
    }
}
```

# おまけ２（2021.4.17追記）
コピー先（バックアップ先）のフォルダに古いファイルが大量に溜まってきた場合に、ファイル作成日付が90日前よりも古いファイルを一括削除するスクリプトの例です。コピー先フォルダに置いてダブルクリックで実行します。

一括削除ツールは結構危険ですので無保証・自己責任にてご利用願います（全ての環境での正常動作は保証できません。例えばJunctionが含まれる場合はもう少し凝ったロジックが必要です。）。

```powershell:@@@DeleteOldFiles.bat
@PowerShell -NoP -C "$PSCP='%~f0';&([ScriptBlock]::Create((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & pause & exit/b
#----------------------------------------------------
$retentionPeriod = 90
#----------------------------------------------------
$limit = (Get-Date).AddDays(-$retentionPeriod)
if ($PSCP) { 
    $thisFile = (Get-Item -LiteralPath $PSCP).FullName
} else {
    $thisFile = (Get-Item -LiteralPath $MyInvocation.MyCommand.Path).FullName
}
if (-not (Test-Path $thisFile)) { exit }
$path = Split-Path $thisFile -Parent
Write-Host "ファイルを削除しています..."
Get-ChildItem -LiteralPath $path -Recurse -File      -Force -EA 0 `
   | ? { $_.CreationTime -lt $limit } | ? { $_.FullName -ne $thisFile } `
   | % { Write-Host $_.FullName; Remove-Item -LiteralPath $_.FullName -Force }
Write-Host "空のフォルダを削除しています..."
Get-ChildItem -LiteralPath $path -Recurse -Directory -Force -EA 0 `
   | ? { (Get-ChildItem -LiteralPath $_.FullName -File -Recurse) -eq $null } `
   | Sort-Object -Property @{ 
        Expression={ $_.FullName.Split([IO.Path]::DirectorySeparatorChar).Count };
        Descending=$true } `
   | % { Write-Host $_.FullName; Remove-Item -LiteralPath $_.FullName -Force }
```
　

クリエイティブ・コモンズ 表示 - 継承 4.0 国際