LoginSignup
4
4

More than 3 years have passed since last update.

フルパス・更新日時・サイズ・属性の四拍子! ファイルサーバのファイルを連続的に一覧化しCSV出力するPowerShellスクリプト

Last updated at Posted at 2019-08-07

概要

あの情報が載っているファイルが何処かにあるはずだと思った時、
エクスプローラで検索窓にキーワードを入れてみたものの、長時間待った挙げ句に何も見つからなかった
なんて事があるかと思います。

先日ファイル一覧化バッチを公開しましたが、
パワーアップを図るべくPowerShellに移植しました。

これにより、サクラエディタ等のGrep機能でファイルを検索する事は勿論ですが、
バッチファイル版と比較して下記の利点があります。

  • UNCパスを指定可能 ⇒ ネットワークドライブの割り当てが不要
  • ディレクトリ階層と更新日時・ファイルサイズ等が一行に ⇒ より細かい条件でGrep可能
  • 出力するファイル一覧がタブ区切りファイル ⇒ Excelで編集しやすい

ネットワークドライブの割り当ては、人によってどのネットワークパスをどの文字へ割り当てるかが異なるでしょうから、メール等でファイルの場所を周知する場合に不便が生じます。PowerShell版で初めからUNCパスでファイルを一覧化しておくことで、その心配が無くなります。
但し、Windowsではファイルパスに260文字の制限があるため、ファイルパスが260文字を超えてしまうファイルについてはエラーを吐いてファイルの情報が取得できません。その点においては、共有フォルダまでのアドレス分の文字数を省けるだけ、UNCパスよりもドライブ割り当ての方に分があります。

※「Grep」とは複数のファイルからキーワードに該当する部分を一括で抽出してくれるプログラムの事です。
正規表現を活用してGrepすると幸せになれます。サクラエディタを使用したGrepの仕方については、別記事で書いています。

環境

Windows 10, Windows 7

使用方法

  • 一覧化対象のディレクトリパスを記述したリストファイル(CSVファイル)を、"LsFile.bat" ファイルにDrag&Dropします。
  • ファイルへのドロップが難しい場合は、そのまま起動するとファイルパスの入力を求められるため、リストファイルをプロンプト画面にD&Dして [Enter]キーを押下します。

  • CSVファイルの例を同梱しましたので、参考にしてください。

    • 1カラム目に終止符 '.' を含めると直下のみ、即ちサブディレクトリより下を含めずに出力します。何も記述しないとエラーになりますので、"tsv" とでも記述して下さい。
    • 1カラム目に "cmd" を記述すると、2カラム目に記述したコマンドを実行します。通常はDOSコマンドとして実行されますが、1カラム目に"cmdlet" と記述するとPowerShellのコマンドとして実行されます。 但し、3カラム目に記述したものと同じ名前のファイルが存在する場合は実行を回避します。複数のリストを並列で実行したい場合に互いのロックファイルを指定すると効果的です。
    • 1カラム目に '#' を含めると、その行の処理をスキップします。
  • 本バッチ実行後にロックファイルとして "(CSVファイル名)+.lock" が生成されます。これを削除すると、リスト内の次の行へ処理が移らずに、頭から再度処理が始まります。
    次以降の処理を変更したいけれど今の処理を中止したくない場合に便利です。

  • もう一つのバッチファイル "GetSubDir.bat" は、
    起動してディレクトリのパスをペーストすると、ディレクトリ直下のフォルダ名を "GetSubDir.txt" へ出力します。
    リスト作成に役立ちます。

  • 本バッチは、処理が成功したかを確認できるよう、下記の通りのログファイルを生成します。不要であれば削除しても差し支えありません。

    • リストファイル(CSVファイル)を1行処理する度に、ログファイル "LsFile-<リストファイル名>.log" に処理結果及びパラメータが出力されます。
    • Get-ChildItemコマンドレット自身がエラーを出力した場合、エラー内容がログファイル "LsErr-<リストファイル名>.log" に出力されます。

ソースファイル

「マジック生成」するには、本ページ全体を選択してコピー後にB642FHT.batを起動して下さい。
その後、生成したZIPファイルを解凍して任意の場所へ配置して下さい。

興味ある方は、以下のコードをご覧ください。

コード

LsFile.bat
@ECHO OFF
PowerShell -ExecutionPolicy RemoteSigned -File %~dpn0.ps1 %* "nul" "nul"
EXIT /B
LsFile.ps1
$Host.UI.RawUI.ForeGroundColor = "Green"

 "############## LsFile.ps1 ##############"
 "# Listing files                        #"
 "#       in the specified directory     #"
 "#                        by PowerShell #"
 "#                                      #"
 "#   1st release: 2019-07-30            #"
 "#   Last update: 2019-08-19            #"
 "#   Author: Y. Kosaka                  #"
 "#   See the web for more information   #"
 "#   https://qiita.com/x-ia             #"
 "########################################"

$nameFileScript = (Get-ChildItem $MyInvocation.MyCommand.Path).BaseName
$dirPathScript = Split-Path $MyInvocation.MyCommand.Path -Parent
$extOut = ".txt"
$nameOut = "FList-"
$nameErr = "LsErr"
$extLog = ".log"
$extLock = ".lock"
$Host.UI.RawUI.WindowTitle = $nameFileScript
$pathFileList = $Args[0]
$pathFileLog = $dirPathScript + "\" + $nameFileScript + $extLog
$timePause = 1500
$cntProc = 0
$cntSucc = 0

if (-Not (Test-Path $pathFileList)) {
  Write-Host "`r`nOption,SearchDrvName/Cmd,SearchDrv/LockFile,SearchPath,SaveDrive,SavePath,Option"
  Write-Host "Enter the path of a CSV file containing parameters for listing up files like above."
  $pathFileList = Read-Host
  if ($pathFileList -eq $null) {
    $pathFileList = "nul"
  }
  if (-Not (Test-Path $pathFileList)) {
    Write-Host "`r`nFile not found.`r`nTerminated."
    Start-Sleep -milliseconds $timePause
    Exit
  }
} else {
  Write-Host "`r`nCSV file containing parameters for listing up files`r`n$pathFileList`r`n"
}

$pathDirList = Split-Path $pathFileList -Parent
$arrList = Get-Content $pathFileList | `
  ConvertFrom-CSV -header keyOpt,keyLabelSearch,keyDrvSearch,keyPathSearch,keyDrvOut,keyPathOut -Delimiter ","
$pathFileLock = $pathFileList + $extLock
(Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff") | `
  Out-File $pathFileLock -Encoding Default -Append

$pathFileLog = $dirPathScript + "\" + $nameFileScript + `
  "-" + $(Get-ChildItem $pathFileList).BaseName + $extLog
$pathFileErr = $dirPathScript + "\" + $nameErr + `
  "-" + $(Get-ChildItem $pathFileList).BaseName + $extLog

for ($i=0; $i -lt $arrList.Length; $i++) {
  $error.clear()
  $dateNow = (Get-Date).ToString("yyyyMMdd")
  $timeNow = (Get-Date).ToString("HHmmss")
  $timeStamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff")

  $strParams = $arrList[$i].keyOpt + `
    "`t" + $arrList[$i].keyLabelSearch + `
    "`t" + $arrList[$i].keyDrvSearch + `
    "`t" + $arrList[$i].keyPathSearch + `
    "`t" + $arrList[$i].keyDrvOut + `
    "`t" + $arrList[$i].keyPathOut

  ++$cntProc
  $Host.UI.RawUI.WindowTitle = "$nameFileScript $cntSucc/$cntProc"

  "`r`n$strParams`r`n>>>>> " + $timeStamp + " >>>>>" | `
    Out-File $pathFileErr -Append

  if ($arrList[$i].keyOpt -like "*#*") {
    $strResult = "Skipped"
  } elseif (($arrList[$i].keyOpt -like "*cmd*") -Or ($arrList[$i].keyOpt -like "*command*")) {
    if ($arrList[$i].keyDrvSearch.Length -gt 0) {
      $pathFileChk = $pathDirList + "\" + $arrList[$i].keyDrvSearch
    } else {
      $pathFileChk = "nul"
    }
    if (Test-Path $pathFileChk) {
      Write-Host "$timeStamp`tSkipped executing`t$($arrList[$i].keyLabelSearch)"
      Write-Host "`tdue to a lock file`t$($arrList[$i].keyDrvSearch)"
      $strResult = "Skipped"
    } else {
      try {
        $strResult = "Executed"
        Write-Host "$timeStamp`tExecuting`t$strParams"
        "$timeStamp`t$nameFileScript`tExecuting`t$strParams" | `
          Out-File $pathFileLog -Encoding Default -Append
        if (($arrList[$i].keyOpt -like "*cmdlet*") -Or ($arrList[$i].keyOpt -like "*commandlet*")) {
          Invoke-Expression $arrList[$i].keyLabelSearch >> $pathFileErr 2>&1
        } else {
          CMD /C $arrList[$i].keyLabelSearch >> $pathFileErr 2>&1
        }
      } catch {
        $error[0] | Out-String | Out-File "$pathFileErr" -Append
        $strResult = "Error"
      } finally {
        $strResult += "(" + @($error).Length + ")"
      }
    }
  } else {
    if ($arrList[$i].keyOpt -like "*`.*") {
      $optSub = ""
    } else {
      $optSub = "-Recurse"
    }

    if ($arrList[$i].keyDrvSearch.SubString(0,1) -ne "\") {
      $arrList[$i].keyDrvSearch = $arrList[$i].keyDrvSearch.SubString(0,1) + ":"
    }
    $pathDir = $arrList[$i].keyDrvSearch + "\" + $arrList[$i].keyPathSearch
    $pathDir = $pathDir.Replace("\\", "\")
    if ($pathDir.SubString(0,1) -eq "\") {
      $pathDir = "\" + $pathDir
    }

    if ($arrList[$i].keyDrvOut.SubString(0,1) -ne "\"){
      $arrList[$i].keyDrvOut = $arrList[$i].keyDrvOut.SubString(0,1) + ":"
    }
    $pathFileOut = $nameOut + $arrList[$i].keyLabelSearch + "." + `
      $arrList[$i].keyPathSearch.Replace("\", ".") + "." 
    if ($arrList[$i].keyOpt -notlike "*`.*") {
      $pathFileOut = $pathFileOut.SubString(0, $pathFileOut.Length - 1)
    }
    $pathFileOut = $arrList[$i].keyDrvOut + `
      "\" + $arrList[$i].keyPathOut + `
      "\" + $pathFileOut + "_" + $dateNow + `
      "-" + $timeNow + $extOut
    $pathFileOut = $pathFileOut.Replace("\\", "\").Replace("..", ".")
    if ($pathFileOut.SubString(0,1) -eq "\") {
      $pathFileOut = "\" + $pathFileOut
    }

    Write-Host "$timeStamp`tStarted`t$strParams"
    "$timeStamp`t$nameFileScript`tStarted`t$strParams" | `
      Out-File $pathFileLog -Encoding Default -Append
    try {
      $strResult = "Finished"
      $cmdLs = "Get-ChildItem -LiteralPath `"$pathDir`" * $optSub -Force 2>> `"$pathFileErr`" `| `
        Select-Object Mode,Length,LastWriteTime,FullName `| `
        ConvertTo-CSV -Delimiter `"`t`" -NoType `| `
        Out-File `"`$pathFileOut`".Replace(`"`[`",`"(`").Replace(`"`]`",`")`") -Encoding Default"
      Invoke-Expression $cmdLs
    } catch {
      $error[0] | Out-String | Out-File "$pathFileErr" -Append
      $strResult = "Error"
    } finally {
      $strResult += "(" + @($error).Length + ")"
    }
  }

  $timeStamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff")

  Write-Host "$timeStamp`t$strResult`t$strParams"
  "$timeStamp`t$nameFileScript`t$strResult`t$strParams" | `
    Out-File $pathFileLog -Encoding Default -Append

  "<<<<< " + $timeStamp + " <<<<<`r`n" | `
    Out-File $pathFileErr -Append

  if (@($error).Length -eq 0) {
    ++$cntSucc
  }

  if (-Not (Test-Path $pathFileLock)) {
    $i = 0
    Write-Host "Batch list has reset."
    $arrList = Get-Content $pathFileList | `
      ConvertFrom-CSV -header keyOpt,keyLabelSearch,keyDrvSearch,keyPathSearch,keyDrvOut,keyPathOut -Delimiter ","
    (Get-Date).ToString("yyyy-MM-dd HH:mm:ss.fff") | `
      Out-File $pathFileLock -Encoding Default -Append
  }
}

$Host.UI.RawUI.WindowTitle = "$nameFileScript $cntSucc/$cntProc"
Remove-Item $pathFileLock
Start-Sleep -milliseconds $timePause
GetSubDir.bat
@ECHO OFF
COLOR 0A
SETLOCAL ENABLEDELAYEDEXPANSION

ECHO ############ GetSubDir.bat #############
ECHO # Getting subfolders                   #
ECHO #                                      #
ECHO #   Last update: 2019-05-18            #
ECHO #   Author: Y. Kosaka                  #
ECHO #   See the web for more information   #
ECHO #   https://qiita.com/x-ia             #
ECHO ########################################

SET tScr=%~n0
SET fScr=%~dpn0
SET extOut=.txt
TITLE %tScr%

:loop
CALL :pInput %1
IF %pInput% EQU "" GOTO eof
IF NOT EXIST %pInput% (
  ECHO Specified directory not exists.
  GOTO loop
)

CALL :getsub
SET /A cnt=cnt+1
TITLE %tScr% %cnt%paths 

SHIFT
ECHO.
GOTO loop

:eof
ECHO Terminated. 
PAUSE
ENDLOCAL
EXIT /B


:pInput
ECHO.
IF %1. NEQ . (
  SET pInput="%1"
  ECHO !pInput!
) ELSE (
  SET pInput=
  ECHO Enter the directory to get subfolders.
  ECHO (To exit, hit the Enter key w/o any characters^)
  SET /P pInput=directory path= 
)
IF !pInput!. EQU . SET pInput=""
SET pInput="!pInput:"=!"
ECHO !pInput!
EXIT /B


:getsub
ECHO %date% %time%  %tScr%  %pInput%>>"%fScr:"=%%extOut%"
DIR /B /AD %pInput%>>"%fScr:"=%%extOut%"
ECHO.>>"%fScr:"=%%extOut%"
DIR /AD %pInput%
EXIT /B

バイナリ (Base64 encoding)

LsFile.zip
---
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
AA==
---
GetSubDir.zip
---
UEsDBBQAAAAIAIOM7U6BuMAsRAIAAO4EAAANAAAAR2V0U3ViRGlyLmJhdI1T72vb
MBD9XIP/h4uDoGWLkwwGWyBlbqK0ZiZuaxfaLwPHURrRxvLsC02/9G/fSarzY926
CWws6d27d+/O3/joIoZ4MnGdURzF19ALXCfhaRSPggj4NDiL+JhHwR29by+DaRLG
U9dxHRPW3ltwLjBZz8ay8mcZHly1G7jGoCzuoV7PFupxLqoa3q4d/L/WPjzKaoR1
Oc9QDOBTr/+10/vc6X/5GzxY41JVA7jz4buqs4fsffZECMClgCcxg4WqYKUqAbKg
z1WGUhW/wZeIZT3odn9KiZmfq1V305GHKdp/MPK9pZ2n5gAmeTVkL0XPbhd2Oy+b
A7HBeI1DHzfoOmmYRhyYjmGaYPCoVEntDqIIBmVYlGsE1nedcALMbhnwqxvwPDiP
0xiEWpjLaZwCvw2TdAc7dh0AU0FSilwupJjDXFYiR1U9Q6GQlMgaa1/jDJnNfaJ1
WAH3AmkarOxuAHmBQ3o+9A91A6NDVma4rMGYcBFOUmsece8xU3lGrxGVimolCxqG
uU9Rl8FNwulmOjbDTV+3IeU800EUZmvacmo3+j5M+RX4tk6t0IKGHut729Jb9rBF
ZQGPEv4GvUXyAkVlRmhnEiogC/b+CH8LP06V9g8/wlKiCbMED+IZnroKsuIZ8mVW
ZTmd1j9OmrTdyybzLo22bgjGeaqskeybRvsHpXm2F832FTrwhi3v1dZdwYcONq00
IKZ/QuobypVgR7aLR83gnJ56TA8tsTJmh5UR+zi8JjYagzH8A2ma9B7LHsWezF9Q
SwECFAAUAAAACACDjO1OgbjALEQCAADuBAAADQAAAAAAAAABACAAAAAAAAAAR2V0
U3ViRGlyLmJhdFBLBQYAAAAAAQABADsAAABvAgAAAAA=
---
4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4