param( [Parameter(Mandatory = $true, Position = 0)] [ValidateSet('export', 'import')] [string]$Action, [string]$JsonPath, [string]$XlsxPath, [string]$OutJsonPath ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.IO.Compression $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path if ([string]::IsNullOrWhiteSpace($JsonPath)) { $JsonPath = Join-Path $repoRoot 'data\cards.json' } if ([string]::IsNullOrWhiteSpace($XlsxPath)) { $XlsxPath = Join-Path $repoRoot 'data\cards.xlsx' } if ([string]::IsNullOrWhiteSpace($OutJsonPath)) { $OutJsonPath = $JsonPath } $utf8NoBom = [System.Text.UTF8Encoding]::new($false) function Escape-Xml([string]$Text) { if ($null -eq $Text) { return '' } return [System.Security.SecurityElement]::Escape($Text) } function Get-ColumnName([int]$Index) { $n = $Index $name = '' while ($n -gt 0) { $n-- $name = [char][int](65 + ($n % 26)) + $name $n = [math]::Floor($n / 26) } return $name } function Get-ColumnIndex([string]$Name) { $n = 0 foreach ($ch in $Name.ToCharArray()) { if ($ch -match '[A-Z]') { $n = $n * 26 + ([int][char]$ch - 64) } } return $n } function Get-CellRef([int]$Col, [int]$Row) { return (Get-ColumnName $Col) + $Row } function Has-MapKey($Map, $Key) { if ($null -eq $Map) { return $false } if ($null -eq $Key) { return $false } if ($Key -is [string] -and [string]::IsNullOrWhiteSpace($Key)) { return $false } foreach ($existingKey in $Map.Keys) { if ($existingKey -eq $Key) { return $true } } return $false } function Get-ScalarType($Value) { if ($null -eq $Value) { return 'null' } if ($Value -is [bool]) { return 'boolean' } if ($Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or $Value -is [int64] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return 'number' } if ($Value -is [string]) { return 'string' } return 'string' } function Get-CardSchema($Cards) { $schema = [ordered]@{} foreach ($cardEntry in $Cards.PSObject.Properties) { $card = $cardEntry.Value foreach ($prop in $card.PSObject.Properties) { $kind = Get-ScalarType $prop.Value if (-not (Has-MapKey $schema $prop.Name)) { $schema[$prop.Name] = $kind } elseif ($schema[$prop.Name] -ne $kind -and $kind -ne 'null') { $schema[$prop.Name] = 'string' } } } return $schema } function Get-ColumnWidth([string]$Header, [string]$Type) { switch ($Header) { 'id' { return 18 } 'name' { return 24 } 'desc' { return 48 } 'image' { return 36 } 'fx' { return 36 } 'kind' { return 12 } 'class' { return 12 } 'rarity' { return 12 } default { if ($Type -eq 'boolean') { return 10 } if ($Type -eq 'number') { return 12 } return 16 } } } function To-InvariantNumber($Value) { return [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0}', $Value) } function New-HeaderCellXml([string]$Ref, [string]$Text) { $escaped = Escape-Xml $Text return "$escaped" } function New-TextCellXml([string]$Ref, [string]$Text) { $escaped = Escape-Xml $Text return "$escaped" } function New-NumberCellXml([string]$Ref, $Value) { if ($null -eq $Value) { return $null } if ($Value -is [string] -and $Value -eq '') { return $null } return "$(To-InvariantNumber $Value)" } function New-BoolCellXml([string]$Ref, $Value) { if ($null -eq $Value) { return $null } if ($Value -is [string] -and $Value -eq '') { return $null } $bool = $false if ($Value -is [bool]) { $bool = $Value } else { $text = [string]$Value if ($text -match '^(?i:true|1|yes|y)$') { $bool = $true } elseif ($text -match '^(?i:false|0|no|n)$') { $bool = $false } else { return $null } } $n = if ($bool) { 1 } else { 0 } return "$n" } function New-CellXml([string]$Ref, $Value, [string]$Type) { switch ($Type) { 'number' { return New-NumberCellXml $Ref $Value } 'boolean' { return New-BoolCellXml $Ref $Value } default { if ($null -eq $Value) { return New-TextCellXml $Ref '' } return New-TextCellXml $Ref ([string]$Value) } } } function Get-WorksheetXml([string]$SheetName, [string[]]$Headers, [object[]]$Rows, [hashtable]$TypeMap) { $maxCol = $Headers.Count $lastCol = Get-ColumnName $maxCol $rowCount = $Rows.Count + 1 $colsXml = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $Headers.Count; $i++) { $header = $Headers[$i] $type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' } $width = Get-ColumnWidth $header $type $colsXml.Add("") } $rowsXml = New-Object System.Collections.Generic.List[string] $headerCells = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $Headers.Count; $i++) { $headerCells.Add((New-HeaderCellXml (Get-CellRef ($i + 1) 1) $Headers[$i])) } $rowsXml.Add("$($headerCells -join '')") for ($r = 0; $r -lt $Rows.Count; $r++) { $row = $Rows[$r] $cells = New-Object System.Collections.Generic.List[string] for ($c = 0; $c -lt $Headers.Count; $c++) { $header = $Headers[$c] $type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' } $value = $null if (Has-MapKey $row $header) { $value = $row[$header] } $cellXml = New-CellXml (Get-CellRef ($c + 1) ($r + 2)) $value $type if ($null -ne $cellXml) { $cells.Add($cellXml) } } $rowsXml.Add("$($cells -join '')") } $sheetView = '' $cols = '' + ($colsXml -join '') + '' $sheetData = '' + ($rowsXml -join '') + '' $autoFilter = "" return @" $sheetView $cols $sheetData $autoFilter "@ } function Get-StylesXml { return @" "@ } function Get-WorkbookXml([string[]]$SheetNames) { $sheetsXml = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $SheetNames.Count; $i++) { $sheetsXml.Add("") } return @" $($sheetsXml -join '') "@ } function Get-WorkbookRelsXml([int]$SheetCount) { $rels = New-Object System.Collections.Generic.List[string] for ($i = 1; $i -le $SheetCount; $i++) { $rels.Add("") } $rels.Add("") return @" $($rels -join '') "@ } function Get-RootRelsXml { return @" "@ } function Get-ContentTypesXml([int]$SheetCount) { $overrides = New-Object System.Collections.Generic.List[string] for ($i = 1; $i -le $SheetCount; $i++) { $overrides.Add("") } $overrides.Add('') $overrides.Add('') return @" $($overrides -join '') "@ } function Write-Xlsx([string]$Path, [hashtable]$Parts) { $dir = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } if (Test-Path $Path) { Remove-Item -LiteralPath $Path -Force } $file = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite) try { $zip = New-Object System.IO.Compression.ZipArchive($file, [System.IO.Compression.ZipArchiveMode]::Create, $false) try { foreach ($entryName in $Parts.Keys) { $entry = $zip.CreateEntry($entryName) $stream = $entry.Open() $writer = New-Object System.IO.StreamWriter($stream, $utf8NoBom) try { $writer.Write([string]$Parts[$entryName]) } finally { $writer.Dispose() $stream.Dispose() } } } finally { $zip.Dispose() } } finally { $file.Dispose() } } function Read-XlsxXml([string]$Path, [string]$EntryName) { $zip = [System.IO.Compression.ZipFile]::OpenRead($Path) try { $entry = $zip.GetEntry($EntryName) if ($null -eq $entry) { throw "Missing XLSX entry: $EntryName" } $stream = $entry.Open() try { $reader = New-Object System.IO.StreamReader($stream, $utf8NoBom) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { $stream.Dispose() } } finally { $zip.Dispose() } } function Read-SharedStrings([string]$Path) { try { $xmlText = Read-XlsxXml $Path 'xl/sharedStrings.xml' } catch { return @() } [xml]$xml = $xmlText $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main') $items = $xml.SelectNodes('/x:sst/x:si', $ns) $values = New-Object System.Collections.Generic.List[string] foreach ($item in $items) { $values.Add([string]$item.InnerText) } return $values.ToArray() } function Read-WorksheetRows([string]$XmlText, [string[]]$SharedStrings) { [xml]$xml = $XmlText $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main') $rows = $xml.SelectNodes('/x:worksheet/x:sheetData/x:row', $ns) $parsed = @() foreach ($row in $rows) { $cells = @{} foreach ($cell in @($row.ChildNodes)) { if ($cell.Name -ne 'c') { continue } $ref = [string]$cell.Attributes['r'].Value $col = Get-ColumnIndex (($ref -replace '\d+$', '')) $type = [string]$cell.Attributes['t'].Value $text = [string]$cell.InnerText if ($type -eq 's' -and $text -match '^\d+$') { $index = [int]$text if ($index -ge 0 -and $index -lt $SharedStrings.Count) { $text = [string]$SharedStrings[$index] } } $cells[$col] = $text } $parsed += ,$cells } return $parsed } function Convert-CellValue([string]$Text, [string]$Type) { if ($null -eq $Text -or $Text -eq '') { return $null } switch ($Type) { 'number' { $num = 0 if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$num)) { if ([math]::Abs($num - [math]::Round($num)) -lt 0.0000001) { return [int64][math]::Round($num) } return $num } return $null } 'boolean' { if ($Text -match '^(?i:true|1|yes|y)$') { return $true } if ($Text -match '^(?i:false|0|no|n)$') { return $false } return $null } default { return $Text } } } function Export-Cards { $source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json $schema = Get-CardSchema $source.cards $cardCore = @('id', 'name', 'cost', 'kind', 'rarity', 'class', 'desc', 'image', 'fx') $cardExtras = @($schema.Keys | Where-Object { $_ -notin $cardCore } | Sort-Object) $cardHeaders = @($cardCore + $cardExtras) $maxDeckSize = 0 foreach ($deckEntry in $source.starterDecks.PSObject.Properties) { $deckSize = @($deckEntry.Value).Count if ($deckSize -gt $maxDeckSize) { $maxDeckSize = $deckSize } } if ($maxDeckSize -lt 1) { $maxDeckSize = 1 } $starterDeckHeaders = New-Object System.Collections.Generic.List[string] $starterDeckHeaders.Add('class') for ($i = 1; $i -le $maxDeckSize; $i++) { $starterDeckHeaders.Add("slot$i") } $cardRows = New-Object System.Collections.Generic.List[object] foreach ($cardEntry in $source.cards.PSObject.Properties) { $cardId = $cardEntry.Name $card = $cardEntry.Value $row = [ordered]@{ id = $cardId } foreach ($header in $cardHeaders) { if ($header -eq 'id') { continue } if ($card.PSObject.Properties.Name -contains $header) { $row[$header] = $card.$header } else { $row[$header] = $null } } $cardRows.Add($row) } $deckRows = New-Object System.Collections.Generic.List[object] foreach ($deckEntry in $source.starterDecks.PSObject.Properties) { $cls = $deckEntry.Name $deck = @($deckEntry.Value) $row = [ordered]@{ class = $cls } for ($i = 1; $i -le $maxDeckSize; $i++) { $key = "slot$i" $row[$key] = if ($i -le $deck.Count) { $deck[$i - 1] } else { $null } } $deckRows.Add($row) } $cardSheet = Get-WorksheetXml 'Cards' $cardHeaders $cardRows $schema $deckTypeMap = [ordered]@{ class = 'string' } for ($i = 1; $i -le $maxDeckSize; $i++) { $deckTypeMap["slot$i"] = 'string' } $deckSheet = Get-WorksheetXml 'StarterDecks' $starterDeckHeaders $deckRows $deckTypeMap $parts = [ordered]@{ '[Content_Types].xml' = (Get-ContentTypesXml 2) '_rels/.rels' = (Get-RootRelsXml) 'xl/workbook.xml' = (Get-WorkbookXml @('Cards', 'StarterDecks')) 'xl/_rels/workbook.xml.rels' = (Get-WorkbookRelsXml 2) 'xl/styles.xml' = (Get-StylesXml) 'xl/worksheets/sheet1.xml' = $cardSheet 'xl/worksheets/sheet2.xml' = $deckSheet } Write-Host "Source JSON: $JsonPath" Write-Host "Target XLSX: $XlsxPath" Write-Xlsx $XlsxPath $parts Write-Host "Excel export complete: $XlsxPath" } function Import-Cards { $source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json $schema = Get-CardSchema $source.cards $origCardOrders = @{} foreach ($cardEntry in $source.cards.PSObject.Properties) { $origCardOrders[$cardEntry.Name] = @($cardEntry.Value.PSObject.Properties.Name) } $origDeckOrder = @($source.starterDecks.PSObject.Properties.Name) $sharedStrings = Read-SharedStrings $XlsxPath $cardsXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet1.xml' $deckXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet2.xml' $cardRowsRaw = Read-WorksheetRows $cardsXml $sharedStrings $deckRowsRaw = Read-WorksheetRows $deckXml $sharedStrings if ($cardRowsRaw.Count -lt 2) { throw 'Cards sheet has no data rows.' } if ($deckRowsRaw.Count -lt 2) { throw 'StarterDecks sheet has no data rows.' } $cardHeaderMap = $cardRowsRaw[0] $cardHeaders = @($cardHeaderMap.Keys | Sort-Object) $orderedCardHeaders = New-Object System.Collections.Generic.List[string] foreach ($col in $cardHeaders) { $header = $cardHeaderMap[$col] if ([string]::IsNullOrWhiteSpace($header)) { continue } $orderedCardHeaders.Add($header) } $newCards = [ordered]@{} for ($r = 1; $r -lt $cardRowsRaw.Count; $r++) { $row = $cardRowsRaw[$r] $cardId = $null $rowValues = @{} for ($c = 0; $c -lt $orderedCardHeaders.Count; $c++) { $header = $orderedCardHeaders[$c] $text = $null if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] } if ($header -eq 'id') { $cardId = [string]$text continue } $rowValues[$header] = $text } if (-not [string]::IsNullOrWhiteSpace($cardId)) { $cardObj = [ordered]@{} $fieldOrder = New-Object System.Collections.Generic.List[string] if ($origCardOrders.ContainsKey($cardId)) { foreach ($name in @($origCardOrders[$cardId])) { if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) { $fieldOrder.Add($name) } } } foreach ($name in $orderedCardHeaders) { if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) { $fieldOrder.Add($name) } } foreach ($header in $fieldOrder) { $text = $null if (Has-MapKey $rowValues $header) { $text = $rowValues[$header] } $type = if (Has-MapKey $schema $header) { [string]$schema[$header] } else { 'string' } $value = Convert-CellValue $text $type if ($null -eq $value) { continue } $cardObj[$header] = $value } $newCards[$cardId] = $cardObj } } $deckHeaderMap = $deckRowsRaw[0] $deckHeaderCols = @($deckHeaderMap.Keys | Sort-Object) $orderedDeckHeaders = New-Object System.Collections.Generic.List[string] foreach ($col in $deckHeaderCols) { $header = $deckHeaderMap[$col] if ([string]::IsNullOrWhiteSpace($header)) { continue } $orderedDeckHeaders.Add($header) } $newDecks = [ordered]@{} for ($r = 1; $r -lt $deckRowsRaw.Count; $r++) { $row = $deckRowsRaw[$r] $cls = $null $deckValues = @{} for ($c = 0; $c -lt $orderedDeckHeaders.Count; $c++) { $header = $orderedDeckHeaders[$c] $text = $null if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] } if ($header -eq 'class') { $cls = [string]$text continue } $deckValues[$header] = $text } if (-not [string]::IsNullOrWhiteSpace($cls)) { $deck = New-Object System.Collections.Generic.List[string] foreach ($header in $orderedDeckHeaders) { if ($header -eq 'class') { continue } $text = $null if (Has-MapKey $deckValues $header) { $text = $deckValues[$header] } if (-not [string]::IsNullOrWhiteSpace([string]$text)) { $deck.Add([string]$text) } } $newDecks[$cls] = $deck.ToArray() } } if ($origDeckOrder.Count -gt 0) { $orderedDecks = [ordered]@{} foreach ($cls in $origDeckOrder) { if (Has-MapKey $newDecks $cls) { $orderedDecks[$cls] = $newDecks[$cls] } } foreach ($entry in $newDecks.GetEnumerator()) { if (-not (Has-MapKey $orderedDecks $entry.Key)) { $orderedDecks[$entry.Key] = $entry.Value } } $newDecks = $orderedDecks } $out = [ordered]@{ cards = $newCards starterDecks = $newDecks } $json = $out | ConvertTo-Json -Depth 64 Write-Host "Source XLSX: $XlsxPath" Write-Host "Target JSON: $OutJsonPath" [System.IO.File]::WriteAllText($OutJsonPath, $json, $utf8NoBom) Write-Host "JSON import complete: $OutJsonPath" } switch ($Action) { 'export' { Export-Cards } 'import' { Import-Cards } }