src/Engine/Gallery.ps1
|
# Gallery.ps1 — read-only PowerShell Gallery search (#38), version lookups and # update checks. # --- search (gh#17) -------------------------------------------------------- # # There are two different questions you can ask the gallery, and psmm needs # both: # # a NAME PATTERN - Find-PSResource -Name 'Az.*'. Provider-side, honours # every registered repository, and matches the module NAME only. # FULL TEXT - the OData Search() endpoint the gallery WEBSITE calls. # Matches name + description + tags, ranked the way the site ranks it, # and limited server-side. # # psmm used to send everything down the first path, wrapping a bare term as # "*term*". Measured against the live gallery on 2026-07-23 (PSResourceGet # 1.2.0), that is wrong in four separate ways: # # 'excel' as *excel* 29 hits in 1.3 s, Search-ExcelFileWithUI first and # ImportExcel (22.9M downloads) sixth. The website # puts ImportExcel first. # 'excel' full text 40 hits in 0.46 s, ImportExcel first - and it also # finds GetSQL and PSWriteOffice, Excel modules whose # NAME contains no "excel" at all. # 'sharepoint' the name glob misses PnP.PowerShell entirely, same # reason: the word is in the description, not the name. # 'a' as *a* 8828 records over the wire in 216 SECONDS, then cut # to an arbitrary 40 (xExchange, xFailOverCluster...). # -First cannot be pushed down into a glob; it IS the # endpoint's $top. # # A leading wildcard has additionally been seen to return 0 results AND 0 # errors (the original gh#17 report; not reproducible on demand) - a silent # failure no -ErrorAction can catch. So: a bare term goes full text, and an # explicit wildcard stays on the provider, which is the only thing that can # honour it. # The public gallery's full-text search URI - the query the website runs. function Get-PSMMGallerySearchUri { [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Term, [int]$First = 40, [switch]$Prerelease ) # OData string literal: double the single quotes, THEN percent-encode the # whole thing - a bare & or # in the term would otherwise end the query # string and take the filter with it. $literal = [uri]::EscapeDataString(("$Term" -replace "'", "''")) $top = [Math]::Max(1, [Math]::Min(200, $First)) # No $orderby on purpose: DownloadCount desc, DownloadCount asc and no # $orderby at all return the SAME order (verified), i.e. the endpoint # ignores it - while an unknown field is a 400. The default order is the # site's relevance ranking, which is exactly what we want. 'https://www.powershellgallery.com/api/v2/Search()' + "?searchTerm='$literal'&`$filter=IsLatestVersion&`$top=$top" + '&semVerLevel=2.0.0' + "&includePrerelease=$(if ($Prerelease) { 'true' } else { 'false' })" } # One <m:properties> child as text. Two shapes are NOT [string] and both # stringify to "System.Xml.XmlElement" if read naively: a null field carries # m:null="true", and a typed field (Edm.Int32 and friends) keeps its value # in #text. function Get-PSMMODataText { [CmdletBinding()] param($Node) if ($null -eq $Node) { return '' } if ($Node -is [string]) { return $Node } try { if ("$($Node.null)" -eq 'true') { return '' } } catch { } try { $t = $Node.'#text' if ($null -ne $t) { return "$t" } } catch { } try { return "$($Node.InnerText)" } catch { return '' } } # One <entry> from the OData feed -> the same lightweight result shape the # provider path produces. function ConvertFrom-PSMMGalleryEntry { [CmdletBinding()] param([Parameter(Mandatory)]$Entry) $p = $Entry.properties $split = Split-PSMMVersionString -Text (Get-PSMMODataText $p.Version) $downloads = [long]0 [void][long]::TryParse((Get-PSMMODataText $p.DownloadCount), [ref]$downloads) $desc = Get-PSMMODataText $p.Description if (-not $desc) { $desc = Get-PSMMODataText $p.Summary } [pscustomobject]@{ Name = (Get-PSMMODataText $p.Id) Version = $split.Version Prerelease = $split.Prerelease Description = $desc Author = (Get-PSMMODataText $p.Authors) Downloads = $downloads ProjectUri = (Get-PSMMODataText $p.ProjectUrl) Repository = 'PSGallery' } } # Full-text search against the public gallery. Throws on a transport or HTTP # failure so the caller can fall back AND say why - the whole point of gh#17 # is that "no results" must not mean "the request failed". function Find-PSMMGalleryFullText { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Term, [int]$First = 40, [switch]$Prerelease ) $uri = Get-PSMMGallerySearchUri -Term $Term -First $First -Prerelease:$Prerelease # -TimeoutSec because Invoke-RestMethod waits forever by default and this # call blocks the UI thread. PS7 already routes through the system proxy. $raw = Invoke-RestMethod -Uri $uri -TimeoutSec 25 -ErrorAction Stop # zero hits come back as $null, and @($null) is a ONE-element array @(@($raw) | Where-Object { $null -ne $_ } | ForEach-Object { ConvertFrom-PSMMGalleryEntry -Entry $_ } | Where-Object { $_.Name }) } # Provider-side NAME search: the only path that can honour a wildcard, and # the only path that can see a private repository. function Find-PSMMGalleryByName { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Pattern, [int]$First = 40, [string[]]$Repository ) $results = if (Get-Command Find-PSResource -ErrorAction SilentlyContinue) { if ($Repository) { Find-PSResource -Name $Pattern -Type Module -Repository $Repository -ErrorAction SilentlyContinue } else { Find-PSResource -Name $Pattern -Type Module -ErrorAction SilentlyContinue } } else { if ($Repository) { Find-Module -Name $Pattern -Repository $Repository -ErrorAction SilentlyContinue } else { Find-Module -Name $Pattern -ErrorAction SilentlyContinue } } @($results | Select-Object -First $First | ForEach-Object { [pscustomobject]@{ Name = $_.Name Version = "$($_.Version)" Prerelease = "$($_.Prerelease)".TrimStart('-') Description = "$($_.Description)" Author = "$($_.Author)" Downloads = [long]0 # the provider does not report it ProjectUri = "$($_.ProjectUri)" Repository = "$($_.Repository)" } }) } # Registered repositories that are NOT the public gallery. The full-text # endpoint only knows about powershellgallery.com, so anything else has to be # asked through the provider or its modules become unfindable - which would # be a fresh instance of the bug this file is fixing. function Get-PSMMExtraRepository { [CmdletBinding()] param() $names = @() try { if (Get-Command Get-PSResourceRepository -ErrorAction SilentlyContinue) { $names = @(Get-PSResourceRepository -ErrorAction Stop | Where-Object { "$($_.Uri)" -notmatch 'powershellgallery\.com' } | ForEach-Object { "$($_.Name)" }) } elseif (Get-Command Get-PSRepository -ErrorAction SilentlyContinue) { $names = @(Get-PSRepository -ErrorAction Stop | Where-Object { "$($_.SourceLocation)" -notmatch 'powershellgallery\.com' } | ForEach-Object { "$($_.Name)" }) } } catch { } # no provider, or it refuses to enumerate: gallery only @($names | Where-Object { $_ }) } # Concatenate two result sets, first one wins on a duplicate name. function Merge-PSMMGalleryResult { [CmdletBinding()] param( [AllowNull()][AllowEmptyCollection()]$Primary, [AllowNull()][AllowEmptyCollection()]$Secondary, [int]$Limit = 40 ) $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $out = [System.Collections.Generic.List[object]]::new() foreach ($r in @(@($Primary) + @($Secondary))) { if ($null -eq $r -or -not "$($r.Name)") { continue } if (-not $seen.Add("$($r.Name)")) { continue } $out.Add($r) if ($out.Count -ge $Limit) { break } } @($out) } # One exception boiled down to a single short line for a status message. function Get-PSMMErrorLine { [CmdletBinding()] param($ErrorRecord) $m = ("$($ErrorRecord.Exception.Message)" -replace '\s+', ' ').Trim() if ($m.Length -gt 90) { $m = $m.Substring(0, 87) + '...' } $m } # THE search entry point. Returns an envelope rather than bare results, # because an empty screen has to be able to say WHY it is empty: "the gallery # has nothing" and "the search service did not answer" look identical # otherwise, and telling them apart is half of gh#17. # Mode: fulltext | name | fulltext-fallback | name-fallback | none # Note: plain text (the UI styles it), '' when there is nothing to explain function Search-PSMMGallery { [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Query, [int]$First = 40 ) $q = "$Query".Trim() $out = [pscustomobject]@{ Query = $q; Mode = 'none'; Results = @(); Note = '' } if (-not $q) { $out.Note = 'Type a name or a word to search for.'; return $out } if ($q -match '[\*\?]') { # an explicit pattern: hand it to the provider verbatim $out.Mode = 'name' $out.Results = @(Find-PSMMGalleryByName -Pattern $q -First $First) if ($out.Results.Count) { return $out } # nothing matched. A leading wildcard is the shape that has been seen # to fail silently, so retry the words in it as a full-text search # rather than leaving the user with a bare "No results." $bare = ((($q -replace '[\*\?]', ' ') -replace '\s+', ' ')).Trim() if ($bare) { try { $hits = @(Find-PSMMGalleryFullText -Term $bare -First $First) if ($hits.Count) { $out.Mode = 'fulltext-fallback' $out.Results = $hits $out.Note = "No module NAME matches '$q' - showing gallery matches for '$bare' instead." return $out } } catch { } # the pattern found nothing either way; say that } $out.Note = "No module name matches '$q'." return $out } # a bare term: search the way the website searches $out.Mode = 'fulltext' try { $out.Results = @(Find-PSMMGalleryFullText -Term $q -First $First) } catch { # the search service did not answer. A NAME PREFIX search still works # and - unlike the old "*term*" - never builds a leading wildcard. $out.Mode = 'name-fallback' $out.Note = "The gallery search service did not answer ($(Get-PSMMErrorLine $_)). Showing modules whose NAME starts with '$q'." $out.Results = @(Find-PSMMGalleryByName -Pattern "$q*" -First $First) } # a module that has only ever published prereleases (psmm is one) is # invisible to the default query - searching 'psmm' inside psmm found # nothing at all. Say so rather than showing an empty screen. if (-not $out.Results.Count -and $out.Mode -eq 'fulltext') { try { $pre = @(Find-PSMMGalleryFullText -Term $q -First $First -Prerelease) if ($pre.Count) { $out.Results = $pre $out.Note = "Only prerelease versions match '$q'." } } catch { } } # the endpoint only knows the public gallery, so ask the provider for # anything else that is registered. Bounded, so a big internal feed # cannot bury the gallery's relevance ranking. $extraRepos = @(Get-PSMMExtraRepository) if ($extraRepos.Count) { $extra = @(Find-PSMMGalleryByName -Pattern "*$q*" -First ([Math]::Min(10, $First)) -Repository $extraRepos) if ($extra.Count) { $out.Results = @(Merge-PSMMGalleryResult -Primary $extra -Secondary $out.Results -Limit $First) } } if (-not $out.Results.Count -and -not $out.Note) { $out.Note = "The gallery has no module matching '$q'." } $out } # Download count as a short cell: 22940663 -> 22.9M. 0 means "not reported" # (the provider path never reports one), which renders blank rather than '0'. function Format-PSMMDownloadCount { [CmdletBinding()] param($Count) $n = [long]0 if (-not [long]::TryParse("$Count", [ref]$n) -or $n -le 0) { return '' } # the gallery's busiest modules are past a billion (and its DownloadCount # is an Edm.Int32, so the very top ones saturate at 2147483647) if ($n -ge 1000000000) { return ('{0:0.#}B' -f ($n / 1000000000.0)) } if ($n -ge 1000000) { return ('{0:0.#}M' -f ($n / 1000000.0)) } if ($n -ge 1000) { return ('{0:0.#}k' -f ($n / 1000.0)) } "$n" } # --- version comparison (SemVer-ish, gh#6) -------------------------------- # A gallery version is a [version] base plus an optional prerelease label. The # base alone cannot order 0.1.0-beta2 / 0.1.0-beta8 / 0.1.0 - they all parse to # [version]0.1.0 - so the label has to decide. psmm already owns exactly that # comparison (Compare-PSMMVersion in SelfUpdate.ps1, verified against # PSResourceGet's behaviour); this is the (version, label) pair adapter for it, # tolerant of the missing/unparseable versions entry state can hold. function Compare-PSMMEntryVersion { [CmdletBinding()] param($VersionA, [string]$PrereleaseA, $VersionB, [string]$PrereleaseB) $a = Get-PSMMVersionDisplay -Version $VersionA -Prerelease $PrereleaseA $b = Get-PSMMVersionDisplay -Version $VersionB -Prerelease $PrereleaseB if (-not $a -and -not $b) { return 0 } if (-not $a) { return -1 } if (-not $b) { return 1 } try { Compare-PSMMVersion -A $a -B $b } catch { 0 } } # Split "1.2.3-beta4" into its base version and label (gallery objects usually # carry them separately, but not every provider does). function Split-PSMMVersionString { [CmdletBinding()] param([string]$Text) $t = "$Text".Trim() $pre = '' if ($t -match '^([^-]+)-(.+)$') { $t = $Matches[1]; $pre = $Matches[2] } [pscustomobject]@{ Version = $t; Prerelease = $pre } } # Normalise one gallery result into @{ Version; Prerelease }. function ConvertTo-PSMMGalleryVersion { [CmdletBinding()] param($Result) $pre = '' try { $pre = "$($Result.Prerelease)".TrimStart('-') } catch { } $split = Split-PSMMVersionString -Text "$($Result.Version)" if (-not $pre) { $pre = $split.Prerelease } [pscustomobject]@{ Version = $split.Version; Prerelease = $pre } } # Latest gallery version for one module name ($null when not found). # Returns @{ Version; Prerelease }; -Prerelease includes prerelease builds. function Get-PSMMGalleryLatest { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [switch]$Prerelease ) try { if (Get-Command Find-PSResource -ErrorAction SilentlyContinue) { $hits = @(Find-PSResource -Name $Name -Prerelease:$Prerelease -ErrorAction Stop) } else { $hits = @(Find-Module -Name $Name -AllowPrerelease:$Prerelease -ErrorAction Stop) } $best = $null foreach ($h in $hits) { $v = ConvertTo-PSMMGalleryVersion -Result $h if (-not $Prerelease -and $v.Prerelease) { continue } if (-not $best -or (Compare-PSMMEntryVersion -VersionA $v.Version -PrereleaseA $v.Prerelease ` -VersionB $best.Version -PrereleaseB $best.Prerelease) -gt 0) { $best = $v } } return $best } catch { return $null } } # Every version of one module that can be pinned: what is on disk (instant, # offline) and what the gallery offers (network). Newest first, deduplicated. # Feeds the pin picker (gh#5). function Get-PSMMAvailableVersion { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [switch]$Prerelease, [switch]$SkipGallery ) $all = [System.Collections.Generic.List[object]]::new() foreach ($m in @(Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue)) { $all.Add([pscustomobject]@{ Version = "$($m.Version)" Prerelease = (Get-PSMMPrereleaseLabel -ModuleInfo $m) OnDisk = $true InGallery = $false }) } if (-not $SkipGallery) { try { $hits = if (Get-Command Find-PSResource -ErrorAction SilentlyContinue) { @(Find-PSResource -Name $Name -Version '*' -Prerelease:$Prerelease -ErrorAction Stop) } else { @(Find-Module -Name $Name -AllVersions -AllowPrerelease:$Prerelease -ErrorAction Stop) } foreach ($h in $hits) { if ("$($h.Name)" -ne $Name) { continue } $v = ConvertTo-PSMMGalleryVersion -Result $h if (-not $Prerelease -and $v.Prerelease) { continue } $all.Add([pscustomobject]@{ Version = $v.Version; Prerelease = $v.Prerelease OnDisk = $false; InGallery = $true }) } } catch { } # offline / not published: disk versions are still useful } # merge duplicates (a version can be both on disk and in the gallery) $merged = [ordered]@{} foreach ($v in $all) { $key = (Get-PSMMVersionDisplay -Version $v.Version -Prerelease $v.Prerelease) if ($merged.Contains($key)) { $merged[$key].OnDisk = $merged[$key].OnDisk -or $v.OnDisk $merged[$key].InGallery = $merged[$key].InGallery -or $v.InGallery } else { $merged[$key] = [pscustomobject]@{ Display = $key; Version = $v.Version; Prerelease = $v.Prerelease OnDisk = $v.OnDisk; InGallery = $v.InGallery } } } # newest first - Sort-Object has no custom-comparer parameter, and the # ordering here is semantic (base version + prerelease label), so sort the # backing list with the comparison we already own $list = [System.Collections.Generic.List[object]]::new() foreach ($v in $merged.Values) { $list.Add($v) } $list.Sort([System.Comparison[object]] { param($a, $b) -1 * (Compare-PSMMEntryVersion -VersionA $a.Version -PrereleaseA $a.Prerelease ` -VersionB $b.Version -PrereleaseB $b.Prerelease) }) @($list) } # Opt-in update check for a set of entries: marks UpdateAvailable/LatestVersion. # Network-bound - never runs automatically. Respects exact pins (a pinned # module reports pinned rather than update-available) and each entry's # prerelease policy (gh#6). function Update-PSMMLatestVersion { [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()] $Entries) # empty set = normal (zero configs) $found = 0 foreach ($e in @($Entries | Where-Object Installed)) { $latest = Get-PSMMGalleryLatest -Name $e.Name -Prerelease:([bool]$e.AllowPrerelease) if (-not $latest) { continue } $e.LatestVersion = $latest.Version $e.LatestPrerelease = $latest.Prerelease $e.UpdateAvailable = $false if ($e.PinnedExact) { continue } # pinned: never nag if ($e.InstalledVersion) { $e.UpdateAvailable = (Compare-PSMMEntryVersion -VersionA $latest.Version -PrereleaseA $latest.Prerelease ` -VersionB "$($e.InstalledVersion)" -PrereleaseB "$($e.InstalledPrerelease)") -gt 0 } if ($e.UpdateAvailable) { $found++ } } $found } |