From 15768c0c84106df4ae8217c4e182e7b8ca4e7b19 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Thu, 16 Jul 2026 15:36:42 -0400 Subject: [PATCH 1/2] fix: accept alphanumeric variations in IPNs The variation segment often codes a value rather than a plain number: 02V5 for 2.5 V, 047n for 47 nH, 8R3m for 8.3 mOhm. reIpn matched four digits only, so those parts failed to parse. Releasing one was refused, the KiCad HTTP API served it without a category, and nextAvailableIPN skipped it when computing the maximum N, which could hand out a number that already existed. parse() now returns V as a string, since it is no longer always a number. newIpnParts and v() took an int variation and had no callers, so they are removed. extractCategory in kicad_api.go parses through the ipn type instead of repeating the format regex. --- CHANGELOG.md | 6 +++++ CLAUDE.md | 6 ++++- csv_data_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++ ipn.go | 53 ++++++++++------------------------------- ipn_test.go | 23 +++++++++++------- kicad_api.go | 14 +++++------ kicad_api_test.go | 42 +++++++++++++++++++++++++++++++++ release.go | 2 +- 8 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 csv_data_test.go create mode 100644 kicad_api_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db518b1..1327db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ For more details or to discuss releases, please visit the ## [Unreleased] +- Parts whose variation codes a value rather than a plain number, such as + `ICS-0047-02V5` for 2.5 V or `RES-0008-8R3m` for 8.3 mOhm, are now accepted + everywhere an IPN is read. Releases of these parts previously failed, the + KiCad HTTP API served them without a category, and the TUI could suggest a + next available IPN that collided with one of them. + ## [0.9.3] - 2026-07-15 - KiCad HTTP API: parts are now named by their IPN, so a placed symbol's library diff --git a/CLAUDE.md b/CLAUDE.md index 332a8e3..5c2fb7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,11 @@ and ASY have recursive BOMs. Purchased: `RES`, `CAP`, `DIO`, `LED`, etc. -Format: `CCC-NNN-VVVV` where N is 3-4 digits, V is always 4 digits. +Format: `CCC-NNN-VVVV` where N is 3-4 digits, and V is 4 alphanumeric +characters. V often codes a value rather than a plain number, such as `02V5` for +2.5 V or `047n` for 47 nH, so its case is significant. `reIpn` in `ipn.go` is +the single definition of this format -- parse IPNs through the `ipn` type rather +than matching the format again elsewhere. ### Data Flow for Release Processing diff --git a/csv_data_test.go b/csv_data_test.go new file mode 100644 index 0000000..257065f --- /dev/null +++ b/csv_data_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +// The highest N in a category may belong to a part whose variation codes a +// value, such as the 02V5 (2.5 V) below. Those parts still have to count +// towards the max N, otherwise the next IPN collides with an existing part. +func TestNextAvailableIPN(t *testing.T) { + tests := []struct { + name string + rows [][]string + want string + }{ + { + name: "digit variations", + rows: [][]string{ + {"ICS-0045-0001"}, + {"ICS-0046-0000"}, + }, + want: "ICS-0047-0001", + }, + { + name: "max N has a value coded variation", + rows: [][]string{ + {"ICS-0045-0001"}, + {"ICS-0046-0000"}, + {"ICS-0047-02V5"}, + }, + want: "ICS-0048-0001", + }, + { + name: "3 digit N is preserved", + rows: [][]string{ + {"PCB-001-0500"}, + }, + want: "PCB-002-0001", + }, + { + name: "rows that are not IPNs are ignored", + rows: [][]string{ + {"ICS-0045-0001"}, + {"not an ipn"}, + {""}, + }, + want: "ICS-0046-0001", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := nextAvailableIPN(test.rows, 0) + if err != nil { + t.Fatalf("nextAvailableIPN() error: %v", err) + } + if got != test.want { + t.Errorf("nextAvailableIPN() = %q, want %q", got, test.want) + } + }) + } +} diff --git a/ipn.go b/ipn.go index c02e02f..f5f6bba 100644 --- a/ipn.go +++ b/ipn.go @@ -11,60 +11,38 @@ import ( type ipn string -var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d\d\d\d)$`) -var reC = regexp.MustCompile(`^[A-Z][A-Z][A-Z]$`) +// reIpn is the single definition of the IPN format: CCC-NNNN-VVVV, and +// CCC-NNN-VVVV for legacy 3-digit N. +// +// VVVV codes a variation and is alphanumeric rather than digits only, because +// it often encodes a value: 02V5 denotes 2.5 V, and 047n denotes 47 nH. Case is +// significant, since SI prefixes such as the m in 8R3m (8.3 mOhm) rely on it. +var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-([0-9A-Za-z]{4})$`) func newIpn(s string) (ipn, error) { _, _, _, err := ipn(s).parse() return ipn(s), err } -func newIpnParts(c string, n, v int) (ipn, error) { - if n < 0 || n > 9999 { - return "", errors.New("N out of range") - } - - if v < 0 || v > 9999 { - return "", errors.New("V out of range") - } - - if len(c) != 3 { - return "", errors.New("C must be 3 chars") - } - - if reC.FindString(c) == "" { - return "", errors.New("C must be in format CCC") - } - - nFmt := "%03v" - if n > 999 { - nFmt = "%04v" - } - return ipn(fmt.Sprintf("%v-"+nFmt+"-%04v", c, n, v)), nil -} - func (i ipn) String() string { return string(i) } -// parse() returns C (category), N (number), V (variation) -func (i ipn) parse() (string, int, int, error) { +// parse() returns C (category), N (number), V (variation). V is returned as a +// string because it is not always a number. +func (i ipn) parse() (string, int, string, error) { groups := reIpn.FindStringSubmatch(string(i)) if len(groups) < 4 { - return "", 0, 0, errors.New("Error parsing ipn") + return "", 0, "", errors.New("Error parsing ipn") } c := groups[1] n, err := strconv.Atoi(groups[2]) if err != nil { - return "", 0, 0, fmt.Errorf("Error parsing N: %v", err) - } - v, err := strconv.Atoi(groups[3]) - if err != nil { - return "", 0, 0, fmt.Errorf("Error parsing V: %v", err) + return "", 0, "", fmt.Errorf("Error parsing N: %v", err) } - return c, n, v, nil + return c, n, groups[3], nil } // nWidth returns the number of digits in the N segment of the IPN @@ -95,11 +73,6 @@ func (i ipn) n() (int, error) { return n, err } -func (i ipn) v() (int, error) { - _, _, v, err := i.parse() - return v, err -} - var ourIPNs = []string{"PCA", "PCB", "ASY", "DOC", "DFW", "DSW", "DCL", "FIX"} func (i ipn) isOurIPN() (bool, error) { diff --git a/ipn_test.go b/ipn_test.go index 3307bed..a42b6de 100644 --- a/ipn_test.go +++ b/ipn_test.go @@ -6,19 +6,26 @@ type ipnTest struct { ipn string c string n int - v int + v string valid bool } func TestIpn(t *testing.T) { tests := []ipnTest{ - {"PCB-001-0500", "PCB", 1, 500, true}, - {"ASY-200-1000", "ASY", 200, 1000, true}, - {"SY-200-1000", "", 0, 0, false}, - {"ASY-0001-0001", "ASY", 1, 1, true}, // 4-digit N - {"PCB-0123-0500", "PCB", 123, 500, true}, // 4-digit N - {"ASY-20-1000", "", 0, 0, false}, - {"ASY-200-100", "", 0, 0, false}, + {"PCB-001-0500", "PCB", 1, "0500", true}, + {"ASY-200-1000", "ASY", 200, "1000", true}, + {"SY-200-1000", "", 0, "", false}, + {"ASY-0001-0001", "ASY", 1, "0001", true}, // 4-digit N + {"PCB-0123-0500", "PCB", 123, "0500", true}, // 4-digit N + {"ICS-0047-02V5", "ICS", 47, "02V5", true}, // V codes 2.5 V + {"CAP-0006-04R7", "CAP", 6, "04R7", true}, // R as a decimal point + {"IND-0005-047n", "IND", 5, "047n", true}, // n codes nH + {"RES-0008-8R3m", "RES", 8, "8R3m", true}, // 8.3 mOhm + {"ASY-20-1000", "", 0, "", false}, // N too short + {"ASY-200-100", "", 0, "", false}, // V too short + {"ASY-200-10000", "", 0, "", false}, // V too long + {"ics-0047-0000", "", 0, "", false}, // C must be upper case + {"ICS-0047-02_5", "", 0, "", false}, // V is alphanumeric only } for _, test := range tests { diff --git a/kicad_api.go b/kicad_api.go index 9067d66..9926332 100644 --- a/kicad_api.go +++ b/kicad_api.go @@ -6,7 +6,6 @@ import ( "log" "net/http" "path/filepath" - "regexp" "sort" "strings" "sync" @@ -238,15 +237,14 @@ func (s *KiCadServer) findColumnIndex(file *CSVFile, columnName string) int { return -1 } -// extractCategory extracts the CCC component from an IPN +// extractCategory extracts the CCC component from an IPN, or returns an empty +// string if the IPN is not in the format described on the ipn type. func (s *KiCadServer) extractCategory(ipnStr string) string { - // IPN format: CCC-NNNN-VVVV (also supports CCC-NNN-VVVV for legacy) - re := regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d{4})$`) - matches := re.FindStringSubmatch(ipnStr) - if len(matches) >= 2 { - return matches[1] + c, err := ipn(ipnStr).c() + if err != nil { + return "" } - return "" + return c } // getCategoryDisplayName returns a human-readable name for a category diff --git a/kicad_api_test.go b/kicad_api_test.go new file mode 100644 index 0000000..4b81ba7 --- /dev/null +++ b/kicad_api_test.go @@ -0,0 +1,42 @@ +package main + +import "testing" + +type extractCategoryTest struct { + ipn string + category string +} + +func TestExtractCategory(t *testing.T) { + tests := []extractCategoryTest{ + {"ICS-0046-0000", "ICS"}, + {"PCB-001-0500", "PCB"}, + {"ASY-0001-0001", "ASY"}, + // VVVV may encode a value such as voltage, so it is alphanumeric + {"ICS-0047-02V5", "ICS"}, + {"REG-0006-03V3", "REG"}, + {"CAP-0006-04R7", "CAP"}, + {"CAP-0003-220M", "CAP"}, + // SI prefixes in the variation are lower case + {"IND-0005-047n", "IND"}, + {"RES-0008-8R3m", "RES"}, + // Malformed IPNs have no category + {"SY-200-1000", ""}, + {"ASY-20-1000", ""}, + {"ASY-200-100", ""}, + {"ASY-200-10000", ""}, + {"ics-0047-0000", ""}, + {"ICS-0047-02V", ""}, + {"ICS-0047-02_5", ""}, + {"", ""}, + } + + s := &KiCadServer{} + + for _, test := range tests { + got := s.extractCategory(test.ipn) + if got != test.category { + t.Errorf("extractCategory(%q) = %q, want %q", test.ipn, got, test.category) + } + } +} diff --git a/release.go b/release.go index 0dc99f7..de69d71 100644 --- a/release.go +++ b/release.go @@ -21,7 +21,7 @@ func processRelease(relPn string, relLog *strings.Builder, pmDir string) (string } relPnBase := relIpn.base() - relPnBaseWithVar := fmt.Sprintf("%v-%02v", relPnBase, v/100) // First two digits of variation + relPnBaseWithVar := fmt.Sprintf("%v-%v", relPnBase, v[:2]) // First two characters of variation bomFile := relPnBase + ".csv" bomFileWithVar := relPnBaseWithVar + ".csv" From e8fdb69bd3b232ce7201bb41f4b84ea7aedf5a0b Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Thu, 16 Jul 2026 15:42:25 -0400 Subject: [PATCH 2/2] v0.9.4 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1327db3..4f5a768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ For more details or to discuss releases, please visit the ## [Unreleased] +## [0.9.4] - 2026-07-16 + - Parts whose variation codes a value rather than a plain number, such as `ICS-0047-02V5` for 2.5 V or `RES-0008-8R3m` for 8.3 mOhm, are now accepted everywhere an IPN is read. Releases of these parts previously failed, the