Private/OpenTag3DProfile.ps1

# Saved vendor profiles: named sets of field values for filament you tag repeatedly.
#
# Stored as one JSON file per profile, so they can be edited by hand, copied between
# machines and kept in version control.

# Names come from the browser, so they are validated rather than trusted: no separators,
# no dots leading anywhere, nothing that could escape the profile directory.
$script:OpenTag3DProfileNamePattern = '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$'

function Get-OpenTag3DProfileDir {
    <#
    .SYNOPSIS
        Folder holding saved vendor profiles, created on first use.
    .DESCRIPTION
        Windows: %APPDATA%\OpenTag3D\Profiles. Linux and macOS: $XDG_CONFIG_HOME/opentag3d/
        profiles, defaulting to ~/.config when that is not set.
    #>

    [CmdletBinding()]
    param()

    if ($IsWindows) {
        $root = $env:APPDATA
        if ([string]::IsNullOrWhiteSpace($root)) { $root = [Environment]::GetFolderPath('ApplicationData') }
        $dir = Join-Path (Join-Path $root 'OpenTag3D') 'Profiles'
    }
    else {
        $root = $env:XDG_CONFIG_HOME
        if ([string]::IsNullOrWhiteSpace($root)) { $root = Join-Path $HOME '.config' }
        $dir = Join-Path (Join-Path $root 'opentag3d') 'profiles'
    }

    if (-not (Test-Path -LiteralPath $dir -PathType Container)) {
        $null = New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop
        Write-Verbose "Created profile directory $dir"
    }
    return $dir
}

function Test-OpenTag3DProfileName {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyString()] [string]$Name)

    if ($Name -notmatch $script:OpenTag3DProfileNamePattern) {
        throw "Profile name '$Name' is not usable. Use letters, digits, spaces, dots, dashes or underscores, up to 64 characters."
    }
    return $Name
}

function Get-OpenTag3DProfileList {
    <#
    .SYNOPSIS
        Names of every saved profile, alphabetically.
    #>

    [CmdletBinding()]
    param()

    Get-ChildItem -LiteralPath (Get-OpenTag3DProfileDir) -Filter '*.json' -File -ErrorAction SilentlyContinue |
        Sort-Object Name |
        ForEach-Object { [IO.Path]::GetFileNameWithoutExtension($_.Name) }
}

function Get-OpenTag3DProfile {
    <#
    .SYNOPSIS
        Reads one saved profile.
    .DESCRIPTION
        Returns the tag type, mode and a value table keyed by spec field id. Keys that are
        not spec fields are dropped, so an edited or older file cannot inject anything the
        encoder does not understand.
    #>

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

    $null = Test-OpenTag3DProfileName -Name $Name
    $path = Join-Path (Get-OpenTag3DProfileDir) "$Name.json"
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "No profile named '$Name'." }

    try { $json = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json }
    catch { throw "Profile '$Name' is not readable JSON: $($_.Exception.Message)" }

    # Accept ids from any version: a 1.003 profile loaded into a 2.000 form should keep the
    # fields the two share, and the encoder ignores anything the target layout lacks.
    $known  = @($script:OpenTag3DSpecVersions | ForEach-Object {
                    (Get-OpenTag3DFieldTable -SpecVersion $_) | ForEach-Object { $_.Id } } | Sort-Object -Unique)
    $values = @{}
    if ($json.values) {
        foreach ($p in $json.values.PSObject.Properties) {
            if ($p.Name -in $known) { $values[$p.Name] = "$($p.Value)" }
        }
    }

    [pscustomobject]@{
        Name        = $Name
        TagType     = if ($json.tagType -in 'NTAG213','NTAG215','NTAG216') { "$($json.tagType)" } else { 'NTAG215' }
        Mode        = if ($json.mode -in 'Core','Extended') { "$($json.mode)" } else { 'Extended' }
        # Profiles written before this field existed are 1.003 by construction - their values
        # were entered under 1.003 semantics (tolerance in micrometres, and so on), so this
        # must not follow the module default.
        SpecVersion = if ("$($json.specVersion)" -in $script:OpenTag3DSpecVersions) { "$($json.specVersion)" }
                      else { '1.003' }
        Values      = $values
        Path        = $path
    }
}

function Save-OpenTag3DProfile {
    <#
    .SYNOPSIS
        Writes a profile, replacing any file of the same name.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [string]$Name,
        [Parameter(Mandatory)] [hashtable]$Values,
        [Parameter()] [ValidateSet('NTAG213','NTAG215','NTAG216')] [string]$TagType = 'NTAG215',
        [Parameter()] [ValidateSet('Core','Extended')] [string]$Mode = 'Extended',
        [Parameter()] [string]$SpecVersion
    )

    $spec = Get-OpenTag3DSpec -SpecVersion $SpecVersion

    $null = Test-OpenTag3DProfileName -Name $Name
    $path = Join-Path (Get-OpenTag3DProfileDir) "$Name.json"

    # Store spec fields only, and only ones with something in them: a profile is a set of
    # defaults to start from, not a full payload.
    $keep = [ordered]@{}
    foreach ($f in $spec.Fields) {
        if ($f.Id -in $script:OpenTag3DGenericReadOnly) { continue }
        if (-not $Values.ContainsKey($f.Id)) { continue }
        $v = "$($Values[$f.Id])".Trim()
        if ($v) { $keep[$f.Id] = $v }
    }

    $doc = [ordered]@{
        name        = $Name
        tagType     = $TagType
        mode        = $Mode
        specVersion = $spec.Version
        savedUtc    = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        values      = $keep
    }

    if ($PSCmdlet.ShouldProcess($path, 'Save profile')) {
        $json = $doc | ConvertTo-Json -Depth 4
        # UTF8 without a BOM: this is data, read by ConvertFrom-Json and by people.
        [IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
        Write-Verbose "Saved profile to $path"
    }
    return $path
}

function Remove-OpenTag3DProfile {
    [CmdletBinding(SupportsShouldProcess)]
    param([Parameter(Mandatory)] [string]$Name)

    $null = Test-OpenTag3DProfileName -Name $Name
    $path = Join-Path (Get-OpenTag3DProfileDir) "$Name.json"
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "No profile named '$Name'." }
    if ($PSCmdlet.ShouldProcess($path, 'Delete profile')) {
        Remove-Item -LiteralPath $path -Force
    }
}