From 8acf3c2ed080f22ae6bd7d96e6fa975d031d2735 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:17:31 -0400 Subject: [PATCH 1/6] ci: align CI/CD and release automation with yoe Restructure the workflows to match the layout used in yoe: a single CI workflow covering tests, a version-stamped build, gofmt/vet, and the Markdown format check, plus a release workflow pinned to GoReleaser v2. The Go version now comes from go.mod rather than a hardcoded value that had drifted, and the Markdown check runs on pushes to main as well as pull requests. Fix two defects in the release path along the way: - `gitplm update` was broken on Windows. The published assets carry a .exe suffix that getBinaryName never added, so the download 404'd. The naming logic is now a pure function pinned by TestBinaryName against the assets actually published, since the archives name_template in .goreleaser.yml and update.go have to agree. - Releases could ship with empty notes, and v0.8.10 did. The changelog extractor silently emitted nothing when a version had no section; it now fails instead. scripts/prepare-release.sh removes the manual step that caused this by promoting [Unreleased] to a dated section, running the tests, committing, and tagging. It does not push. Restore the 0.8.10 through 0.8.12 changelog sections, which existed at their tags but had been dropped from main, and correct the typo in the 0.8.12 release link. Rewrite envsetup.sh to mirror what CI does, replacing GoReleaser flags removed in v2 and a stale -bom flag that no longer exists. --- .github/workflows/ci.yaml | 95 ++++++++++++++++++ .github/workflows/doc-check.yaml | 27 ----- .github/workflows/go.yml | 41 -------- .../workflows/{release.yml => release.yaml} | 15 +-- .goreleaser.yml | 6 +- CHANGELOG.md | 21 ++++ CLAUDE.md | 51 +++++++++- envsetup.sh | 93 +++++++++++++++--- scripts/extract-changelog.sh | 33 +++++-- scripts/prepare-release.sh | 98 +++++++++++++++++++ update.go | 38 ++++--- update_test.go | 34 +++++++ 12 files changed, 441 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/doc-check.yaml delete mode 100644 .github/workflows/go.yml rename .github/workflows/{release.yml => release.yaml} (63%) create mode 100755 scripts/prepare-release.sh create mode 100644 update_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9749b8f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Tags are needed so the version stamped into the binary matches + # what a release build would produce. + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: | + VERSION=$(git describe --tags HEAD 2>/dev/null || echo "Development") + CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o gitplm . + + - name: Verify version stamp + run: ./gitplm version + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check formatting + run: | + if [ -n "$(gofmt -s -l .)" ]; then + echo "Go code is not formatted. Please run 'gofmt -s -w .'" + gofmt -s -l . + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + format-check: + name: Markdown Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install prettier + run: npm install -g prettier + + - name: Check Markdown formatting + run: prettier --check "**/*.md" diff --git a/.github/workflows/doc-check.yaml b/.github/workflows/doc-check.yaml deleted file mode 100644 index 32d7885..0000000 --- a/.github/workflows/doc-check.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Documentation Check - -on: - pull_request: - -permissions: - contents: read - -jobs: - format-check: - name: Markdown Format Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install prettier - run: npm install -g prettier - - - name: Check Markdown formatting - run: prettier --check "**/*.md" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index be93dd5..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - test: - name: Build and Test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - - - name: Download dependencies - run: go mod download - - - name: Build - run: go build -v -o gitplm . - - - name: Run tests - run: go test -v ./... - - - name: Check formatting - run: | - if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then - echo "Go code is not formatted. Please run 'gofmt -s -w .'" - gofmt -s -l . - exit 1 - fi - - - name: Run go vet - run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yaml similarity index 63% rename from .github/workflows/release.yml rename to .github/workflows/release.yaml index 6ba2653..ae21489 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yaml @@ -3,30 +3,33 @@ name: Release on: push: tags: - - 'v*' + - "v*" permissions: contents: write jobs: - goreleaser: + release: + name: Release runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v4 with: + # GoReleaser needs full history and tags to build the changelog and + # to let scripts/extract-changelog.sh find the tag being released. fetch-depth: 0 - - name: Set up Go + - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: - distribution: goreleaser - version: latest + version: "~> v2" args: release --clean --release-notes .release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index bfa5017..fe36408 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,6 +2,8 @@ version: 2 +project_name: gitplm + before: hooks: - go mod tidy @@ -55,4 +57,6 @@ release: --- - To update, run: `gitplm -update` + To update, run: `gitplm update` + + Or download the binary for your platform below and place it in your PATH. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29796d8..08c72f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ For more details or to discuss releases, please visit the ## [Unreleased] +- `gitplm update` now works on Windows. It was requesting a download URL without + the `.exe` suffix the released Windows binaries carry, so the update failed. + +## [[0.8.12] - 2026-03-02](https://github.com/git-plm/gitplm/releases/tag/v0.8.12) + +- Release process now requires a CHANGELOG.md entry for the IPN version being + released. If the entry is missing, the TUI opens `$EDITOR` so the user can add + it before proceeding. The CLI release command also checks and fails with a + clear error. + +## [[0.8.11] - 2026-03-02](https://github.com/git-plm/gitplm/releases/tag/v0.8.11) + +- TUI: add confirmation prompt before running a release (y/n). Show "Releasing + ..." immediately while the release runs asynchronously. + +## [[0.8.10] - 2026-03-02](https://github.com/git-plm/gitplm/releases/tag/v0.8.10) + +- TUI: release overlay captures log output properly instead of writing to + stderr, constrains width to the terminal, removes timestamps, and truncates + long lines. + ## [[0.8.9] - 2026-03-02](https://github.com/git-plm/gitplm/releases/tag/v0.8.9) - TUI: entering search mode with `/` now immediately reapplies the current diff --git a/CLAUDE.md b/CLAUDE.md index aedac0f..332a8e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,56 @@ go test ./... # Run all tests go test -run TestFunctionName ./... # Run a single test ``` -No Makefile or linter is configured. CI runs `go test ./...` on every push. +Source `envsetup.sh` for helper functions that mirror what CI does: -Cross-platform releases use GoReleaser (see `envsetup.sh` for helper functions). +```bash +. envsetup.sh +gitplm_build # build with the version stamped in via ldflags +gitplm_test # go test ./... +gitplm_format # gofmt -s -w . and prettier --write on Markdown +gitplm_check # everything CI runs; use this before pushing +``` + +## CI + +`.github/workflows/ci.yaml` runs on every push to `main` and every pull request: +tests, a version-stamped build, `gofmt -s` and `go vet`, and a Prettier check of +all Markdown. `gitplm_check` runs the same set locally. + +## Releasing + +Pushing a `v*` tag triggers `.github/workflows/release.yaml`, which runs +GoReleaser to build every platform in `.goreleaser.yml` and publish the binaries +to a GitHub release. The release notes are not generated from commits: they are +the `CHANGELOG.md` section for the version being released, extracted by +`scripts/extract-changelog.sh`. If that section is missing, the release fails +rather than publishing empty notes. + +To cut a release: + +```bash +. envsetup.sh +gitplm_prepare_release 0.8.13 # promotes [Unreleased], commits, tags +git push origin main && git push origin v0.8.13 +``` + +`scripts/prepare-release.sh` refuses to run on a dirty tree, on an existing tag, +or with an empty `[Unreleased]` section, and it runs the tests before tagging. + +Notes on keeping the release path working: + +- **Every user-visible change needs a `CHANGELOG.md` entry under + `[Unreleased]`.** That entry becomes the release notes, so write it for the + user of `gitplm`, not the engineer changing it: one or two sentences leading + with what they can now do or what was broken and is now fixed. Do not list + file paths or function names. Entries sit directly under each other with no + blank line between them; the blank line separates one version section from the + next. +- **The published asset names are a contract with `gitplm update`.** The + `archives` `name_template` in `.goreleaser.yml` names the files attached to + the release, and `binaryName` in `update.go` reconstructs those names to build + the download URL. `TestBinaryName` pins the two together against the names + actually published. Change one and you must change the other. ## Architecture diff --git a/envsetup.sh b/envsetup.sh index 10232cf..bbeda9f 100644 --- a/envsetup.sh +++ b/envsetup.sh @@ -1,25 +1,86 @@ -# download goreleaser from https://github.com/goreleaser/goreleaser/releases/ -# and put in /usr/local/bin -# This can be useful to test/debug the release process locally -gitplm_goreleaser_build() { - goreleaser build --skip-validate --rm-dist +#!/usr/bin/env bash +# this file should be sourced (.), not run as a script + +GITPLM_BASE=$(readlink -f "$(dirname "${BASH_SOURCE[0]:-$0}")") + +# Build the binary with the version stamped in, the same way CI and the release +# build do, so `gitplm version` reports something meaningful. +gitplm_build() { + local version + version=$(cd "${GITPLM_BASE}" && git describe --tags --always --dirty 2>/dev/null || echo "Development") + (cd "${GITPLM_BASE}" && CGO_ENABLED=0 go build -ldflags "-X main.version=${version}" -o "${GITPLM_BASE}/gitplm" .) || return 1 +} + +gitplm_test() { + (cd "${GITPLM_BASE}" && go test ./...) || return 1 } -# before releasing, you need to tag the release -# you need to provide GITHUB_TOKEN in env or ~/.config/goreleaser/github_token -# generate tokens: https://github.com/settings/tokens/new -# enable repo and workflow sections -gitplm_goreleaser_release() { - goreleaser release --rm-dist +gitplm_vet() { + (cd "${GITPLM_BASE}" && go vet ./...) || return 1 } gitplm_format() { - gofmt -s -w . - prettier --write "**/*.md" + (cd "${GITPLM_BASE}" && gofmt -s -w .) || return 1 + (cd "${GITPLM_BASE}" && prettier --write "**/*.md") || return 1 +} + +gitplm_format_check() { + local unformatted + unformatted=$(cd "${GITPLM_BASE}" && gofmt -s -l .) + if [ -n "${unformatted}" ]; then + echo "Go code is not formatted. Run gitplm_format:" + echo "${unformatted}" + return 1 + fi + (cd "${GITPLM_BASE}" && prettier --check "**/*.md") || return 1 +} + +# Everything CI runs. Use this before pushing. +gitplm_check() { + gitplm_test || return 1 + gitplm_build || return 1 + gitplm_format_check || return 1 + gitplm_vet || return 1 + echo "=== all checks passed ===" +} + +# --- Releasing ---------------------------------------------------------------- +# +# Releases are cut by pushing a tag. The Release workflow +# (.github/workflows/release.yaml) then runs GoReleaser, which builds every +# platform and publishes the CHANGELOG section for that version as the release +# notes. +# +# 1. Land the changes to release on main, each with a CHANGELOG entry under +# [Unreleased]. +# 2. gitplm_prepare_release 0.8.13 # promotes the changelog, commits, tags +# 3. git push origin main && git push origin v0.8.13 +# +# Promote the [Unreleased] changelog section to a version, commit it, and create +# the tag. This does not push; it prints the push commands to run. +gitplm_prepare_release() { + (cd "${GITPLM_BASE}" && ./scripts/prepare-release.sh "$@") || return 1 +} + +# Preview the release notes GoReleaser would publish for a version. +gitplm_release_notes() { + (cd "${GITPLM_BASE}" && ./scripts/extract-changelog.sh "${1:-$(git describe --tags --abbrev=0)}") || return 1 +} + +# Build release artifacts locally without publishing, to check that every +# platform in .goreleaser.yml still compiles. Install goreleaser from +# https://github.com/goreleaser/goreleaser/releases into /usr/local/bin. +gitplm_goreleaser_build() { + (cd "${GITPLM_BASE}" && goreleaser build --snapshot --clean) || return 1 } +# Regenerate the example releases in example/. Run after changing release +# processing so the checked-in example output stays current. gitplm_update_examples() { - for bom in ASY-012-0012 ASY-002-0001 PCA-019-0000 ASY-001-0000; do - go run . -bom $bom || return - done + gitplm_build || return 1 + local ipn + for ipn in ASY-001-0000 PCA-019-0000; do + echo "=== release ${ipn} ===" + (cd "${GITPLM_BASE}/example" && "${GITPLM_BASE}/gitplm" release "${ipn}") || return 1 + done } diff --git a/scripts/extract-changelog.sh b/scripts/extract-changelog.sh index dac0324..f91ae55 100755 --- a/scripts/extract-changelog.sh +++ b/scripts/extract-changelog.sh @@ -1,19 +1,27 @@ #!/bin/bash -# Extract the changelog section for a specific version from CHANGELOG.md +# Extract the changelog section for a specific version from CHANGELOG.md. # Usage: extract-changelog.sh +# +# The release workflow feeds this script's output to GoReleaser as the release +# notes, so a missing section is treated as an error rather than silently +# producing an empty release body. -VERSION=$1 +set -euo pipefail + +VERSION=${1:-} if [ -z "$VERSION" ]; then - echo "Usage: $0 " + echo "Usage: $0 " >&2 exit 1 fi # Remove 'v' prefix if present VERSION=${VERSION#v} -# Extract the section for this version -awk -v version="$VERSION" ' +# Extract the section for this version. Version headings are written as +# "## [[0.8.9] - 2026-03-02](https://.../releases/tag/v0.8.9)", so match on the +# bracketed version and stop at the next "## [" heading. +NOTES=$(awk -v version="$VERSION" ' /^## \[/ { if (found) exit if ($0 ~ "\\[" version "\\]") { @@ -23,4 +31,17 @@ awk -v version="$VERSION" ' } found && /^## \[/ { exit } found { print } -' CHANGELOG.md +' CHANGELOG.md) + +if [ -z "${NOTES//[[:space:]]/}" ]; then + cat >&2 < e.g. scripts/prepare-release.sh 0.8.13 +# +# Pushing is left to you. Once the tag is pushed, the Release workflow builds +# the binaries with GoReleaser and publishes them with the changelog section as +# the release notes. + +set -euo pipefail + +REPO_URL="https://github.com/git-plm/gitplm" + +VERSION=${1:-} + +if [ -z "$VERSION" ]; then + echo "Usage: $0 (e.g. $0 0.8.13)" >&2 + exit 1 +fi + +# Accept either 0.8.13 or v0.8.13; normalize to both forms. +VERSION=${VERSION#v} +TAG="v${VERSION}" + +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: version must look like 1.2.3 (got '${VERSION}')" >&2 + exit 1 +fi + +cd "$(git rev-parse --show-toplevel)" + +if [ -n "$(git status --porcelain)" ]; then + echo "error: working tree has uncommitted changes. Commit or stash them first." >&2 + git status --short >&2 + exit 1 +fi + +if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "error: tag ${TAG} already exists." >&2 + exit 1 +fi + +if grep -q "\[${VERSION}\]" CHANGELOG.md; then + echo "error: CHANGELOG.md already has a section for ${VERSION}." >&2 + exit 1 +fi + +# The [Unreleased] section must describe something, otherwise the release would +# ship with empty notes. +UNRELEASED=$(awk ' +/^## \[Unreleased\]/ { found=1; next } +found && /^## \[/ { exit } +found { print } +' CHANGELOG.md) + +if [ -z "${UNRELEASED//[[:space:]]/}" ]; then + echo "error: CHANGELOG.md [Unreleased] section is empty; nothing to release." >&2 + exit 1 +fi + +DATE=$(date +%Y-%m-%d) +HEADING="## [[${VERSION}] - ${DATE}](${REPO_URL}/releases/tag/${TAG})" + +echo "Preparing ${TAG} with these notes:" +echo "$UNRELEASED" +echo + +# Promote [Unreleased]: leave the heading in place for the next cycle and insert +# the new version heading directly above the entries it now covers. +awk -v heading="$HEADING" ' +/^## \[Unreleased\]/ && !done { + print + print "" + print heading + done=1 + next +} +{ print } +' CHANGELOG.md > CHANGELOG.md.tmp +mv CHANGELOG.md.tmp CHANGELOG.md + +# Keep the changelog conformant with the Markdown format check in CI. +if command -v prettier >/dev/null 2>&1; then + prettier --write CHANGELOG.md >/dev/null +fi + +# A release that does not build is worse than a late one. +go test ./... >/dev/null + +git add CHANGELOG.md +git commit -q -m "${TAG}" +git tag -a "${TAG}" -m "${TAG}" + +echo "Committed CHANGELOG and tagged ${TAG}." +echo +echo "Review with: git show ${TAG}" +echo "Then publish: git push origin main && git push origin ${TAG}" diff --git a/update.go b/update.go index 9adc6ef..4d53046 100644 --- a/update.go +++ b/update.go @@ -57,7 +57,7 @@ func CheckForUpdate(currentVersion string) string { latest := strings.TrimPrefix(latestVersion, "v") if latest != current { - return fmt.Sprintf("A new version of gitplm is available (v%s). Run 'gitplm -update' to upgrade.", latest) + return fmt.Sprintf("A new version of gitplm is available (v%s). Run 'gitplm update' to upgrade.", latest) } return "" @@ -178,28 +178,42 @@ func downloadAndInstall(version string) error { } func getBinaryName(version string) string { - goos := runtime.GOOS - goarch := runtime.GOARCH + goarm := "" + if runtime.GOARCH == "arm" { + goarm = "7" + if v := os.Getenv("GOARM"); v != "" { + goarm = v + } + } + return binaryName(version, runtime.GOOS, runtime.GOARCH, goarm) +} + +// binaryName builds the name of the release asset for a platform. It must agree +// with the archives name_template in .goreleaser.yml, which is what names the +// files attached to the GitHub release. +func binaryName(version, goos, goarch, goarm string) string { osName := goos if goos == "darwin" { osName = "macos" } archName := goarch - if goarch == "amd64" { + switch goarch { + case "amd64": archName = "x86_64" - } else if goarch == "386" { + case "386": archName = "i386" + case "arm": + archName = "arm" + goarm } - armVersion := "" - if goarch == "arm" { - armVersion = "7" - if v := os.Getenv("GOARM"); v != "" { - armVersion = v - } + // GoReleaser appends the binary extension on Windows, so the published + // asset is gitplm-v1.2.3-windows-x86_64.exe. + ext := "" + if goos == "windows" { + ext = ".exe" } - return fmt.Sprintf("gitplm-%s-%s-%s%s", version, osName, archName, armVersion) + return fmt.Sprintf("gitplm-%s-%s-%s%s", version, osName, archName, ext) } diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..96d606b --- /dev/null +++ b/update_test.go @@ -0,0 +1,34 @@ +package main + +import "testing" + +// The expected names are the assets actually published for v0.8.12. If this +// test fails, `gitplm update` is downloading a URL that does not exist, so keep +// it in step with the archives name_template in .goreleaser.yml. +func TestBinaryName(t *testing.T) { + tests := []struct { + goos string + goarch string + goarm string + want string + }{ + {"linux", "amd64", "", "gitplm-v0.8.12-linux-x86_64"}, + {"linux", "386", "", "gitplm-v0.8.12-linux-i386"}, + {"linux", "arm64", "", "gitplm-v0.8.12-linux-arm64"}, + {"linux", "arm", "6", "gitplm-v0.8.12-linux-arm6"}, + {"linux", "arm", "7", "gitplm-v0.8.12-linux-arm7"}, + {"darwin", "amd64", "", "gitplm-v0.8.12-macos-x86_64"}, + {"darwin", "arm64", "", "gitplm-v0.8.12-macos-arm64"}, + {"windows", "amd64", "", "gitplm-v0.8.12-windows-x86_64.exe"}, + {"windows", "386", "", "gitplm-v0.8.12-windows-i386.exe"}, + {"windows", "arm64", "", "gitplm-v0.8.12-windows-arm64.exe"}, + } + + for _, test := range tests { + got := binaryName("v0.8.12", test.goos, test.goarch, test.goarm) + if got != test.want { + t.Errorf("binaryName(v0.8.12, %v, %v, %v) = %v; want %v", + test.goos, test.goarch, test.goarm, got, test.want) + } + } +} From 73e8debdfb284e938a71fd16878c981efb833264 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:18:24 -0400 Subject: [PATCH 2/6] feat: control KiCad field visibility and reload CSVs on change Serving a part to KiCad left every CSV column visible on the schematic, so placing a symbol surrounded it with its IPN, MPN, datasheet URL, and every parametric value. KiCad displays a field whose visibility is unspecified, and the HTTP library API never set it. Fields are now served hidden, and an http.fields section in gitplm.yml states the exceptions for each IPN category: the column that populates KiCad's built-in Value field, the columns KiCad displays, and any column served under a different field name. A category's settings are applied on top of a default section shared by all categories, so a category only carries what it changes. These settings say the same thing as the field definitions in a KiCad database library (.kicad_dbl), which makes the two configurations easy to keep in step. The server also watches the partmaster directory and reloads the CSV files when one of them changes, printing a message to the console, so edits reach KiCad without a restart. The whole directory is reloaded rather than the file that changed, since editors and Git write through temporary files and renames. A reload that fails leaves the previously loaded data in place. The CSV collection is guarded by a mutex and replaced wholesale, so requests in flight keep reading the collection they started with. --- CHANGELOG.md | 11 +++ README.md | 63 ++++++++++++++++ config.go | 59 +++++++++++++++ go.mod | 1 + go.sum | 2 + kicad_api.go | 207 ++++++++++++++++++++++++++++++++++++++++++++------- main.go | 12 ++- 7 files changed, 328 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c72f4..3d22a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ For more details or to discuss releases, please visit the ## [Unreleased] +- KiCad HTTP API: the server now watches the partmaster directory and reloads + the CSV files when one of them changes, printing a message to the console. + Edits reach KiCad without restarting the server. +- KiCad HTTP API: fields are now served hidden unless configured otherwise. + KiCad displays any field whose visibility is unspecified, so every CSV column + appeared on the schematic when a symbol was placed. +- KiCad HTTP API: added an `http.fields` section to `gitplm.yml` that says, per + IPN category, which column populates KiCad's `Value` field (`value`), which + columns KiCad displays (`visible`), and which are served under a different + field name (`rename`). A category's settings are applied on top of a `default` + section shared by all categories. - `gitplm update` now works on Windows. It was requesting a download URL without the `.exe` suffix the released Windows binaries carry, so the update failed. diff --git a/README.md b/README.md index b0239b9..6cf55a0 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,54 @@ Then run `gitplm http` to start the server with configured settings. ### Configuring what fields are visible +Every column in a part's CSV row is served to KiCad as a hidden field. KiCad +displays a field on the schematic unless it is told otherwise, so hiding by +default keeps a placed symbol from being surrounded by its IPN, MPN, datasheet +URL, and every parametric value. + +The `http.fields` section of `gitplm.yml` states the exceptions for each IPN +category: + +```yaml +pmDir: database + +http: + port: 7654 + fields: + # `default` applies to every category + default: + value: MPN # KiCad's built-in Value field comes from the MPN column + visible: [MPN] # the MPN is displayed on the schematic + rename: # served under a different KiCad field name + Sim_Library: Sim.Library + Sim_Name: Sim.Name + + # a category's settings are applied on top of the default + RES: + value: Resistance + visible: [Resistance, Tolerance, Power] + + CAP: + value: Capacitance + visible: [Capacitance, Voltage, Material, Tolerance] +``` + +- `value` names the column that populates KiCad's built-in `Value` field. +- `visible` lists the columns KiCad displays when the symbol is placed. Every + other column is served hidden, so a category that displays its parametric + columns does not have to hide the MPN the default displays: naming its own + list replaces the default's. `visible: []` displays nothing. +- `rename` serves a column under a different KiCad field name. Renames add to + the default's rather than replacing them. +- `visible` and `rename` are keyed by CSV column name, not by KiCad field name. +- `Symbol` is always served as the symbol ID rather than as a field. +- Categories that need nothing beyond the default can be left out entirely. + +If you also maintain a KiCad database library (`.kicad_dbl`), these settings say +the same thing as its `fields` definitions: `value` is the column mapped to the +`Value` field, `visible` the columns with `visible_on_add`, and `rename` those +whose `name` differs from their `column`. + ### Configuring KiCad To use GitPLM as a parts library in KiCad: @@ -377,6 +425,21 @@ GitPLM automatically: - Maps parts to appropriate KiCad symbols - Serves part data with all fields from the CSV (Description, Value, MPN, etc.) +### Reloading on changes + +The server watches the partmaster directory and reloads the CSV files whenever +one of them changes, so edits made in the TUI, in an editor, or by a Git +checkout reach KiCad without restarting the server. Each reload prints a line to +the console: + +``` +2026/07/14 16:04:26 Change detected in g-res.csv - reloaded 23 CSV files, 1697 parts +``` + +Refresh the library in KiCad to pick the new data up. If a reload fails, for +instance while a file is partially written, the server reports the error and +keeps serving the data it already had. + ## 💡 Examples See the examples folder. You can run commands like to exercise GitPLM: diff --git a/config.go b/config.go index a416518..444b806 100644 --- a/config.go +++ b/config.go @@ -3,14 +3,73 @@ package main import ( "os" "path/filepath" + "strings" "gopkg.in/yaml.v2" ) +// FieldConfig says how the CSV columns of one IPN category are presented to +// KiCad. Every column is served hidden under its own name, so a category only +// states its exceptions: +// +// Value the column that populates KiCad's built-in Value field +// Visible the columns KiCad displays on the schematic; all others are hidden +// Rename columns served under a different KiCad field name +// +// Visible and Rename are keyed by CSV column name, not by KiCad field name. +type FieldConfig struct { + Value string `yaml:"value"` + Visible []string `yaml:"visible"` + Rename map[string]string `yaml:"rename"` +} + type HTTPConfig struct { Enabled bool `yaml:"enabled"` Port int `yaml:"port"` Token string `yaml:"token"` + // Fields configures the fields served for each IPN category (RES, CAP, + // ...). The "default" key applies to every category, and a category's own + // settings are applied on top of it. + Fields map[string]FieldConfig `yaml:"fields"` +} + +// FieldsForCategory returns the field configuration for a category: the +// "default" settings with the category's own applied on top. A category +// replaces the default's value column and visible list outright, and adds to +// its renames. +func (h HTTPConfig) FieldsForCategory(category string) FieldConfig { + merged := h.Fields["default"] + + fields, ok := h.Fields[strings.ToUpper(category)] + if !ok { + fields, ok = h.Fields[strings.ToLower(category)] + } + if !ok { + return merged + } + + if fields.Value != "" { + merged.Value = fields.Value + } + + // A category that lists no visible columns of its own inherits the + // default's. `visible: []` is a category that displays nothing. + if fields.Visible != nil { + merged.Visible = fields.Visible + } + + if len(fields.Rename) > 0 { + renames := make(map[string]string, len(merged.Rename)+len(fields.Rename)) + for column, name := range merged.Rename { + renames[column] = name + } + for column, name := range fields.Rename { + renames[column] = name + } + merged.Rename = renames + } + + return merged } type Config struct { diff --git a/go.mod b/go.mod index ec35e57..13f8e12 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/otiai10/copy v1.9.0 github.com/samber/lo v1.33.0 diff --git a/go.sum b/go.sum index 1a43b4b..aad6b3a 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/kicad_api.go b/kicad_api.go index 4661b21..106649a 100644 --- a/kicad_api.go +++ b/kicad_api.go @@ -5,10 +5,14 @@ import ( "fmt" "log" "net/http" + "path/filepath" "regexp" "sort" "strings" + "sync" + "time" + "github.com/fsnotify/fsnotify" "github.com/samber/lo" ) @@ -51,16 +55,29 @@ type KiCadRootResponse struct { // KiCadServer represents the KiCad HTTP API server type KiCadServer struct { - pmDir string + pmDir string + token string + httpConfig HTTPConfig + + // csvCollection is replaced wholesale when the CSV files change, so + // requests in flight keep reading the collection they started with + mu sync.RWMutex csvCollection *CSVFileCollection - token string +} + +// collection returns the CSV data currently being served +func (s *KiCadServer) collection() *CSVFileCollection { + s.mu.RLock() + defer s.mu.RUnlock() + return s.csvCollection } // NewKiCadServer creates a new KiCad HTTP API server -func NewKiCadServer(pmDir, token string) (*KiCadServer, error) { +func NewKiCadServer(pmDir, token string, httpConfig HTTPConfig) (*KiCadServer, error) { server := &KiCadServer{ - pmDir: pmDir, - token: token, + pmDir: pmDir, + token: token, + httpConfig: httpConfig, } // Load CSV collection data @@ -82,7 +99,86 @@ func (s *KiCadServer) loadCSVCollection() error { return fmt.Errorf("failed to load CSV files from %s: %w", s.pmDir, err) } + s.mu.Lock() s.csvCollection = collection + s.mu.Unlock() + + return nil +} + +// watchCSVFiles reloads the CSV data whenever a file in the partmaster +// directory changes, so edits reach KiCad without restarting the server. +// Editors and Git tend to emit several events for one logical change, and +// write the new contents through a temporary file, so events are coalesced and +// the whole directory is reloaded rather than the single file that changed. +func (s *KiCadServer) watchCSVFiles() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %w", err) + } + + if err := watcher.Add(s.pmDir); err != nil { + watcher.Close() + return fmt.Errorf("failed to watch %s: %w", s.pmDir, err) + } + + go func() { + defer watcher.Close() + + var ( + pending = make(<-chan time.Time) // nil until a change arrives + timer *time.Timer + changed string + settling = 200 * time.Millisecond + ) + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if !strings.EqualFold(filepath.Ext(event.Name), ".csv") { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { + continue + } + + changed = filepath.Base(event.Name) + if timer == nil { + timer = time.NewTimer(settling) + } else { + timer.Reset(settling) + } + pending = timer.C + + case <-pending: + pending = make(<-chan time.Time) + + if err := s.loadCSVCollection(); err != nil { + log.Printf("Change detected in %s, but reloading failed: %v", changed, err) + log.Printf("Continuing to serve the previously loaded data") + continue + } + + collection := s.collection() + parts := 0 + for _, file := range collection.Files { + parts += len(file.Rows) + } + log.Printf("Change detected in %s - reloaded %d CSV files, %d parts", + changed, len(collection.Files), parts) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("File watcher error: %v", err) + } + } + }() + return nil } @@ -102,7 +198,7 @@ func (s *KiCadServer) getCategories() []KiCadCategory { categoryMap := make(map[string]bool) // Extract categories from CSV files - use IPNs from each file - for _, file := range s.csvCollection.Files { + for _, file := range s.collection().Files { // Extract from IPNs if they exist if ipnIdx := s.findColumnIndex(file, "IPN"); ipnIdx >= 0 { for _, row := range file.Rows { @@ -265,7 +361,7 @@ func (s *KiCadServer) getCategoryDescription(category string) string { func (s *KiCadServer) getPartsByCategory(categoryID string) []KiCadPartSummary { var parts []KiCadPartSummary - for _, file := range s.csvCollection.Files { + for _, file := range s.collection().Files { // Check if this file belongs to the category fileName := strings.TrimSuffix(strings.ToUpper(file.Name), ".CSV") fileCategory := "" @@ -321,9 +417,68 @@ func (s *KiCadServer) getPartsByCategory(categoryID string) []KiCadPartSummary { return parts } +// kicadBool renders a bool the way the KiCad HTTP library API expects it: a +// string, since the API carries all values as strings. +func kicadBool(b bool) string { + if b { + return "True" + } + return "False" +} + +// buildFields converts a part's CSV columns into KiCad fields, following the +// field configuration for the part's category. +// +// Every column is served hidden under its own name, since KiCad displays any +// field whose visibility is unspecified and a schematic covered in IPNs and +// datasheet URLs is rarely what is wanted. A category's configuration then +// names the columns KiCad displays, the column that populates the built-in +// Value field, and any column served under a different KiCad field name. +func (s *KiCadServer) buildFields(category string, headers []string, values map[string]string) map[string]KiCadPartField { + config := s.httpConfig.FieldsForCategory(category) + + visible := make(map[string]bool, len(config.Visible)) + for _, column := range config.Visible { + visible[column] = true + } + + fields := make(map[string]KiCadPartField) + + for _, header := range headers { + // Symbol is served as symbolIdStr rather than as a field + if header == "Symbol" { + continue + } + value, exists := values[header] + if !exists { + continue + } + + name := header + if renamed, ok := config.Rename[header]; ok { + name = renamed + } + + fields[name] = KiCadPartField{ + Value: value, + Visible: kicadBool(visible[header]), + } + } + + // KiCad's built-in Value field, populated from the configured column + if value, exists := values[config.Value]; exists && config.Value != "" { + fields["Value"] = KiCadPartField{ + Value: value, + Visible: kicadBool(visible["Value"]), + } + } + + return fields +} + // getPartDetail returns detailed information for a specific part func (s *KiCadServer) getPartDetail(partID string) *KiCadPartDetail { - for _, file := range s.csvCollection.Files { + for _, file := range s.collection().Files { ipnIdx := s.findColumnIndex(file, "IPN") for _, row := range file.Rows { @@ -338,29 +493,20 @@ func (s *KiCadServer) getPartDetail(partID string) *KiCadPartDetail { } if rowPartID == partID { - fields := make(map[string]KiCadPartField) - partName := "" - symbolID := "" category := s.extractCategory(partID) - // Add all fields from the CSV dynamically + // Collect the row's non-empty columns by header name + values := make(map[string]string) for i, header := range file.Headers { if i < len(row) && row[i] != "" && header != "" { - // Set name from Description field - if header == "Description" { - partName = row[i] - } - - // Set symbol from Symbol field - if header == "Symbol" { - symbolID = row[i] - } else { - // Add field to fields map (exclude Symbol as it goes in symbolIdStr) - fields[header] = KiCadPartField{Value: row[i]} - } + values[header] = row[i] } } + partName := values["Description"] + symbolID := values["Symbol"] + fields := s.buildFields(category, file.Headers, values) + // Error if no Symbol field found if symbolID == "" { log.Printf("ERROR: Part %s has no Symbol field defined", partID) @@ -503,12 +649,21 @@ func getScheme(r *http.Request) string { } // StartKiCadServer starts the KiCad HTTP API server -func StartKiCadServer(pmDir, token string, port int) error { - server, err := NewKiCadServer(pmDir, token) +func StartKiCadServer(pmDir, token string, port int, httpConfig HTTPConfig) error { + server, err := NewKiCadServer(pmDir, token, httpConfig) if err != nil { return fmt.Errorf("failed to create KiCad server: %w", err) } + // Serving stale data is worse than not watching at all, so a watcher that + // cannot start is reported rather than ignored, and the server carries on + if err := server.watchCSVFiles(); err != nil { + log.Printf("Warning: not watching for CSV changes: %v", err) + log.Printf("Restart the server to pick up edits to the partmaster") + } else { + log.Printf("Watching %s for CSV changes", pmDir) + } + // Set up routes http.HandleFunc("/v1/", server.rootHandler) http.HandleFunc("/v1/categories.json", server.categoriesHandler) diff --git a/main.go b/main.go index 074dce6..88f1f4b 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,10 @@ import ( "log" "os" "path/filepath" + "sort" "strings" + + "github.com/samber/lo" ) var version = "Development" @@ -236,8 +239,15 @@ func cmdHTTP(args []string) { } else { log.Printf("No authentication token specified - server will be open") } + if len(config.HTTP.Fields) > 0 { + categories := lo.Keys(config.HTTP.Fields) + sort.Strings(categories) + log.Printf("Field mappings configured for: %s", strings.Join(categories, ", ")) + } else { + log.Printf("No field mappings configured - all CSV columns served hidden") + } - err = StartKiCadServer(*flagPMDir, *flagToken, *flagPort) + err = StartKiCadServer(*flagPMDir, *flagToken, *flagPort, config.HTTP) if err != nil { log.Fatal("Error starting HTTP server: ", err) } From 17086688ee6cdebc5c249acdd95dbbf3d6ab81df Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:25:19 -0400 Subject: [PATCH 3/6] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d22a60..cb5b7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ For more details or to discuss releases, please visit the ## [Unreleased] +## [0.9.0] - 2026-07-14 + - KiCad HTTP API: the server now watches the partmaster directory and reloads the CSV files when one of them changes, printing a message to the console. Edits reach KiCad without restarting the server. From 62afa7eedcb49930f4e588a12dc048e3f2f17101 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:28:15 -0400 Subject: [PATCH 4/6] ci: pin prettier so local and CI formatting agree The Markdown format check installed the latest prettier on every run, while contributors ran whatever version they happened to have. Prettier 3.9 added a rule requiring a blank line before the closing TOC marker, so CI began failing README.md on a section no one had touched, and `gitplm_check` still passed locally against 3.8. The version now lives in .prettier-version. CI installs that version, and the envsetup helpers run it through npx, so a local check reaches the same verdict as CI and a new prettier release cannot fail a build on its own. README.md is reformatted with the pinned version. --- .github/workflows/ci.yaml | 5 ++++- .prettier-version | 1 + README.md | 1 + envsetup.sh | 13 +++++++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .prettier-version diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9749b8f..1503ed9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,8 +88,11 @@ jobs: with: node-version: "20" + # Pinned so a prettier release cannot start failing files that were + # passing. envsetup.sh reads the same file, so gitplm_check locally + # reaches the same verdict as CI. - name: Install prettier - run: npm install -g prettier + run: npm install -g "prettier@$(cat .prettier-version)" - name: Check Markdown formatting run: prettier --check "**/*.md" diff --git a/.prettier-version b/.prettier-version new file mode 100644 index 0000000..11aaa06 --- /dev/null +++ b/.prettier-version @@ -0,0 +1 @@ +3.9.5 diff --git a/README.md b/README.md index 6cf55a0..f9d886d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - [📝 Additional notes](#📝-additional-notes) - [📦 Releasing](#📦-releasing) - [📚 Reference Information](#📚-reference-information) + ## 🏭 Product Life cycle Management (PLM) in Git. diff --git a/envsetup.sh b/envsetup.sh index bbeda9f..ae6d79d 100644 --- a/envsetup.sh +++ b/envsetup.sh @@ -19,9 +19,18 @@ gitplm_vet() { (cd "${GITPLM_BASE}" && go vet ./...) || return 1 } +# Run the version of prettier pinned in .prettier-version, so formatting +# locally reaches the same verdict as CI. A prettier release can add a rule and +# start failing a file that was passing. +gitplm_prettier() { + local version + version=$(cat "${GITPLM_BASE}/.prettier-version") || return 1 + (cd "${GITPLM_BASE}" && npx --yes "prettier@${version}" "$@") || return 1 +} + gitplm_format() { (cd "${GITPLM_BASE}" && gofmt -s -w .) || return 1 - (cd "${GITPLM_BASE}" && prettier --write "**/*.md") || return 1 + gitplm_prettier --write "**/*.md" || return 1 } gitplm_format_check() { @@ -32,7 +41,7 @@ gitplm_format_check() { echo "${unformatted}" return 1 fi - (cd "${GITPLM_BASE}" && prettier --check "**/*.md") || return 1 + gitplm_prettier --check "**/*.md" || return 1 } # Everything CI runs. Use this before pushing. From b821e9fd910a74a4c83f2000a35faef86f42b068 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:35:48 -0400 Subject: [PATCH 5/6] remove stats that don't work anymore --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index f9d886d..bc0abd0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ ![gitplm logo](gitplm-logo.png) -[![Go](https://github.com/git-plm/gitplm/workflows/Go/badge.svg?branch=main)](https://github.com/git-plm/gitplm/actions) -![code stats](https://tokei.rs/b1/github/git-plm/gitplm?category=code) -[![Go Report Card](https://goreportcard.com/badge/github.com/git-plm/gitplm)](https://goreportcard.com/report/github.com/git-plm/gitplm) - - [🏭 Product Life cycle Management (PLM) in Git.](#🏭-product-life-cycle-management-plm-in-git) From ae84d879a674bc8b073fed4dd3202c26b3f3be50 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Tue, 14 Jul 2026 16:39:33 -0400 Subject: [PATCH 6/6] update --- README.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bc0abd0..16b1a5e 100644 --- a/README.md +++ b/README.md @@ -386,14 +386,43 @@ the same thing as its `fields` definitions: `value` is the column mapped to the `Value` field, `visible` the columns with `visible_on_add`, and `rename` those whose `name` differs from their `column`. +The [`gitplm.yml`](https://github.com/git-plm/parts/blob/main/gitplm.yml) in the +[parts](https://github.com/git-plm/parts) repository is a complete working +example: a `default` section covering most categories, with resistors, +capacitors, inductors, and the rest displaying their parametric columns instead. + ### Configuring KiCad -To use GitPLM as a parts library in KiCad: +KiCad finds the server through a `.kicad_httplib` file that points at it. There +is one in this repository ([`gitplm.kicad_httplib`](gitplm.kicad_httplib)), and +another alongside the parts database it serves: +[`gplm.kicad_httplib`](https://github.com/git-plm/parts/blob/main/database/gplm.kicad_httplib). + +```json +{ + "meta": { "version": 1.0 }, + "name": "GitPLM Parts", + "description": "Parts database served over the KiCad HTTP Library API", + "source": { + "type": "REST_API", + "api_version": "v1", + "root_url": "http://localhost:7654", + "token": "", + "timeout_parts_seconds": 60, + "timeout_categories_seconds": 60 + } +} +``` + +`root_url` leaves off the `/v1` suffix, which KiCad appends from `api_version`. +Set `token` to match the server's `-token` when one is configured. + +To use the library: -1. Open KiCad and go to **Preferences → Configure Paths** -2. Add a new HTTP library with the URL: `http://localhost:7654/v1/` -3. If you configured an authentication token, add it in the library settings -4. The parts will now be available in the Symbol Chooser +1. Start the server with `gitplm http` +2. In KiCad, open **Preferences → Manage Symbol Libraries** +3. Add the `.kicad_httplib` file as a library +4. The parts are now available in the Symbol Chooser ### API Endpoints