検索したファイルを集めて編集する場合は、元の相対パスと作業用ファイル名の対応表を先に保存します。
同じ名前のファイルが別フォルダーにある場合も、作業用の名前に連番を付けると区別できます。
この手順は元ファイルを残し、編集後のファイルを新しいrestoredフォルダーへ元の階層でコピーします。
元の保存先を自動で上書きする処理は行わず、復元した内容を比較してから制作側へ反映します。
入力例と検索結果のパスを用意する
ZIPは新しい空のフォルダーへ展開し、元データや既存の出力を含む場所へ重ねて展開しないでください。
ZIPのsource/aとsource/bには、同名の「同名 ! & [x].txt」が1つずつあります。
paths.txtはこの2ファイルの場所を1行ずつ記録しており、外側の二重引用符は付いていても構いません。
- 実データで試す前に、ZIPの練習データで以下の収集・復元を行う
- 実データでは、元ファイルが共通して入るフォルダーをRootとして決める
- エクスプローラーでその範囲を検索して対象を選択し、「パスのコピー」で一覧を取得する
- パスを1行1つでpaths.txtへ貼り付け、UTF-8で保存する
エクスプローラーのメニュー操作は今回の実機確認に含めていません。
収集して編集する
PS1はWindows PowerShell 5.1で使用し、UTF-8(BOM付き)で保存します。
以下の起動例はそのプロセスだけにRemoteSignedを指定し、PC全体の実行ポリシーは変更しません。
ダウンロードしたPS1の署名エラーでは内容と出所を確認し、信頼できる対象ファイルだけプロパティからブロックを解除します。
組織のポリシーで止まる場合は管理者へ確認してください。
ZIPを展開した場所でコマンドプロンプトを開き、まずプレビューだけを実行します。
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File ".\workflow.ps1" -Root ".\source" -Stage ".\stage" -List ".\paths.txt"RelativeとStagedNameが2件分表示され、stageはまだ作成されません。
対応関係を確認した後、同じコマンドの末尾へ-Applyを付けてコピーします。
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File ".\workflow.ps1" -Root ".\source" -Stage ".\stage" -List ".\paths.txt" -Applystageにはmapping.csvと、000001_・000002_で始まる作業用ファイルができます。
stageの作業用ファイルだけを編集し、ファイル名とmapping.csvを変えずに保存します。
元の階層へ復元する
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File ".\workflow.ps1" -Mode Restore -Root ".\source" -Stage ".\stage" -Output ".\restored"SourceとDestinationを比較し、出力先がrestored/aとrestored/bへ分かれていることを確認します。
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File ".\workflow.ps1" -Mode Restore -Root ".\source" -Stage ".\stage" -Output ".\restored" -Applyrestoredには編集後の内容が入り、sourceのファイル名と内容は変わりません。
戻すときは保持したsourceを使い、restoredを元データへ一括上書きしないでください。
収集と復元のスクリプト
以下がZIPのworkflow.ps1の全文です。
param([ValidateSet('Collect','Restore')][string]$Mode = 'Collect',
[Parameter(Mandatory=$true)][string]$Root,
[Parameter(Mandatory=$true)][string]$Stage,
[string]$List, [string]$Output, [switch]$Apply)
$ErrorActionPreference = 'Stop'
function Folder([string]$path) {
$item = Get-Item -LiteralPath $path
if (-not $item.PSIsContainer) { throw 'Expected a folder' }
return $item.FullName.TrimEnd('\')
}
function Outside([string]$path, [string]$base) {
if ($path -eq $base -or $path.StartsWith($base + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'Output must be outside input folders'
}
}
function PlainFile([string]$path, [string]$base) {
$item = Get-Item -LiteralPath $path
if ($item.PSIsContainer -or -not $item.FullName.StartsWith($base + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'File must be inside the specified folder'
}
$check = $item
while ($check) {
if ($check.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Links/junctions are not supported' }
if ($check.FullName.TrimEnd('\') -eq $base) { break }
$check = if ($check.PSIsContainer) { $check.Parent } else { $check.Directory }
}
return $item
}
$rootPath = Folder $Root
$stagePath = [IO.Path]::GetFullPath($Stage).TrimEnd('\')
Outside $stagePath $rootPath
if ($Mode -eq 'Collect') {
if (Test-Path -LiteralPath $stagePath) { throw 'Stage already exists' }
if (-not $List) { throw 'List is required' }
$paths = @(Get-Content -LiteralPath $List -Encoding UTF8 | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ })
if (-not $paths.Count) { throw 'Empty path list' }
$files = @($paths | ForEach-Object { PlainFile $_ $rootPath })
if (@($files | Group-Object FullName | Where-Object Count -gt 1).Count) { throw 'Duplicate input paths' }
$i = 0
$mapping = @(foreach ($file in $files) {
$i++
[pscustomobject]@{
Relative = $file.FullName.Substring($rootPath.Length + 1)
StagedName = ('{0:D6}_' -f $i) + $file.Name
OriginalSHA256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
}
})
$mapping | Format-List Relative, StagedName
if (-not $Apply) { Write-Host 'Preview only'; return }
[IO.Directory]::CreateDirectory($stagePath) | Out-Null
$mapping | Export-Csv -LiteralPath (Join-Path $stagePath 'mapping.csv') -NoTypeInformation -Encoding UTF8
foreach ($row in $mapping) {
$src = Join-Path $rootPath $row.Relative
$dst = Join-Path $stagePath $row.StagedName
if ((Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash -ne $row.OriginalSHA256) { throw 'Original changed during copy' }
[IO.File]::Copy($src, $dst, $false)
if ((Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash -ne $row.OriginalSHA256) { throw 'Copy hash mismatch' }
}
} else {
$stagePath = Folder $stagePath
if (-not $Output) { throw 'Output is required' }
$out = [IO.Path]::GetFullPath($Output).TrimEnd('\')
Outside $out $rootPath
Outside $out $stagePath
if (Test-Path -LiteralPath $out) { throw 'Output already exists' }
$mapping = @(Import-Csv -LiteralPath (Join-Path $stagePath 'mapping.csv') -Encoding UTF8)
if (-not $mapping.Count) { throw 'Empty mapping' }
if (@($mapping | Group-Object Relative | Where-Object Count -gt 1).Count -or
@($mapping | Group-Object StagedName | Where-Object Count -gt 1).Count) { throw 'Duplicate mapping' }
$plan = @(foreach ($row in $mapping) {
if (-not $row.Relative -or [IO.Path]::IsPathRooted($row.Relative) -or
$row.Relative -match '(^|[\\/])\.\.?([\\/]|$)|:' -or
-not $row.StagedName -or $row.StagedName -ne [IO.Path]::GetFileName($row.StagedName)) { throw 'Invalid mapping path' }
$original = PlainFile (Join-Path $rootPath $row.Relative) $rootPath
if ((Get-FileHash -LiteralPath $original.FullName -Algorithm SHA256).Hash -ne $row.OriginalSHA256) { throw 'Original changed; compare manually' }
$edited = PlainFile (Join-Path $stagePath $row.StagedName) $stagePath
[pscustomobject]@{
Relative = $row.Relative
Source = $edited.FullName
Destination = Join-Path $out $row.Relative
SHA256 = (Get-FileHash -LiteralPath $edited.FullName -Algorithm SHA256).Hash
Original = $original.FullName
OriginalSHA256 = $row.OriginalSHA256
}
})
$plan | Format-List Relative, Source, Destination
if (-not $Apply) { Write-Host 'Preview only'; return }
[IO.Directory]::CreateDirectory($out) | Out-Null
foreach ($row in $plan) {
if ((Get-FileHash -LiteralPath $row.Original -Algorithm SHA256).Hash -ne $row.OriginalSHA256 -or
(Get-FileHash -LiteralPath $row.Source -Algorithm SHA256).Hash -ne $row.SHA256) { throw 'Input changed during restore' }
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($row.Destination)) | Out-Null
[IO.File]::Copy($row.Source, $row.Destination, $false)
if ((Get-FileHash -LiteralPath $row.Destination -Algorithm SHA256).Hash -ne $row.SHA256) { throw 'Restore hash mismatch' }
}
}
Write-Host ('Completed: ' + $mapping.Count)
停止したときの確認
Stage already existsまたはOutput already existsでは、前回の出力を残し、新しいStage・Output名でやり直します。
Original changedでは、収集後に元ファイルが変わっているため、どちらを採用するか内容を比較して判断します。
Missing・Empty・Duplicate・Invalidのエラーでは、paths.txtとmapping.csv、作業用ファイルの不足や重複を確認します。
元フォルダー内への出力やリンク・ジャンクションは対象外です。
コピー中のエラーでは出力の一部だけが残る可能性があるため、その出力を完成扱いせず、元データを保持して確認します。
通常ファイルの内容をコピーする例で、ACLや代替データストリームを含む完全なバックアップではありません。
2026年10月2日にWindows PowerShell 5.1で同名・日本語・記号、編集後のバイト一致、元データの保持、既存出力・不足・重複・元データ変更・不正な相対パスでの停止を検証しました。
仕様:Microsoft File.Copy、Get-FileHash。
関連するファイル操作とツール





