Private/ConvertFrom-WingetTable.ps1
|
function ConvertFrom-WingetTable { <# .SYNOPSIS Parses winget's console result table into {Name, Id, Version, Source} objects by header-derived COLUMN OFFSETS — locale-independent and safe for content-width columns. .DESCRIPTION winget sizes every column to its longest cell and separates columns with a SINGLE space. The old "split on 2+ spaces" heuristic therefore failed whenever a cell was the widest in its column — and for a single-row result (the -Id fast path) EVERY cell is, so the whole row collapsed into 2 parts and was dropped. Resolve-Win32ToolkitWingetId then fell back to Name=<Id> and Version='Unknown' ("Selected: Git.Git vUnknown", project named *_Unknown) even though winget printed the version. Column LABELS are localized (Name/ID/Version/Quelle, ...) but their ORDER is stable: Name, Id, Version, [Match], Source. So the parser: 1. finds the dash separator line and takes the line above it as the header; 2. uses the header tokens' character offsets as column starts; 3. slices each data row at those offsets (a Name containing spaces stays intact); 4. keeps a row only when the sliced Id is non-empty and contains no whitespace — which also rejects localized footer sentences without matching any localized text. Source is the LAST column when one exists (a fuzzy search inserts a Match column before it); rows on tables without a Source column default to 'winget'. When no separator/header is present (error text, "no package found" messages), it falls back to the old 2+-space split, which yields no rows for such output. KNOWN LIMIT (pre-existing): winget aligns by DISPLAY width, so a row whose Name holds double-width glyphs (e.g. CJK) can slice one cell off. Values truncated by winget keep their trailing ellipsis. .PARAMETER Text The raw console text of a winget table command (e.g. `winget search ... | Out-String`). .OUTPUTS [pscustomobject] { Name; Id; Version; Source } per data row (possibly none). #> [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowEmptyString()] [AllowNull()] [string]$Text ) $apps = @() if ([string]::IsNullOrWhiteSpace($Text)) { return $apps } # Keep raw lines — leading positions matter for offset slicing. Only drop blank ones later. $lines = $Text -split "`r?`n" # Locate the dash separator; the line directly above it is the (localized) header. $sepIndex = -1 for ($i = 1; $i -lt $lines.Count; $i++) { if ($lines[$i].Trim() -match '^-{2,}$') { $sepIndex = $i; break } } if ($sepIndex -ge 1 -and -not [string]::IsNullOrWhiteSpace($lines[$sepIndex - 1])) { # ── Primary: slice data rows at the header tokens' offsets ──────────────────────────────── $starts = @([regex]::Matches($lines[$sepIndex - 1], '\S+') | ForEach-Object { $_.Index }) if ($starts.Count -ge 3) { for ($i = $sepIndex + 1; $i -lt $lines.Count; $i++) { $line = $lines[$i] if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($line.Trim() -match '^-{2,}$') { continue } $vals = for ($k = 0; $k -lt $starts.Count; $k++) { $s = $starts[$k] $e = if ($k + 1 -lt $starts.Count) { [Math]::Min($starts[$k + 1], $line.Length) } else { $line.Length } if ($s -ge $line.Length) { '' } else { $line.Substring($s, $e - $s).Trim() } } # A real row has a non-empty, whitespace-free Id; localized footer sentences # ("More than N results...", "N Übereinstimmungen...") slice to spaced/empty text. $id = $vals[1] if (-not $id -or $id -match '\s') { continue } $apps += [PSCustomObject]@{ Name = $vals[0] Id = $id Version = $vals[2] Source = if ($starts.Count -gt 3 -and $vals[$starts.Count - 1]) { $vals[$starts.Count - 1] } else { 'winget' } } } return $apps } } # ── Fallback (no separator/header found): the old 2+-space split. Yields nothing for plain # message output ("No package found ..."), which is the correct empty result. ─────────────── foreach ($raw in $lines) { $line = $raw.Trim() if ($line -and $line -notmatch '^[-\s]+$' -and $line -notmatch '^\d+\s+matches?\s+found' -and $line -notmatch '^More\s+than' -and $line -notmatch '^Name\s+Id\s+') { $parts = $line -split '\s{2,}' if ($parts.Count -ge 3) { $apps += [PSCustomObject]@{ Name = $parts[0].Trim() Id = $parts[1].Trim() Version = $parts[2].Trim() Source = if ($parts.Count -gt 3) { $parts[$parts.Count - 1].Trim() } else { 'winget' } } } } } return $apps } |