PowerShellで作るファイルのコメントを管理するコンソールアプリ

コンピュータ

指定するファイルに対してコメントを記録するコンソールアプリです。
一つの外部コマンドで、コメントの追加・変更・削除を行います。

以前記事にしたC#で作成したプログラムをPowerShellスクリプトにしてみました。若干機能を追加していて手元の環境ではC#版より高速に動作しています。

導入方法

以下のスクリプトをテキストエディタなどで$PROFILEへ追記

スクリプト

# ===== Comment tools in $PROFILE =====

# ルートとインデックスファイル
$Global:CommentRoot = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'comment'
$Global:CommentIndexFile = Join-Path $Global:CommentRoot 'index.txt'  # 1行=正規化パス
$Global:Utf8 = [System.Text.UTF8Encoding]::new($false)

# メモリ常駐インデックス: hashHex(lower) -> canonicalPath
$Global:CommentIndex = @{}

# 共有ロック(複数シェル/プロセス対策の簡易Mutex)
function Invoke-WithCommentIndexLock([scriptblock]$Action) {
  $m = [System.Threading.Mutex]::new($false, 'Global\CommentIndexLock')
  try { $null = $m.WaitOne(5000); & $Action } finally { $m.ReleaseMutex(); $m.Dispose() }
}

function ConvertTo-CanonicalPath([string]$Path) {
  [System.IO.Path]::GetFullPath($Path).TrimEnd('\','/').ToLowerInvariant()
}
function Get-Md5HexLower([string]$Text) {
  $md5 = [System.Security.Cryptography.MD5]::Create()
  try {
    ($md5.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text)) | ForEach-Object { $_.ToString('x2') }) -join ''
  } finally { $md5.Dispose() }
}
function Get-CommentFilePathByHash([string]$HashHex) {
  Join-Path $Global:CommentRoot ("{0}.txt" -f $HashHex)
}
function Get-CommentFilePathByCanonical([string]$Canonical) {
  Get-CommentFilePathByHash (Get-Md5HexLower $Canonical)
}

# 起動時ロード(index.txt を読み→各行のMD5を計算→連想配列へ)
function Initialize-CommentEnv {
  New-Item -ItemType Directory -Path $Global:CommentRoot -Force | Out-Null
  if (-not (Test-Path $Global:CommentIndexFile)) {
    New-Item -ItemType File -Path $Global:CommentIndexFile -Force | Out-Null
  }
  $Global:CommentIndex.Clear()
  foreach ($line in Get-Content -LiteralPath $Global:CommentIndexFile -Encoding UTF8) {
    $canon = $line.Trim()
    if (-not $canon -or $canon.StartsWith('#')) { continue }
    $hash = Get-Md5HexLower $canon
    $Global:CommentIndex[$hash] = $canon
  }
}
Initialize-CommentEnv

# 追記(追加のみ)&メモリ更新
function Add-CommentIndex([string]$Canonical) {
  $hash = Get-Md5HexLower $Canonical
  $Global:CommentIndex[$hash] = $Canonical
  Invoke-WithCommentIndexLock {
    Add-Content -LiteralPath $Global:CommentIndexFile -Value $Canonical -Encoding UTF8
  }
}
# 削除はテキストに追記(メモリからは外す)。定期的に圧縮で掃除。
function Remove-CommentIndex([string]$Canonical) {
  $hash = Get-Md5HexLower $Canonical
  [void]$Global:CommentIndex.Remove($hash)
  Invoke-WithCommentIndexLock {
    Add-Content -LiteralPath $Global:CommentIndexFile -Value ("#DEL " + $Canonical) -Encoding UTF8
  }
}
# 圧縮(index.txt を作り直し:重複/実体なしを除去)
function Compress-CommentIndex {
  $tmp = $Global:CommentIndex.Keys |
    Where-Object { Test-Path (Get-CommentFilePathByHash $_) } |
    ForEach-Object { $Global:CommentIndex[$_] } |
    Sort-Object -Unique
  $tmpPath = "$($Global:CommentIndexFile).tmp"
  Set-Content -LiteralPath $tmpPath -Value ($tmp -join [Environment]::NewLine) -Encoding UTF8
  Invoke-WithCommentIndexLock {
    Move-Item -Force $tmpPath $Global:CommentIndexFile
  }
  Initialize-CommentEnv
}

# --- コア機能(C#版と互換IF) ---
function Get-PathComment {
  [CmdletBinding()]
  param([Parameter(ValueFromPipeline)][string[]]$Path, [switch]$Raw)
  process {
    foreach ($p in $Path) {
      if (-not $p) { continue }
      $canon = ConvertTo-CanonicalPath $p
      $cfile = Get-CommentFilePathByCanonical $canon
      $text = (Test-Path $cfile) ? [IO.File]::ReadAllText($cfile, $Global:Utf8) : $null
      if ($Raw -and $Path.Count -eq 1) { $text }
      else { [pscustomobject]@{ Path=$p; Canonical=$canon; Comment=$text } }
    }
  }
}
function Set-PathComment {
  [CmdletBinding(SupportsShouldProcess)]
  param([Parameter(Mandatory,ValueFromPipeline)][string[]]$Path,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Comment)
  process {
    foreach ($p in $Path) {
      $canon = ConvertTo-CanonicalPath $p
      $cfile = Get-CommentFilePathByCanonical $canon
      $tmp = "$cfile.tmp"
      if ($PSCmdlet.ShouldProcess($p, "Write comment")) {
        [IO.File]::WriteAllText($tmp, $Comment, $Global:Utf8)
        if (Test-Path $cfile) { [IO.File]::Replace($tmp, $cfile, $null) } else { [IO.File]::Move($tmp, $cfile) }
        Add-CommentIndex $canon
        [pscustomobject]@{ Path=$p; Comment=$Comment } 
      }
    }
  }
}
function Remove-PathComment {
  [CmdletBinding(SupportsShouldProcess)]
  param([Parameter(Mandatory,ValueFromPipeline)][string[]]$Path)
  process {
    foreach ($p in $Path) {
      $canon = ConvertTo-CanonicalPath $p
      $cfile = Get-CommentFilePathByCanonical $canon
      if ((Test-Path $cfile) -and $PSCmdlet.ShouldProcess($p,"Delete comment")) {
        Remove-Item $cfile -Force
        Remove-CommentIndex $canon | Out-Null
        [pscustomobject]@{ Path=$p; Deleted=$true }
      }
    }
  }
}
function comment {
  [CmdletBinding()]
  param([Parameter(Mandatory,Position=0,ValueFromPipeline)][string]$Path,
        [Parameter(Position=1)][AllowNull()][AllowEmptyString()][string]$Text)
  process {
    if ($PSBoundParameters.ContainsKey('Text')) {
      if ($null -ne $Text -and $Text.Length -eq 0) { $Path | Remove-PathComment | Out-Null }
      else { $Path | Set-PathComment -Comment $Text | Out-Null }
    } else {
      Get-PathComment -Path $Path -Raw
    }
  }
}

# 全文検索:comment/*.txt を走査 → ファイル名(=hash)をインメモリ逆引き
function Search-Comment {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)][string]$Query,
    [switch]$IgnoreCase = $true,  # 既定は大文字小文字を無視
    [switch]$All                    # 全マッチを出す(既定: 各ファイル1件だけ)
  )

  # Select-String 用のパラメータはハッシュでスプラット
  $ss = @{
    Pattern     = $Query
    SimpleMatch = $true            # 正規表現ではなく単純一致
    Encoding    = 'utf8'
  }
  if (-not $IgnoreCase) { $ss['CaseSensitive'] = $true }
  if (-not $All)        { $ss['List'] = $true }  # 1ファイルにつき最初の1件だけ

  Get-ChildItem -LiteralPath $Global:CommentRoot -Filter *.txt -File |
    Select-String @ss |
    ForEach-Object {
      $hash = [IO.Path]::GetFileNameWithoutExtension($_.Path).ToLowerInvariant()
      if ($Global:CommentIndex.ContainsKey($hash)) {
        [pscustomobject]@{
          Path  = $Global:CommentIndex[$hash]
          Match = if ($_.Matches) { $_.Matches[0].Value } else { $Query }
          Line  = $_.Line
        }
      }
    }
}

# ── 1) 出力に Comment 列を付けるデコレータ ─────────────────────────────
function Add-CommentColumn {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [System.IO.FileSystemInfo]$InputObject,

    # インデックス($Global:CommentIndex)に無いものは出力しない
    [switch]$OnlyWithComment,

    # インデックスに無くてもディスク確認して読む(インデックスが古い時用)
    [switch]$ScanIfMissing
  )
  begin {
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    $idx  = $Global:CommentIndex
  }
  process {
    $p     = $InputObject.FullName
    $canon = ConvertTo-CanonicalPath $p
    $hash  = Get-Md5HexLower $canon
    $hit   = $idx.ContainsKey($hash)

    if (-not $hit -and -not $ScanIfMissing) {
      if (-not $OnlyWithComment) {
        $InputObject | Add-Member -NotePropertyName Comment -NotePropertyValue $null -PassThru
      }
      return
    }

    $cfile = Get-CommentFilePathByHash $hash
    if (Test-Path $cfile) {
      $txt = [IO.File]::ReadAllText($cfile, $utf8)
      $InputObject | Add-Member -NotePropertyName Comment -NotePropertyValue $txt -PassThru
    } elseif (-not $OnlyWithComment) {
      $InputObject | Add-Member -NotePropertyName Comment -NotePropertyValue $null -PassThru
    }
  }
}

# 使い方(素直):
# Get-ChildItem -File | Add-CommentColumn | Format-Table Name,Length,Comment -Auto

# 便利エイリアス
Set-Alias cmt comment -Scope Global

function cmt-ls {
  [CmdletBinding()]
  param(
    [string]$Path='.'
  )
  Get-ChildItem -Path $Path  |
  Select-Object FullName, Length, LastWriteTime, @{n='Comment';e={(comment $_.FullName)}}
}
# ===== end of comment tools =====

コメント