lib/submit.ps1

# submit.ps1 -- `tstyles submit`: offering a user style for the catalogue.
#
# The user's half only checks and packs. Nothing here talks to GitHub: the
# command writes a zip, opens the "Style submission" issue form, and says
# what is left to do. The maintainer's half is scripts/review-submission.ps1,
# which reads the same rules from here (Get-StyleSubmissionProblems,
# Test-StylePromptIsGenerated), so the check the user ran and the check the
# maintainer runs are one check.

$script:TStylesSubmitRepo = 'fcreme/TerminalStyles'

function Get-StyleMetaLimits {
    # The picker row's caps on a style's meta.json, in characters. One place:
    # Style-Meta.Tests.ps1 holds the bundled styles to it and the submission
    # check holds a submitted one to it.
    @{ Description = 90; Quote = 70 }
}

function Get-StyleSubmissionFileNames {
    # The files a submission carries, besides one background.<ext> of a type
    # Get-BackgroundImageExtensions names. The zip and the review's unpack
    # both take exactly these. Not README.md: a style folder may hold one and
    # it is left out of the zip, because the review writes the catalogue's
    # own README, whose Includes list CI holds to the folder.
    @('scheme.json', 'theme.json', 'profile.ps1', 'prompt.sh', 'meta.json')
}

function Get-SubmittedThemeProblems {
    <#
    .SYNOPSIS
    Why a submitted theme.json could not ship, as plain sentences.

    .DESCRIPTION
    Merge-StyleIntoSettings copies every theme.json key into the user's
    Windows Terminal profile as it is, so a key the catalogue does not use
    is not decoration: "commandline" would run a program for everyone who
    applies the style, and an "icon" or a backgroundImage on a network path
    would make Windows Terminal fetch it. So the check is an allow-list --
    the keys every bundled style and New-CreatedThemeObject write, with the
    value shapes Windows Terminal takes for them -- and backgroundImage
    must be the {{BACKGROUND_IMAGE}} placeholder.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([Parameter(Mandatory)]$Theme, [Parameter(Mandatory)][string]$Name)

    $p = New-Object System.Collections.Generic.List[string]
    $isText = { param($v, [int]$max) ($v -is [string]) -and $v.Length -le $max -and ($v -notmatch '[\x00-\x1F\x7F]') }
    $isNum = { param($v) ($v -is [int]) -or ($v -is [long]) -or ($v -is [double]) -or ($v -is [decimal]) }
    $checks = [ordered]@{
        colorScheme                        = { param($v) ($v -is [string]) -and ($v -ceq $Name) }
        tabTitle                           = { param($v) & $isText $v 64 }
        tabColor                           = { param($v) ($v -is [string]) -and ($v -match '^#[0-9a-fA-F]{6}$') }
        cursorShape                        = { param($v) @('bar', 'vintage', 'underscore', 'filledBox', 'emptyBox', 'doubleUnderscore') -ccontains $v }
        useAcrylic                         = { param($v) $v -is [bool] }
        opacity                            = { param($v) (& $isNum $v) -and $v -ge 0 -and $v -le 100 }
        'experimental.retroTerminalEffect' = { param($v) $v -is [bool] }
        font                               = { param($v) $null -ne $v -and $v -isnot [string] -and $v -isnot [bool] -and -not (& $isNum $v) }
        padding                            = { param($v) ($v -is [string]) -and ($v -match '^\d{1,3}(, ?\d{1,3}){0,3}$') }
        backgroundImage                    = { param($v) $v -ceq '{{BACKGROUND_IMAGE}}' }
        backgroundImageOpacity             = { param($v) (& $isNum $v) -and $v -ge 0 -and $v -le 1 }
        backgroundImageStretchMode         = { param($v) @('none', 'fill', 'uniform', 'uniformToFill') -ccontains $v }
        backgroundImageAlignment           = { param($v) @('center', 'left', 'top', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight') -ccontains $v }
    }
    foreach ($prop in @($Theme.PSObject.Properties)) {
        if (-not $checks.Contains($prop.Name)) {
            $p.Add("theme.json's '$($prop.Name)' cannot be submitted: a catalogue style sets only $(@($checks.Keys) -join ', ').")
            continue
        }
        if (-not (& $checks[$prop.Name] $prop.Value)) {
            $want = if ($prop.Name -eq 'colorScheme') { " (it must be '$Name')" } elseif ($prop.Name -eq 'backgroundImage') { ' (it must be {{BACKGROUND_IMAGE}})' } else { '' }
            $p.Add("theme.json's '$($prop.Name)' is not a value Windows Terminal takes there$want.")
        }
    }
    if ($Theme.PSObject.Properties['font'] -and (& $checks['font'] $Theme.font)) {
        $weights = 'thin', 'extra-light', 'light', 'semi-light', 'normal', 'medium', 'semi-bold', 'bold', 'extra-bold', 'black', 'extra-black'
        foreach ($fp in @($Theme.font.PSObject.Properties)) {
            $ok = switch ($fp.Name) {
                'face'   { & $isText $fp.Value 64 }
                'size'   { (& $isNum $fp.Value) -and $fp.Value -ge 4 -and $fp.Value -le 72 }
                'weight' { $weights -ccontains $fp.Value }
                default  { $false }
            }
            if (-not $ok) { $p.Add("theme.json's font '$($fp.Name)' is not one a catalogue style sets, or not a value Windows Terminal takes there.") }
        }
    }
    $p.ToArray()
}

function Test-ImageFileSignature {
    # Do a file's first bytes say it is the image type its extension names?
    # A .gif must start GIF8, a .png with the PNG signature, a .jpg/.jpeg
    # with FF D8 FF.
    param([Parameter(Mandatory)][string]$Path)
    $ext = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
    $head = New-Object byte[] 8
    $n = 0
    $fs = [System.IO.File]::OpenRead($Path)
    try { $n = $fs.Read($head, 0, 8) } finally { $fs.Dispose() }
    switch ($ext) {
        '.gif'  { return $n -ge 4 -and $head[0] -eq 0x47 -and $head[1] -eq 0x49 -and $head[2] -eq 0x46 -and $head[3] -eq 0x38 }
        '.png'  { return $n -ge 8 -and (@(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) -join ',') -eq (@($head[0..7]) -join ',') }
        { $_ -in '.jpg', '.jpeg' } { return $n -ge 3 -and $head[0] -eq 0xFF -and $head[1] -eq 0xD8 -and $head[2] -eq 0xFF }
    }
    $false
}

function Get-SchemeCatalogueContrastProblems {
    <#
    .SYNOPSIS
    The catalogue's contrast floors, as the slots that miss them.

    .DESCRIPTION
    Every chromatic ANSI slot needs MORE than 3.0:1 on background (WCAG
    1.4.11's floor for accent colour); foreground needs more than 4.5:1 on
    selectionBackground, because selected text is body text. black, white
    and their brights are exempt by design. Scheme-Contrast.Tests.ps1 holds
    every bundled scheme to this function and Get-StyleSubmissionProblems
    holds a submitted one to it, so the two cannot disagree.

    Returns one record per miss: Slot, Color, Against ('background' or
    'selectionBackground'), AgainstColor, Ratio, Floor. -Exempt names
    slots to skip (umbrella's documented red). A slot that is missing or
    does not parse is skipped; the key-set check reports those.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Scheme, [string[]]$Exempt = @())

    $out = New-Object System.Collections.Generic.List[object]
    $bg = ConvertTo-NormalHex $Scheme.background
    if ($bg) {
        foreach ($k in 'red', 'green', 'yellow', 'blue', 'purple', 'cyan',
                       'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan') {
            if ($Exempt -contains $k) { continue }
            $hex = ConvertTo-NormalHex $Scheme.$k
            if (-not $hex) { continue }
            $ratio = Get-ContrastRatio $hex $bg
            if ($ratio -le 3.0) {
                $out.Add([pscustomobject]@{ Slot = $k; Color = $hex; Against = 'background'; AgainstColor = $bg; Ratio = $ratio; Floor = 3.0 })
            }
        }
    }
    $sel = ConvertTo-NormalHex $Scheme.selectionBackground
    $fg = ConvertTo-NormalHex $Scheme.foreground
    if ($sel -and $fg -and ($Exempt -notcontains 'foreground')) {
        $ratio = Get-ContrastRatio $fg $sel
        if ($ratio -le 4.5) {
            $out.Add([pscustomobject]@{ Slot = 'foreground'; Color = $fg; Against = 'selectionBackground'; AgainstColor = $sel; Ratio = $ratio; Floor = 4.5 })
        }
    }
    # Callers wrap in @(): a bare return unrolls to nothing when empty.
    $out.ToArray()
}

function Get-StyleSubmissionProblems {
    <#
    .SYNOPSIS
    Why a style folder could not join the catalogue, as plain sentences.

    .DESCRIPTION
    Empty when the folder passes. A pre-flight, not the gate: the PR that
    adds a style runs the whole suite, which stays the final word. But a
    test holds every bundled style to this function, so it is never
    stricter than the catalogue it feeds.

    -Name defaults to the folder's own name. -CatalogueRoot is the styles
    folder whose names are taken (default: this module's styles\); a folder
    there with this name is a collision unless it IS -StyleDir, which is how
    a bootstrap install, where the two roots are one, still works.
    -ContrastExempt passes through to Get-SchemeCatalogueContrastProblems.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$StyleDir,
        [string]$Name,
        [string]$CatalogueRoot,
        [string[]]$ContrastExempt = @()
    )

    if (-not $Name) { $Name = [System.IO.Path]::GetFileName($StyleDir.TrimEnd('\', '/')) }
    if (-not $CatalogueRoot) { $CatalogueRoot = Join-Path $script:TStylesModuleRoot 'styles' }
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    $p = New-Object System.Collections.Generic.List[string]

    if (-not (Test-StyleNameValid -Name $Name)) {
        $p.Add("'$Name' is not a name the catalogue can use. $(Get-StyleNameRuleText)")
    } else {
        $taken = Join-Path $CatalogueRoot $Name
        if ([System.IO.Directory]::Exists($taken)) {
            $same = $false
            try {
                $same = [System.IO.Path]::GetFullPath($taken).TrimEnd('\', '/') -eq
                        [System.IO.Path]::GetFullPath($StyleDir).TrimEnd('\', '/')
            } catch { $same = $false }
            if (-not $same) { $p.Add("The catalogue already has a style called '$Name'. Make yours again under a name of its own with: tstyles create") }
        }
    }

    # Which files. Anything else -- a folder, a second image, a .webp -- is
    # named, because the zip and the review take only these.
    $allowed = @(Get-StyleSubmissionFileNames)
    $imageExts = @(Get-BackgroundImageExtensions)
    $images = New-Object System.Collections.Generic.List[string]
    foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($StyleDir)) {
        $leaf = [System.IO.Path]::GetFileName($entry)
        if ([System.IO.Directory]::Exists($entry)) {
            $p.Add("The folder '$leaf' cannot be submitted: a style is only files.")
            continue
        }
        if ($allowed -ccontains $leaf) { continue }
        # Left out of the zip, not a problem: the review writes the README.
        if ($leaf -ceq 'README.md') { continue }
        if ($leaf -ceq 'tune.json') {
            $p.Add("'$Name' was saved by tstyles tune and builds on the style it was tuned from; the catalogue takes styles made whole, with tstyles create.")
            continue
        }
        $m = [regex]::Match($leaf, '^background\.([A-Za-z]+)$')
        if ($m.Success -and ($imageExts -ccontains $m.Groups[1].Value)) { $images.Add($entry); continue }
        $p.Add("'$leaf' cannot be submitted. A style holds only $($allowed -join ', ') and one background image ($((@($imageExts) | ForEach-Object { '.' + $_ }) -join ', ')).")
    }
    if ($images.Count -gt 1) {
        $p.Add("There are $($images.Count) background images; keep one: $((@($images) | ForEach-Object { [System.IO.Path]::GetFileName($_) }) -join ', ').")
    }
    foreach ($img in $images) {
        $len = (New-Object System.IO.FileInfo $img).Length
        if (-not (Test-ImageFileSignature -Path $img)) {
            $p.Add("$([System.IO.Path]::GetFileName($img)) is not the image its name says; save it again as a real $([System.IO.Path]::GetExtension($img)) file.")
        }
        if ($len -gt 2MB) {
            $mb = [string]::Format([cultureinfo]::InvariantCulture, '{0:0.0}', $len / 1MB)
            $p.Add("$([System.IO.Path]::GetFileName($img)) is $mb MB; the catalogue takes images up to 2 MB.")
        }
    }

    # scheme.json: the full key set, every colour #rrggbb, the contrast floors.
    $schemePath = Join-Path $StyleDir 'scheme.json'
    if (-not [System.IO.File]::Exists($schemePath)) {
        $p.Add('scheme.json is missing.')
    } else {
        $scheme = $null
        try { $scheme = [System.IO.File]::ReadAllText($schemePath, $utf8) | ConvertFrom-Json -ErrorAction Stop } catch { $scheme = $null }
        if (-not $scheme) {
            $p.Add('scheme.json is not valid JSON.')
        } else {
            $slots = 'background', 'foreground', 'cursorColor', 'selectionBackground',
                     'black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white',
                     'brightBlack', 'brightRed', 'brightGreen', 'brightYellow',
                     'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite'
            $missing = @($slots | Where-Object { -not $scheme.PSObject.Properties[$_] })
            if ($missing.Count) { $p.Add("scheme.json is missing: $($missing -join ', ').") }
            $bad = @($slots | Where-Object { $scheme.PSObject.Properties[$_] -and ("$($scheme.$_)" -notmatch '^#[0-9a-fA-F]{6}$') })
            if ($bad.Count) { $p.Add("scheme.json colours must be #rrggbb: $($bad -join ', ').") }
            if ("$($scheme.name)" -cne $Name) { $p.Add("scheme.json's name must be '$Name', the style's name.") }
            # The picker's hint line is dimmed from the foreground and must
            # still read on the background (Picker-HintContrast.Tests.ps1).
            $hint = $null
            try { $hint = Get-SchemeHintColor -Scheme $scheme } catch { $hint = $null }
            $hintRatio = if ($hint -and (ConvertTo-NormalHex $scheme.background)) { Get-ContrastRatio -A $hint -B $scheme.background } else { $null }
            if ($null -ne $hintRatio -and $hintRatio -lt 4.5) {
                $p.Add("foreground ($($scheme.foreground)) is too faint on the background for the picker's hint line; the catalogue needs it at 4.5:1 or more.")
            }
            foreach ($c in @(Get-SchemeCatalogueContrastProblems -Scheme $scheme -Exempt $ContrastExempt)) {
                $ratio = [string]::Format([cultureinfo]::InvariantCulture, '{0:0.00}', $c.Ratio)
                $p.Add("$($c.Slot) ($($c.Color)) is $ratio`:1 on $($c.Against) ($($c.AgainstColor)); the catalogue needs more than $($c.Floor):1.")
            }
        }
    }

    $themePath = Join-Path $StyleDir 'theme.json'
    if ([System.IO.File]::Exists($themePath)) {
        $theme = $null
        try { $theme = [System.IO.File]::ReadAllText($themePath, $utf8) | ConvertFrom-Json -ErrorAction Stop } catch { $theme = $null }
        if (-not $theme) { $p.Add('theme.json is not valid JSON.') }
        else { foreach ($t in @(Get-SubmittedThemeProblems -Theme $theme -Name $Name)) { $p.Add($t) } }
    }

    # Shell-Prompt.Tests.ps1 renders a prompt.sh beside every scheme.json.
    if (-not [System.IO.File]::Exists((Join-Path $StyleDir 'prompt.sh'))) {
        $p.Add('prompt.sh is missing: the catalogue needs the zsh/bash prompt as well as the PowerShell one.')
    }

    # meta.json: the picker shows its description on one row.
    $limits = Get-StyleMetaLimits
    $metaPath = Join-Path $StyleDir 'meta.json'
    $meta = $null
    if ([System.IO.File]::Exists($metaPath)) {
        try { $meta = [System.IO.File]::ReadAllText($metaPath, $utf8) | ConvertFrom-Json -ErrorAction Stop } catch { $meta = $null }
    }
    $desc = if ($meta) { "$($meta.description)".Trim() } else { '' }
    $quote = if ($meta) { "$($meta.quote)".Trim() } else { '' }
    if (-not $desc) {
        $p.Add('meta.json needs a one-line description: it is what the picker shows beside the name.')
    } elseif ($desc -match "[`r`n]" -or $desc.Length -gt $limits.Description) {
        $p.Add("meta.json's description must be one line of at most $($limits.Description) characters (it is $($desc.Length)).")
    }
    if ($quote -and ($quote -match "[`r`n]" -or $quote.Length -gt $limits.Quote)) {
        $p.Add("meta.json's quote must be one line of at most $($limits.Quote) characters (it is $($quote.Length)).")
    }

    # Callers wrap in @(): a bare return unrolls to nothing when empty.
    $p.ToArray()
}

function ConvertTo-CreatedPromptChoice {
    # The creator state's prompt choices, as the record meta.json keeps:
    # what Test-StylePromptIsGenerated regenerates the prompt from.
    param([Parameter(Mandatory)][hashtable]$State)
    [ordered]@{
        shape       = [string]$State.PromptShape
        symbol      = [string]$State.Symbol
        pathColor   = [string]$State.PromptPathColor
        symbolColor = [string]$State.PromptSymbolColor
    }
}

function New-CreatedStylePromptText {
    # The prompt a created style is saved with, from its recorded choice:
    # New-StylePromptText titled with the upper-cased name, no -BranchColor
    # (the git shape's branch takes the generator's default arm). The
    # creator's save and the review's regeneration both come here.
    param([Parameter(Mandatory)][System.Collections.IDictionary]$Choice, [Parameter(Mandatory)][string]$Name)
    New-StylePromptText -Shape $Choice.shape -Symbol $Choice.symbol `
        -PathColor $Choice.pathColor -SymbolColor $Choice.symbolColor `
        -Title $Name.ToUpperInvariant()
}

function Test-StylePromptIsGenerated {
    <#
    .SYNOPSIS
    'generated' | 'hand-written' | 'none': is this style's prompt code the
    creator's, byte for byte?

    .DESCRIPTION
    'generated' only when meta.json carries the creator's `created` record,
    its shape and symbol are ones the creator offers and its colours are
    #rrggbb, and both profile.ps1 and prompt.sh are exactly what
    New-CreatedStylePromptText makes from it (prompt.sh LF-normalised, as
    Save-CreatedStyle writes it). Then the files are this module's code and
    nothing the submitter wrote. Anything else with a prompt file is
    'hand-written' -- the maintainer reads it before accepting. 'none'
    means the style ships neither file.

    The symbol is checked against Get-StylePromptSymbols rather than only
    the generator's own rule: the verdict vouches for the code, so it takes
    nothing the creator would not have offered.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$StyleDir, [string]$Name)

    if (-not $Name) { $Name = [System.IO.Path]::GetFileName($StyleDir.TrimEnd('\', '/')) }
    $profilePath = Join-Path $StyleDir 'profile.ps1'
    $shellPath = Join-Path $StyleDir 'prompt.sh'
    $hasProfile = [System.IO.File]::Exists($profilePath)
    $hasShell = [System.IO.File]::Exists($shellPath)
    if (-not $hasProfile -and -not $hasShell) { return 'none' }
    if (-not ($hasProfile -and $hasShell)) { return 'hand-written' }

    $utf8 = [System.Text.UTF8Encoding]::new($false)
    try {
        $meta = [System.IO.File]::ReadAllText((Join-Path $StyleDir 'meta.json'), $utf8) | ConvertFrom-Json -ErrorAction Stop
    } catch { return 'hand-written' }
    $c = $meta.created
    if (-not $c) { return 'hand-written' }
    $choice = [ordered]@{
        shape = "$($c.shape)"; symbol = "$($c.symbol)"
        pathColor = "$($c.pathColor)"; symbolColor = "$($c.symbolColor)"
    }
    if (@(Get-StylePromptShapes) -cnotcontains $choice.shape) { return 'hand-written' }
    if (@(Get-StylePromptSymbols) -cnotcontains $choice.symbol) { return 'hand-written' }
    if ($choice.pathColor -notmatch '^#[0-9a-fA-F]{6}$' -or $choice.symbolColor -notmatch '^#[0-9a-fA-F]{6}$') { return 'hand-written' }
    if (-not (Test-StyleNameValid -Name $Name)) { return 'hand-written' }

    try { $want = New-CreatedStylePromptText -Choice $choice -Name $Name } catch { return 'hand-written' }
    $wantProfile = $utf8.GetBytes([string]$want.Profile)
    $wantShell = $utf8.GetBytes((([string]$want.Shell) -replace "`r`n", "`n"))
    $gotProfile = [System.IO.File]::ReadAllBytes($profilePath)
    $gotShell = [System.IO.File]::ReadAllBytes($shellPath)
    $same = { param($a, $b) [Convert]::ToBase64String([byte[]]$a) -ceq [Convert]::ToBase64String([byte[]]$b) }
    if ((& $same $wantProfile $gotProfile) -and (& $same $wantShell $gotShell)) { return 'generated' }
    'hand-written'
}

function Get-TerminalStylesModuleVersion {
    # This module's version from its manifest, or '' when it cannot be read.
    try {
        $m = Import-PowerShellDataFile -Path (Join-Path $script:TStylesModuleRoot 'TerminalStyles.psd1') -ErrorAction Stop
        return [string]$m.ModuleVersion
    } catch { return '' }
}

function New-StyleSubmissionPackage {
    <#
    .SYNOPSIS
    Packs a style folder into <OutDir>\<Name>.zip for a submission issue.

    .DESCRIPTION
    Flat entries: the style's allowed files (Get-StyleSubmissionFileNames),
    its one background image, and submission.json -- name, description,
    image source, the rights answer ($null when there is no image), module
    version and a UTC timestamp written culture-invariantly. Built beside
    the destination and moved over it, so an older zip for the same name is
    replaced whole or not at all.

    Status is written | failed, with Path or Reason.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StyleDir,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$OutDir,
        [AllowEmptyString()][string]$ImageSource = '',
        $RightsConfirmed = $null
    )
    $result = { param($s, $p, $r) [pscustomobject]@{ Status = $s; Path = $p; Reason = $r } }
    $dest = Join-Path $OutDir "$Name.zip"
    $tmp = Join-Path $OutDir ("$Name." + [guid]::NewGuid().ToString('N') + '.part')
    try {
        Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
        Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
        if (-not [System.IO.Directory]::Exists($OutDir)) { [void][System.IO.Directory]::CreateDirectory($OutDir) }

        $utf8 = [System.Text.UTF8Encoding]::new($false)
        $desc = ''
        try {
            $meta = [System.IO.File]::ReadAllText((Join-Path $StyleDir 'meta.json'), $utf8) | ConvertFrom-Json -ErrorAction Stop
            $desc = "$($meta.description)".Trim()
        } catch { $desc = '' }
        $record = [ordered]@{
            name                 = $Name
            description          = $desc
            imageSource          = $ImageSource
            imageRightsConfirmed = $RightsConfirmed
            moduleVersion        = (Get-TerminalStylesModuleVersion)
            createdUtc           = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [cultureinfo]::InvariantCulture)
        }

        $files = New-Object System.Collections.Generic.List[string]
        foreach ($f in @(Get-StyleSubmissionFileNames)) {
            $path = Join-Path $StyleDir $f
            if ([System.IO.File]::Exists($path)) { $files.Add($path) }
        }
        $img = Get-BackgroundFileIn -Directory $StyleDir
        if ($img) { $files.Add([string]$img) }

        $fs = [System.IO.File]::Open($tmp, [System.IO.FileMode]::CreateNew)
        try {
            $zip = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
            try {
                foreach ($path in $files) {
                    [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $path, [System.IO.Path]::GetFileName($path))
                }
                $entry = $zip.CreateEntry('submission.json')
                $w = New-Object System.IO.StreamWriter($entry.Open(), $utf8)
                try { $w.Write(([pscustomobject]$record | ConvertTo-Json -Depth 4)) } finally { $w.Dispose() }
            } finally { $zip.Dispose() }
        } finally { $fs.Dispose() }

        if ([System.IO.File]::Exists($dest)) { [System.IO.File]::Delete($dest) }
        [System.IO.File]::Move($tmp, $dest)
        return (& $result 'written' $dest $null)
    } catch {
        if ([System.IO.File]::Exists($tmp)) { try { [System.IO.File]::Delete($tmp) } catch { $null = $_ } }
        return (& $result 'failed' $null $_.Exception.Message)
    }
}

function Get-StyleSubmissionUrl {
    # The "Style submission" issue form, prefilled. GitHub issue forms take
    # a field's id as a query parameter.
    param(
        [Parameter(Mandatory)][string]$Name,
        [AllowEmptyString()][string]$Description = '',
        [AllowEmptyString()][string]$ImageSource = ''
    )
    $q = [ordered]@{
        template       = 'style_submission.yml'
        title          = "Style: $Name"
        'style-name'   = $Name
        description    = $Description
        'image-source' = $ImageSource
    }
    $pairs = foreach ($k in $q.Keys) { '{0}={1}' -f $k, [uri]::EscapeDataString([string]$q[$k]) }
    "https://github.com/$($script:TStylesSubmitRepo)/issues/new?" + ($pairs -join '&')
}

function Invoke-TerminalStyleSubmit {
    <#
    .SYNOPSIS
    `tstyles submit <name>`: check a style of yours, pack it, open the issue.

    .DESCRIPTION
    Nothing is sent. The last line says so, and what the user still has to
    do: attach the zip to the issue the browser opened.

    -ReadLine and -OpenUrl are seams: tests answer the prompts and catch the
    URL instead of a person and a browser.
    #>

    [CmdletBinding()]
    param(
        [string]$Name,
        [scriptblock]$ReadLine = { param($prompt) Read-Host $prompt },
        [scriptblock]$OpenUrl = { param($url) Start-Process $url }
    )

    if (-not $Name) {
        Write-Host " Usage: tstyles submit <name> (one of your own styles; see tstyles help submit)" -ForegroundColor Yellow
        return
    }
    $dir = Get-StyleDir -StyleName $Name
    if (-not $dir) {
        Write-Host " No style named '$Name'. Nothing was submitted." -ForegroundColor Yellow
        return
    }
    $origin = Get-StyleOrigin -Name $Name -StyleDir $dir
    if ($origin -eq 'bundled') {
        Write-Host " '$Name' is already in the catalogue. Nothing was submitted." -ForegroundColor Yellow
        return
    }
    if ($origin -eq 'shadow') {
        Write-Host " Your '$Name' uses a bundled style's name, and the catalogue needs a name of its own. Make it again under a new name with: tstyles create" -ForegroundColor Yellow
        return
    }
    if ($origin -ne 'yours') {
        Write-Host " Cannot tell whether '$Name' is your style or a bundled one, so nothing was submitted." -ForegroundColor Yellow
        return
    }

    $problems = @(Get-StyleSubmissionProblems -StyleDir $dir -Name $Name)
    if ($problems.Count) {
        Write-Host " '$Name' cannot be submitted yet:" -ForegroundColor Yellow
        foreach ($line in $problems) { Write-Host " - $line" }
        Write-Host " Nothing was submitted."
        return
    }

    $source = ''
    $rights = $null
    if (Get-BackgroundFileIn -Directory $dir) {
        $source = "$(& $ReadLine ' Where does the background image come from? (a link or a few words; Enter to skip)')".Trim()
        $answer = "$(& $ReadLine ' Do you have the right to let everyone use this image? [y/N]')".Trim()
        if ($answer -notmatch '^(y|yes)$') {
            Write-Host " Not submitted: the catalogue can only ship images that may be shared." -ForegroundColor Yellow
            return
        }
        $rights = $true
    }

    $pack = New-StyleSubmissionPackage -StyleDir $dir -Name $Name `
        -OutDir (Join-Path $script:TStylesDataRoot 'submissions') -ImageSource $source -RightsConfirmed $rights
    if ($pack.Status -ne 'written') {
        Write-Host " Could not write the submission: $($pack.Reason) Nothing was submitted." -ForegroundColor Red
        return
    }

    $meta = Get-StyleMeta -StyleDir $dir
    $url = Get-StyleSubmissionUrl -Name $Name -Description $meta.Description -ImageSource $source
    $opened = $true
    try { & $OpenUrl $url } catch { $opened = $false }

    Write-Host " Packed '$Name' into $($pack.Path)" -ForegroundColor Green
    if ($opened) {
        Write-Host " The submission form is opening in your browser. If it does not, open:"
    } else {
        Write-Host " Open the submission form in your browser:"
    }
    Write-Host " $url"
    Write-Host " Not sent yet: drag the zip into the form and press Submit. The maintainer decides whether it joins the catalogue." -ForegroundColor Cyan
}