bcquality/tools/Update-LearnCatalog.ps1
Jesper Schulz-Wedde 56b80e6dcf Add knowledge-backed AL development
Add read-only planning and repository-changing development skills so BCQuality
knowledge can guide features, bug fixes, refactors, upgrades, and maintenance
before the existing AL review gate runs. Track Microsoft Learn ingestion and
add development and BCApps-shaped guidance evaluation fixtures.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 638b66d2-9f06-4f60-8781-808709e1485c
2026-09-04 12:53:54 +02:00

262 lines
8.9 KiB
PowerShell

<#
.SYNOPSIS
Updates the committed Microsoft Learn Business Central developer catalog.
.DESCRIPTION
Filters a Microsoft Learn catalog payload to modules tagged for both
dynamics-business-central and developer. The output is intentionally only
source metadata; editorial dispositions live in coverage/learn-coverage.json.
The legacy unauthenticated catalog endpoint remains the default while it is
available. Use -CatalogPath with an export from the authenticated Learn
Platform API when the legacy endpoint is retired.
#>
[CmdletBinding(DefaultParameterSetName = 'Remote')]
param(
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
[Parameter(ParameterSetName = 'Remote')]
[uri] $CatalogUri = 'https://learn.microsoft.com/api/catalog/?locale=en-us',
[Parameter(Mandatory, ParameterSetName = 'File')]
[string] $CatalogPath,
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
if (-not $OutputPath) {
$OutputPath = Join-Path $Root 'coverage/microsoft-learn-developer-catalog.json'
}
function Get-CanonicalUrl {
param([string] $Url)
if ([string]::IsNullOrWhiteSpace($Url)) {
return $null
}
$parsed = [uri]$Url
return "$($parsed.Scheme)://$($parsed.Host)$($parsed.AbsolutePath)".TrimEnd('/')
}
function Get-CatalogProperty {
param(
[object] $Object,
[string] $Name,
[object] $Default = $null
)
$property = $Object.PSObject.Properties[$Name]
if ($property) {
return $property.Value
}
return $Default
}
function Get-LatestTimestamp {
param([object[]] $Values)
$timestamps = @(
$Values |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
ForEach-Object { [datetimeoffset]::Parse([string]$_) } |
Sort-Object
)
if (-not $timestamps.Count) {
return $null
}
return $timestamps[-1].ToUniversalTime().ToString(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
[System.Globalization.CultureInfo]::InvariantCulture
)
}
function Convert-ToUtcTimestamp {
param([object] $Value)
return ([datetimeoffset]$Value).ToUniversalTime().ToString(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
[System.Globalization.CultureInfo]::InvariantCulture
)
}
$catalog = if ($PSCmdlet.ParameterSetName -eq 'File') {
Get-Content -LiteralPath $CatalogPath -Raw | ConvertFrom-Json
} else {
Invoke-RestMethod -Uri $CatalogUri
}
$product = 'dynamics-business-central'
$role = 'developer'
$modules = @(
$catalog.modules |
Where-Object {
@(Get-CatalogProperty $_ 'products' @()) -contains $product -and
@(Get-CatalogProperty $_ 'roles' @()) -contains $role
} |
Sort-Object uid
)
$moduleIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($module in $modules) {
$moduleIds.Add([string]$module.uid) | Out-Null
}
$learningPaths = @(
$catalog.learningPaths |
Where-Object {
$path = $_
$pathModules = @(Get-CatalogProperty $path 'modules' @())
@($pathModules | Where-Object { $moduleIds.Contains([string]$_) }).Count -gt 0 -and
@(Get-CatalogProperty $path 'products' @()) -contains $product -and
@(Get-CatalogProperty $path 'roles' @()) -contains $role
} |
Sort-Object uid
)
$pathIdsByModule = @{}
foreach ($path in $learningPaths) {
foreach ($moduleId in @(Get-CatalogProperty $path 'modules' @())) {
if (-not $moduleIds.Contains([string]$moduleId)) {
continue
}
if (-not $pathIdsByModule.ContainsKey([string]$moduleId)) {
$pathIdsByModule[[string]$moduleId] = [System.Collections.Generic.List[string]]::new()
}
$pathIdsByModule[[string]$moduleId].Add([string]$path.uid)
}
}
$unitById = @{}
foreach ($unit in @(Get-CatalogProperty $catalog 'units' @())) {
$unitById[[string]$unit.uid] = $unit
}
$moduleIdsByUnit = @{}
$moduleById = @{}
$unitIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($module in $modules) {
$moduleById[[string]$module.uid] = $module
foreach ($unitId in @(Get-CatalogProperty $module 'units' @())) {
$unitId = [string]$unitId
$unitIds.Add($unitId) | Out-Null
if (-not $moduleIdsByUnit.ContainsKey($unitId)) {
$moduleIdsByUnit[$unitId] = [System.Collections.Generic.List[string]]::new()
}
$moduleIdsByUnit[$unitId].Add([string]$module.uid)
}
}
$pathRecords = @(
foreach ($path in $learningPaths) {
[ordered]@{
uid = [string]$path.uid
title = [string]$path.title
url = Get-CanonicalUrl ([string]$path.url)
lastModified = if (Get-CatalogProperty $path 'last_modified') {
Convert-ToUtcTimestamp (Get-CatalogProperty $path 'last_modified')
} else {
$null
}
durationInMinutes = [int](Get-CatalogProperty $path 'duration_in_minutes' 0)
moduleUids = @(
@(Get-CatalogProperty $path 'modules' @()) |
Where-Object { $moduleIds.Contains([string]$_) } |
ForEach-Object { [string]$_ } |
Sort-Object -Unique
)
}
}
)
$moduleRecords = @(
foreach ($module in $modules) {
[ordered]@{
uid = [string]$module.uid
title = [string]$module.title
summary = [string](Get-CatalogProperty $module 'summary' '')
url = Get-CanonicalUrl ([string]$module.url)
lastModified = Convert-ToUtcTimestamp (Get-CatalogProperty $module 'last_modified')
durationInMinutes = [int](Get-CatalogProperty $module 'duration_in_minutes' 0)
levels = @(@(Get-CatalogProperty $module 'levels' @()) | ForEach-Object { [string]$_ } | Sort-Object -Unique)
subjects = @(@(Get-CatalogProperty $module 'subjects' @()) | ForEach-Object { [string]$_ } | Sort-Object -Unique)
learningPathUids = if ($pathIdsByModule.ContainsKey([string]$module.uid)) {
@($pathIdsByModule[[string]$module.uid] | Sort-Object -Unique)
} else {
@()
}
unitUids = @(@(Get-CatalogProperty $module 'units' @()) | ForEach-Object { [string]$_ })
}
}
)
$unitRecords = @(
foreach ($unitId in @($unitIds | Sort-Object)) {
if (-not $unitById.ContainsKey($unitId)) {
throw "Catalog module references missing unit '$unitId'."
}
$unit = $unitById[$unitId]
$unitUrl = [string](Get-CatalogProperty $unit 'url' '')
if ([string]::IsNullOrWhiteSpace($unitUrl)) {
$moduleId = [string]@($moduleIdsByUnit[$unitId])[0]
$moduleUrl = Get-CanonicalUrl ([string]$moduleById[$moduleId].url)
$unitSlug = if ($unitId.StartsWith("$moduleId.", [System.StringComparison]::Ordinal)) {
$unitId.Substring($moduleId.Length + 1)
} else {
@($unitId -split '\.')[-1]
}
$unitUrl = "$moduleUrl/$unitSlug"
}
[ordered]@{
uid = $unitId
title = [string]$unit.title
url = Get-CanonicalUrl $unitUrl
lastModified = if (Get-CatalogProperty $unit 'last_modified') {
Convert-ToUtcTimestamp (Get-CatalogProperty $unit 'last_modified')
} else {
$null
}
durationInMinutes = [int](Get-CatalogProperty $unit 'duration_in_minutes' 0)
moduleUids = @($moduleIdsByUnit[$unitId] | Sort-Object -Unique)
}
}
)
$snapshot = [ordered]@{
version = 1
source = [ordered]@{
provider = 'Microsoft Learn'
catalogUri = if ($PSCmdlet.ParameterSetName -eq 'Remote') { [string]$CatalogUri } else { $null }
locale = 'en-us'
filters = [ordered]@{
product = $product
role = $role
}
catalogLastModified = Get-LatestTimestamp @(
@($moduleRecords | ForEach-Object lastModified) +
@($unitRecords | ForEach-Object lastModified) +
@($pathRecords | ForEach-Object lastModified)
)
}
counts = [ordered]@{
learningPaths = $pathRecords.Count
modules = $moduleRecords.Count
units = $unitRecords.Count
}
learningPaths = $pathRecords
modules = $moduleRecords
units = $unitRecords
}
$outputDirectory = Split-Path -Parent $OutputPath
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$snapshot | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
Write-Host "Microsoft Learn developer catalog: $($pathRecords.Count) paths, $($moduleRecords.Count) modules, $($unitRecords.Count) units."
Write-Host "Catalog: $OutputPath"