diff --git a/cards_to_excel.bat b/cards_to_excel.bat new file mode 100644 index 0000000..7e92401 --- /dev/null +++ b/cards_to_excel.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" export +echo. +echo Press any key to close this window. +pause >nul diff --git a/data/cards.xlsx b/data/cards.xlsx new file mode 100644 index 0000000..57de5bb Binary files /dev/null and b/data/cards.xlsx differ diff --git a/excel_to_cards.bat b/excel_to_cards.bat new file mode 100644 index 0000000..85fdbe0 --- /dev/null +++ b/excel_to_cards.bat @@ -0,0 +1,7 @@ +@echo off +setlocal +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" import +echo. +echo Press any key to close this window. +pause >nul diff --git a/tools/cards/cards_excel.ps1 b/tools/cards/cards_excel.ps1 new file mode 100644 index 0000000..e70d47e --- /dev/null +++ b/tools/cards/cards_excel.ps1 @@ -0,0 +1,629 @@ +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 } +}