diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1503ed9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,98 @@ +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" + + # 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@$(cat .prettier-version)" + + - 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/.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/CHANGELOG.md b/CHANGELOG.md index 29796d8..cb5b7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,40 @@ 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. +- 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. + +## [[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/README.md b/README.md index b0239b9..16b1a5e 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) @@ -29,6 +25,7 @@ - [📝 Additional notes](#📝-additional-notes) - [📦 Releasing](#📦-releasing) - [📚 Reference Information](#📚-reference-information) + ## 🏭 Product Life cycle Management (PLM) in Git. @@ -341,14 +338,91 @@ 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`. + +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. -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 +To use the library: + +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 @@ -377,6 +451,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/envsetup.sh b/envsetup.sh index 10232cf..ae6d79d 100644 --- a/envsetup.sh +++ b/envsetup.sh @@ -1,25 +1,95 @@ -# 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 +} + +# 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() { - gofmt -s -w . - prettier --write "**/*.md" + (cd "${GITPLM_BASE}" && gofmt -s -w .) || return 1 + gitplm_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 + gitplm_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/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) } 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) + } + } +}