Private/Get-DFCategoryDb.ps1
|
#Requires -Version 7.0 # Lazy-loaded, indexed category database for trifle discovery (-Category, # -WorksWith, Get-DFCategoryList, the detail card's Category/Related/Alt-to # sections). Ships as data/tool-categories.json; Update-DFCategoryDb can # place a newer copy under $XDG_DATA_HOME/dotforge/. The db is an index into # real catalog data — every consumer still resolves matches through the # normal search-and-merge path, never trusting the db's own snapshot of # versions/availability. function Get-DFCategoryDb { <# .SYNOPSIS Loads (and indexes) the trifle category database. Lazy singleton; never throws — an unreadable file degrades to an empty, valid, harmless db object. .PARAMETER Path Override the shipped-side location (a test seam — lets a fixture stand in for the real shipped file). The refreshed-vs-shipped precedence check against $Env:XDG_DATA_HOME still runs normally against this override, so tests can exercise the full resolution algorithm without depending on the real data/tool-categories.json. Always forces a fresh, uncached read. .PARAMETER Force Reload from the resolved location, bypassing the singleton cache. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string]$Path, [switch]$Force ) if (-not $Path -and -not $Force -and $script:DFCategoryDb) { return $script:DFCategoryDb } $shippedPath = $Path ? $Path : (Join-Path $PSScriptRoot '../data/tool-categories.json') $resolvedPath = $shippedPath if ($Env:XDG_DATA_HOME) { $refreshedPath = Join-Path $Env:XDG_DATA_HOME 'dotforge/tool-categories.json' if (Test-Path $refreshedPath) { try { $shippedUpdated = (Test-Path $shippedPath) ? (Get-Content $shippedPath -Raw | ConvertFrom-Json).updated : $null $refreshedUpdated = (Get-Content $refreshedPath -Raw | ConvertFrom-Json).updated if (-not $shippedUpdated -or [datetime]$refreshedUpdated -gt [datetime]$shippedUpdated) { $resolvedPath = $refreshedPath } } catch { Write-Verbose "DotForge: unreadable refreshed category db '$refreshedPath', using shipped: $_" } } } # Two-tier fallback: a resolved refreshed copy that turns out corrupt — # whether unparseable JSON or merely schema-invalid — always falls back # to the shipped copy before giving up. (An unparseable refreshed file # that fails even the earlier date-comparison read never gets selected # as $resolvedPath in the first place, so it's already excluded here; # this covers the remaining case: valid JSON, valid 'updated', but the # document itself fails schema validation.) $tryLoadCategoryDb = { param($p) if (-not (Test-Path $p)) { return $null } try { $doc = Get-Content $p -Raw | ConvertFrom-Json $errs = $null if (Test-DFCategoryDbSchema -Database $doc -Errors ([ref]$errs)) { return $doc } Write-Verbose "DotForge: category db at '$p' failed schema validation: $($errs -join '; ')" return $null } catch { Write-Verbose "DotForge: unreadable category db '$p': $_" return $null } } $usedRefreshed = ($resolvedPath -ne $shippedPath) $raw = & $tryLoadCategoryDb $resolvedPath if (-not $raw -and $usedRefreshed) { $raw = & $tryLoadCategoryDb $shippedPath } if (-not $raw -and -not $script:DFCategoryDbWarned) { Write-Warning 'DotForge: category database is unavailable — -Category/-WorksWith search and card discovery sections will be empty.' $script:DFCategoryDbWarned = $true } $nameIndex = @{} $idIndex = @{} $facetIndex = @{} if ($raw) { foreach ($prop in $raw.tools.PSObject.Properties) { $key = $prop.Name $entry = $prop.Value $nameIndex[$key.ToLowerInvariant()] = $key foreach ($alias in @($entry.aliases)) { if ($alias) { $nameIndex[([string]$alias).ToLowerInvariant()] = $key } } if ($entry.ids) { foreach ($idProp in $entry.ids.PSObject.Properties) { $idIndex["$($idProp.Name):$($idProp.Value)".ToLowerInvariant()] = $key } } foreach ($f in @($entry.function)) { $facetKey = "function:$f" if (-not $facetIndex.ContainsKey($facetKey)) { $facetIndex[$facetKey] = [System.Collections.Generic.List[string]]::new() } $facetIndex[$facetKey].Add($key) } foreach ($w in @($entry.worksWith)) { $facetKey = "worksWith:$w" if (-not $facetIndex.ContainsKey($facetKey)) { $facetIndex[$facetKey] = [System.Collections.Generic.List[string]]::new() } $facetIndex[$facetKey].Add($key) } } } $db = [pscustomobject]@{ Raw = $raw NameIndex = $nameIndex IdIndex = $idIndex FacetIndex = $facetIndex } if (-not $Path) { $script:DFCategoryDb = $db } $db } function Get-DFCategoryDbEntry { <# .SYNOPSIS Resolves a merged DotForge.ToolInfo to its category-db entry, trying DFTool name, then each source:packageId, then plain name. Returns $null when the tool isn't in the seed db — a normal, expected case. .DESCRIPTION Scoop package ids are bucket-qualified at runtime (e.g. 'main/fd') but the seed data stores bare names ('fd', matching Tools/*.json's own convention) — so a scoop id lookup that misses on the full value retries with just the trailing segment after '/', mirroring the same bucket-stripping Find-DFPackage's resolveDFTool scriptblock uses. .PARAMETER Info A DotForge.ToolInfo (or any object with DFTool/Name/Sources shape). .PARAMETER Database The category db (defaults to Get-DFCategoryDb with no overrides). #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] $Info, $Database = (Get-DFCategoryDb) ) if (-not $Database.Raw) { return $null } $key = $null if ($Info.DFTool) { $key = $Database.NameIndex[$Info.DFTool.ToLowerInvariant()] } if (-not $key) { foreach ($s in @($Info.Sources)) { $lookup = "$($s.Source):$($s.PackageId)".ToLowerInvariant() if ($Database.IdIndex.ContainsKey($lookup)) { $key = $Database.IdIndex[$lookup]; break } if ($s.Source -eq 'scoop' -and $s.PackageId -match '/') { $bareLookup = "scoop:$(($s.PackageId -split '/')[-1])".ToLowerInvariant() if ($Database.IdIndex.ContainsKey($bareLookup)) { $key = $Database.IdIndex[$bareLookup]; break } } } } if (-not $key -and $Info.Name) { $key = $Database.NameIndex[$Info.Name.ToLowerInvariant()] } if (-not $key) { return $null } [pscustomobject]@{ Key = $key; Entry = $Database.Raw.tools.$key } } function Get-DFCategoryRelatedTools { <# .SYNOPSIS Computes the "Related" list for one tool: its curated relatedTo entries first, then same-function tools by popularity descending, excluding itself, capped at 6, deduplicated. .PARAMETER Database The category db. .PARAMETER Key The tool key to compute relations for. #> [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)] $Database, [Parameter(Mandatory)] [string]$Key ) if (-not $Database.Raw -or -not $Database.Raw.tools.$Key) { return @() } $entry = $Database.Raw.tools.$Key $related = [System.Collections.Generic.List[string]]::new() foreach ($r in @($entry.relatedTo)) { if ($r -and $r -ne $Key -and $r -notin $related) { $related.Add($r) } } if ($related.Count -lt 6) { $candidates = [System.Collections.Generic.List[string]]::new() foreach ($f in @($entry.function)) { foreach ($otherKey in @($Database.FacetIndex["function:$f"])) { if ($otherKey -ne $Key -and $otherKey -notin $related -and $otherKey -notin $candidates) { $candidates.Add($otherKey) } } } $fill = @($candidates | Sort-Object ` { -($Database.Raw.tools.$_.popularity ?? 0) }, { $_ } | Select-Object -First (6 - $related.Count)) foreach ($f in $fill) { $related.Add($f) } } @($related | Select-Object -First 6) } |