汎用CLP表→CSV化

<# 
Reflow CLP-style fixed-width table into TSV with lookahead continuation.

仕様(重要な変更点):
- 列境界: 最初に出る "-----" 罫線からダッシュの塊で列範囲を決定(固定幅スライス)
- 先読み束ね: 物理行 i が「日付セル = 時刻」にマッチしたら新規レコード開始。
              以降、次行 j を“先読み”し、j が日付で始まらない限り、テキスト列へ連結し続ける。
- 継続行の取り方(スマート):
    1) テキスト列の範囲スライス
    2) 空なら テキスト列の開始位置から行末まで(列幅越え対策)
    3) まだ空なら その行の最長非空セル
    4) それでも空なら 行全体の Trim
- 改行: CRLF / LF / CR / NEL(0x85) / LS(0x2028) / PS(0x2029) を LF に正規化
- 出力: TSV(タブ区切り)。PS7+ なら UseQuotes Always 指定
#>

param(
  [Parameter(Position=0)]
  [string]$InPath  = (Join-Path $PSScriptRoot 'clp_output.txt'),

  [Parameter(Position=1)]
  [string]$OutPath = (Join-Path $PSScriptRoot 'reflow.tsv'),

  # Db2 っぽい時刻/日付。必要に応じて調整。
  [string]$TimeRegex = '^(?:\d{4}-\d{2}-\d{2}[-\s]?\d{2}[:\.]\d{2}[:\.]\d{2}(?:\.\d{1,6})?|\d{8}|\d{2}:\d{2}:\d{2}|\d{14})$',

  # どの列を「時刻列」として見るか(0始まり)。既定=先頭列
  [int]$DateColumnIndex = 0,

  # どの列に継続を連結するか(テキスト列)
  [string]$TextColumnName = '',
  [int]$TextColumnIndex = -1,

  # 連結時の区切り
  [ValidateSet('space','newline','literal-n')]
  [string]$JoinMode = 'space',

  # 空行も区切りだけ入れて保持するか
  [bool]$KeepBlankContinuation = $true
)

# ----- Join token -----
switch ($JoinMode) {
  'space'     { $JoinToken = ' ' }
  'newline'   { $JoinToken = "`n" }
  'literal-n' { $JoinToken = '\n' }
}

# ----- 入力 & 改行正規化 -----
if (-not (Test-Path -LiteralPath $InPath)) {
  $alt = Join-Path (Get-Location) (Split-Path $InPath -Leaf)
  if (Test-Path -LiteralPath $alt) { $InPath = $alt }
}
if (-not (Test-Path -LiteralPath $InPath)) { throw "Input file not found: $InPath" }

$raw = Get-Content -LiteralPath $InPath -Raw
$raw = $raw -replace "`r`n", "`n"                        # CRLF -> LF
$raw = $raw -replace "`r",   "`n"                        # CR   -> LF
$raw = $raw -replace ([string][char]0x0085), "`n"       # NEL  -> LF
$raw = $raw -replace ([string][char]0x2028), "`n"       # LS   -> LF
$raw = $raw -replace ([string][char]0x2029), "`n"       # PS   -> LF

$lines = $raw -split "`n"
if (-not $lines -or $lines.Count -eq 0) { throw "No input lines found." }

# ----- Helpers -----
function IsSepLine([string]$s){
  if ([string]::IsNullOrWhiteSpace($s)) { return $false }
  return [bool]([regex]::IsMatch($s, '^(?=.*-{3,})[ \t\-\+\|]+$'))
}
function Get-ColRanges([string]$sep){
  $ranges = @(); $chars = $sep.ToCharArray(); $i = 0
  while ($i -lt $chars.Length){
    if ($chars[$i] -eq '-') {
      $start = $i
      while ($i -lt $chars.Length -and $chars[$i] -eq '-') { $i++ }
      $end = $i - 1
      $ranges += ,@($start, $end)
    } else { $i++ }
  }
  return $ranges
}
function Slice-Trim([string]$line, [int]$start, [int]$end){
  if ($null -eq $line) { return "" }
  $len = [math]::Max(0, [math]::Min($line.Length, $end+1) - $start)
  if ($len -le 0 -or $start -ge $line.Length) { return "" }
  return $line.Substring($start, $len).Trim()
}
function Slice-From([string]$line, [int]$start){
  if ($null -eq $line) { return "" }
  if ($start -ge $line.Length) { return "" }
  return $line.Substring($start).Trim()
}
function Test-IsTime([string]$s, [string]$pattern){
  if ([string]::IsNullOrWhiteSpace($s)) { return $false }
  return [bool]([regex]::IsMatch($s, $pattern))
}

# ----- ヘッダ/罫線の検出 -----
$sepIdx = -1
for ($i=0; $i -lt $lines.Count; $i++){
  if (IsSepLine $lines[$i]) { $sepIdx = $i; break }
}
if ($sepIdx -lt 1) { throw "Header separator (-----) not found." }

$headerLine = $lines[$sepIdx - 1]
$sepLine    = $lines[$sepIdx]

# ----- 列範囲 & ヘッダ -----
$ranges = Get-ColRanges $sepLine
if (-not $ranges -or $ranges.Count -lt 2) { throw "Failed to detect column ranges." }

$headers = @()
foreach($r in $ranges){ $headers += (Slice-Trim $headerLine $r[0] $r[1]) }

# ヘッダ正規化
$seen = @{}
for($i=0;$i -lt $headers.Count;$i++){
  if ([string]::IsNullOrWhiteSpace($headers[$i])) { $headers[$i] = "Col$($i+1)" }
  if ($seen.ContainsKey($headers[$i])) {
    $n = 2; while ($seen.ContainsKey("$($headers[$i])_$n")) { $n++ }
    $headers[$i] = "$($headers[$i])_$n"
  }
  $seen[$headers[$i]] = $true
}

# インデックス決定
if ($DateColumnIndex -lt 0 -or $DateColumnIndex -ge $headers.Count) { $DateColumnIndex = 0 }

$TextCol = -1
if ($TextColumnIndex -ge 0 -and $TextColumnIndex -lt $headers.Count) {
  $TextCol = $TextColumnIndex
} elseif (-not [string]::IsNullOrWhiteSpace($TextColumnName)) {
  for($i=0;$i -lt $headers.Count;$i++){ if ($headers[$i] -ieq $TextColumnName){ $TextCol = $i; break } }
}
if ($TextCol -lt 0) {
  for($i=0;$i -lt $headers.Count;$i++){
    if ($headers[$i] -match '(?i)^(STMT_TEXT|SQL_TEXT|DYNAMIC_SQL_TEXT|TEXT|SQL|STATEMENT)$'){ $TextCol = $i; break }
  }
}
if ($TextCol -lt 0) { $TextCol = $headers.Count - 1 }  # 最終列をテキスト列に

# テキスト列のスライス開始位置をキャッシュ
$rText = $ranges[$TextCol]
$txtStart = $rText[0]; $txtEnd = $rText[1]

# ----- データ部(先読みで束ね) -----
$dataLines = @()
for($i = $sepIdx + 1; $i -lt $lines.Count; $i++){ $dataLines += $lines[$i] }

# ライン属性を先に評価(isDate / isSep / isBlank)
$attrs = @()
for($i=0; $i -lt $dataLines.Count; $i++){
  $ln = $dataLines[$i]
  $isBlank = [string]::IsNullOrWhiteSpace($ln)
  $isSep   = (-not $isBlank) -and (IsSepLine $ln)
  $isDate  = $false
  if (-not $isBlank -and -not $isSep) {
    # 「現在の行」の日付セルで判定
    $cellsTmp = @()
    foreach($r in $ranges){ $cellsTmp += (Slice-Trim $ln $r[0] $r[1]) }
    $dateCell = $cellsTmp[$DateColumnIndex]
    $isDate   = Test-IsTime $dateCell $TimeRegex
  }
  $attrs += ,@($isBlank, $isSep, $isDate)
}

$rows = New-Object System.Collections.Generic.List[Object]

for ($i=0; $i -lt $dataLines.Count; $i++) {
  $ln = $dataLines[$i]
  $isBlank = $attrs[$i][0]; $isSep = $attrs[$i][1]; $isDate = $attrs[$i][2]

  if ($isBlank -or $isSep) { continue }

  # ---- 新規レコードは「現在が日付行」だけで開始し、その後は“先読み”で連結 ----
  if ($isDate) {
    # 行 i を固定幅スライスして初期レコード作成
    $cells = @()
    foreach($r in $ranges){ $cells += (Slice-Trim $ln $r[0] $r[1]) }
    $obj = [ordered]@{}
    for($c=0; $c -lt $headers.Count; $c++){ $obj[$headers[$c]] = $cells[$c] }

    # 先読み:i+1 以降、次の「日付行 or 罫線 or 空行」直前までをテキスト列に連結
    $j = $i + 1
    while ($j -lt $dataLines.Count) {
      $n_isBlank = $attrs[$j][0]; $n_isSep = $attrs[$j][1]; $n_isDate = $attrs[$j][2]
      if ($n_isDate) { break }           # 次が日付行 → ここで束ね終了
      if ($n_isSep)  { $j++; continue }  # 余計な罫線はスキップ
      if ($n_isBlank){
        if ($KeepBlankContinuation) { $obj[$headers[$TextCol]] = ($obj[$headers[$TextCol]] + $JoinToken) }
        $j++; continue
      }

      $nLine = $dataLines[$j]

      # 継続行の“スマート掴み”
      $frag = Slice-Trim $nLine $txtStart $txtEnd           # 1) テキスト列範囲
      if ([string]::IsNullOrWhiteSpace($frag)) {
        $frag = Slice-From $nLine $txtStart                  # 2) テキスト開始から行末
      }
      if ([string]::IsNullOrWhiteSpace($frag)) {
        # 3) 行内で最長の非空セル
        $cellsN = @(); foreach($r in $ranges){ $cellsN += (Slice-Trim $nLine $r[0] $r[1]) }
        $longest = ""; foreach($cc in $cellsN){ if ($null -ne $cc -and $cc.Trim().Length -gt $longest.Length){ $longest = $cc.Trim() } }
        $frag = $longest
      }
      if ([string]::IsNullOrWhiteSpace($frag)) {
        # 4) 行全体
        $t = $nLine.Trim(); if ($t -ne "") { $frag = $t }
      }

      if (-not [string]::IsNullOrWhiteSpace($frag)) {
        $obj[$headers[$TextCol]] = ( @($obj[$headers[$TextCol]], $frag) -join $JoinToken ).Trim()
      } else {
        if ($KeepBlankContinuation) { $obj[$headers[$TextCol]] = ($obj[$headers[$TextCol]] + $JoinToken) }
      }

      $j++
    }

    # 完成レコードを追加
    $rows.Add([pscustomobject]$obj) | Out-Null

    # 先読みで消費したぶん i を進める
    $i = $j - 1
  }
  else {
    # 日付行以前の前置きやノイズは無視(先頭が日付行になるまでスキップ)
    continue
  }
}

if ($rows.Count -eq 0) { throw "No data rows (reflow failed)." }

# ----- TSV 出力 -----
try {
  $parent = Split-Path -Parent $OutPath
  if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }

  $quoteParam = @{}
  if ($PSVersionTable.PSVersion.Major -ge 7) { $quoteParam.UseQuotes = 'Always' }

  $rows | Export-Csv -LiteralPath $OutPath -Delimiter "`t" -NoTypeInformation -Encoding UTF8 -Force -ErrorAction Stop @quoteParam
  Write-Host ("DONE: {0}" -f (Resolve-Path $OutPath))
}
catch {
  Write-Warning ("Export-Csv failed. Fallback writer: {0}" -f $_.Exception.Message)
  if ($PSVersionTable.PSVersion.Major -ge 7) {
    $csv = $rows | ConvertTo-Csv -NoTypeInformation -Delimiter "`t" -UseQuotes Always
  } else {
    $csv = $rows | ConvertTo-Csv -NoTypeInformation -Delimiter "`t"
  }
  $utf8bom = New-Object System.Text.UTF8Encoding($true)
  [System.IO.File]::WriteAllLines($OutPath, $csv, $utf8bom)
  Write-Host ("DONE (fallback): {0}" -f (Resolve-Path $OutPath))
}