Amazonで商品を見るセール会場へ

Windowsバッチ│検索結果のパス取得してコピー、後で元に場所に戻す方法

検索したファイルを集めて編集する場合は、元の相対パスと作業用ファイル名の対応表を先に保存します。

同じ名前のファイルが別フォルダーにある場合も、作業用の名前に連番を付けると区別できます。

この手順は元ファイルを残し、編集後のファイルを新しいrestoredフォルダーへ元の階層でコピーします。

元の保存先を自動で上書きする処理は行わず、復元した内容を比較してから制作側へ反映します。

目次

入力例と検索結果のパスを用意する

ファイル操作・一覧出力の練習用データとスクリプト(ZIP)

ZIPは新しい空のフォルダーへ展開し、元データや既存の出力を含む場所へ重ねて展開しないでください。

ZIPのsource/aとsource/bには、同名の「同名 ! & [x].txt」が1つずつあります。

paths.txtはこの2ファイルの場所を1行ずつ記録しており、外側の二重引用符は付いていても構いません。

  1. 実データで試す前に、ZIPの練習データで以下の収集・復元を行う
  2. 実データでは、元ファイルが共通して入るフォルダーをRootとして決める
  3. エクスプローラーでその範囲を検索して対象を選択し、「パスのコピー」で一覧を取得する
  4. パスを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" -Apply

stageには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" -Apply

restoredには編集後の内容が入り、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。

関連するファイル操作とツール

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

仕事効率化のおすすめ書籍

仕事用PCの選び方

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次