<#
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))
}