From ffd7f36275147773eeb24ed082d70d361c1c4b80 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Thu, 26 Jun 2025 22:01:55 -0400 Subject: [PATCH 01/13] update CL --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5853d08..b529645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ For more details or to discuss releases, please visit the ';'. It turns out that anything besides ',' introduces a lot of friction in using other tools like LibreOffice. - breaking change: switch to using space for reference delimiters (was ',') +- improve error handling ## [[0.4.0] - 2024-02-02](https://github.com/git-plm/gitplm/releases/tag/v0.4.0) From 5274446fc48e79d52613b7568297d4287b827349 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 30 Jun 2025 21:01:51 -0400 Subject: [PATCH 02/13] add yaml config file --- README.md | 17 +++++++++++++++++ config.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 9 ++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 config.go diff --git a/README.md b/README.md index 26e1d0c..1df75d2 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,23 @@ Usage of gitplm: display version of this application ``` +## Configuration + +GitPLM supports configuration via YAML files. The tool will look for configuration files in the following order: + +1. Current directory: `gitplm.yaml`, `gitplm.yml`, `.gitplm.yaml`, `.gitplm.yml` +2. Home directory: `~/.gitplm.yaml`, `~/.gitplm.yml` + +Example configuration file: + +```yaml +pmDir: /path/to/partmaster/directory +``` + +Available configuration options: + +- `pmDir`: Specifies the directory containing the partmaster.csv file + ## Part Numbers Each part used to make a product is defined by a diff --git a/config.go b/config.go new file mode 100644 index 0000000..fdec443 --- /dev/null +++ b/config.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +type Config struct { + PMDir string `yaml:"pmDir"` +} + +func loadConfig() (*Config, error) { + config := &Config{} + + // Look for config file in current directory first, then home directory + configPaths := []string{ + "gitplm.yaml", + "gitplm.yml", + ".gitplm.yaml", + ".gitplm.yml", + } + + // Also check home directory + if homeDir, err := os.UserHomeDir(); err == nil { + homePaths := []string{ + filepath.Join(homeDir, ".gitplm.yaml"), + filepath.Join(homeDir, ".gitplm.yml"), + } + configPaths = append(configPaths, homePaths...) + } + + var configData []byte + var err error + + // Try to find and load a config file + for _, path := range configPaths { + if configData, err = os.ReadFile(path); err == nil { + break + } + } + + // If no config file found, return empty config (not an error) + if err != nil { + return config, nil + } + + err = yaml.Unmarshal(configData, config) + if err != nil { + return nil, err + } + + return config, nil +} \ No newline at end of file diff --git a/main.go b/main.go index 86be045..bfb32af 100644 --- a/main.go +++ b/main.go @@ -15,12 +15,19 @@ var version = "Development" func main() { initCSV() + // Load config file first + config, err := loadConfig() + if err != nil { + log.Printf("Error loading config: %v", err) + os.Exit(-1) + } + flagRelease := flag.String("release", "", "Process release for IPN (ex: PCB-056-0005, ASY-002-0023)") flagVersion := flag.Bool("version", false, "display version of this application") flagSimplify := flag.String("simplify", "", "simplify a BOM file, combine lines with common MPN") flagOutput := flag.String("out", "", "output file") flagCombine := flag.String("combine", "", "adds BOM to output bom") - flagPMDir := flag.String("pmDir", "", "specify location of partmaster CSV files") + flagPMDir := flag.String("pmDir", config.PMDir, "specify location of partmaster CSV files") flag.Parse() if *flagVersion { From 87db2445e6db37afceb03ebe135ce18b1449dffc Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 30 Jun 2025 21:02:32 -0400 Subject: [PATCH 03/13] add TUI shell --- go.mod | 27 ++++++++++++++--- go.sum | 48 ++++++++++++++++++++++++++++-- main.go | 9 ++++++ tui.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 tui.go diff --git a/go.mod b/go.mod index 8f45bc6..194e6c7 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/git-plm/gitplm -go 1.22 +go 1.23.0 -toolchain go1.22.1 +toolchain go1.24.4 require ( + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/lipgloss v1.1.0 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/otiai10/copy v1.9.0 github.com/samber/lo v1.33.0 @@ -12,6 +14,23 @@ require ( ) require ( - golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 8e475f1..6a72bff 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,39 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/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= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/otiai10/copy v1.9.0 h1:7KFNiCgZ91Ru4qW4CWPf/7jqtxLagGRmIxWldPP9VY4= @@ -15,16 +45,28 @@ github.com/otiai10/mint v1.4.0 h1:umwcf7gbpEwf7WFzqmWwSv0CzbeMsae2u9ZvpP8j2q4= github.com/otiai10/mint v1.4.0/go.mod h1:gifjb2MYOoULtKLqUAEILUG/9KONW6f7YsJ6vQLTlFI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.33.0 h1:2aKucr+rQV6gHpY3bpeZu69uYoQOzVhGT3J22Op6Cjk= github.com/samber/lo v1.33.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index bfb32af..b5f0945 100644 --- a/main.go +++ b/main.go @@ -143,6 +143,15 @@ func main() { return } + // If no flags were provided, show the TUI + if len(os.Args) == 1 { + err := runTUI() + if err != nil { + log.Fatal("Error running TUI: ", err) + } + return + } + fmt.Println("Error, please specify an action") flag.Usage() } diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..338935d --- /dev/null +++ b/tui.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + Align(lipgloss.Center). + MarginTop(2). + MarginBottom(1) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Align(lipgloss.Center). + MarginBottom(2) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Align(lipgloss.Center). + MarginTop(2) +) + +type model struct { + width int + height int +} + +func initialModel() model { + return model{} +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + } + } + + return m, nil +} + +func (m model) View() string { + if m.width == 0 { + return "" + } + + // Create the GitPLM title + title := titleStyle.Width(m.width).Render("GitPLM") + + // Create subtitle + subtitle := subtitleStyle.Width(m.width).Render("Git Product Lifecycle Management") + + // Create help text + help := helpStyle.Width(m.width).Render("Press 'q', 'esc', or 'ctrl+c' to quit") + + // Calculate vertical centering + content := lipgloss.JoinVertical(lipgloss.Center, title, subtitle, help) + contentHeight := strings.Count(content, "\n") + 1 + + // Add vertical padding to center content + verticalPadding := (m.height - contentHeight) / 2 + if verticalPadding > 0 { + padding := strings.Repeat("\n", verticalPadding) + content = padding + content + } + + return content +} + +func runTUI() error { + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + _, err := p.Run() + return err +} \ No newline at end of file From 9102f8a76ccd146db0ecc11855c1b85efd9cc1f9 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 30 Jun 2025 22:18:47 -0400 Subject: [PATCH 04/13] update TUI to display partmaster --- go.mod | 2 + go.sum | 4 + main.go | 2 +- tui.go | 267 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 245 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 194e6c7..7650b57 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,9 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect diff --git a/go.sum b/go.sum index 6a72bff..c034e3f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= diff --git a/main.go b/main.go index b5f0945..c8f0d07 100644 --- a/main.go +++ b/main.go @@ -145,7 +145,7 @@ func main() { // If no flags were provided, show the TUI if len(os.Args) == 1 { - err := runTUI() + err := runTUI(*flagPMDir) if err != nil { log.Fatal("Error running TUI: ", err) } diff --git a/tui.go b/tui.go index 338935d..b095712 100644 --- a/tui.go +++ b/tui.go @@ -1,10 +1,14 @@ package main import ( + "os" "strings" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v2" ) var ( @@ -24,15 +28,111 @@ var ( Foreground(lipgloss.Color("241")). Align(lipgloss.Center). MarginTop(2) + + inputStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Width(60) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Align(lipgloss.Center). + MarginTop(1) + + tableStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) ) type model struct { - width int - height int + width int + height int + textInput textinput.Model + table table.Model + showInput bool + pmDir string + error string + done bool + partmaster partmaster } -func initialModel() model { - return model{} +func initialModel(needsPMDir bool, pmDir string) model { + ti := textinput.New() + ti.Placeholder = "/path/to/partmaster/directory" + ti.Focus() + ti.CharLimit = 256 + ti.Width = 50 + + // Create table + columns := []table.Column{ + {Title: "IPN", Width: 15}, + {Title: "Description", Width: 30}, + {Title: "Manufacturer", Width: 20}, + {Title: "MPN", Width: 20}, + {Title: "Value", Width: 10}, + } + + t := table.New( + table.WithColumns(columns), + table.WithHeight(10), + table.WithFocused(true), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + m := model{ + textInput: ti, + table: t, + showInput: needsPMDir, + pmDir: pmDir, + } + + // Load partmaster if pmDir is available + if pmDir != "" && !needsPMDir { + m.loadPartmaster() + } + + return m +} + +func (m *model) loadPartmaster() { + if m.pmDir == "" { + return + } + + pm, err := loadPartmasterFromDir(m.pmDir) + if err != nil { + m.error = "Error loading partmaster: " + err.Error() + return + } + + m.partmaster = pm + m.updateTable() +} + +func (m *model) updateTable() { + rows := []table.Row{} + for _, part := range m.partmaster { + rows = append(rows, table.Row{ + string(part.IPN), + part.Description, + part.Manufacturer, + part.MPN, + part.Value, + }) + } + m.table.SetRows(rows) } func (m model) Init() tea.Cmd { @@ -40,19 +140,58 @@ func (m model) Init() tea.Cmd { } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit + if m.showInput { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "enter": + dir := strings.TrimSpace(m.textInput.Value()) + if dir == "" { + m.error = "Directory path cannot be empty" + return m, nil + } + + // Check if directory exists + if _, err := os.Stat(dir); os.IsNotExist(err) { + m.error = "Directory does not exist: " + dir + return m, nil + } + + // Save config to gitplm.yml + if err := saveConfig(dir); err != nil { + m.error = "Error saving config: " + err.Error() + return m, nil + } + + m.pmDir = dir + m.showInput = false + m.error = "" + m.loadPartmaster() + return m, nil + } + } else { + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + } } } - return m, nil + if m.showInput { + m.textInput, cmd = m.textInput.Update(msg) + } else { + m.table, cmd = m.table.Update(msg) + } + + return m, cmd } func (m model) View() string { @@ -62,29 +201,99 @@ func (m model) View() string { // Create the GitPLM title title := titleStyle.Width(m.width).Render("GitPLM") - - // Create subtitle - subtitle := subtitleStyle.Width(m.width).Render("Git Product Lifecycle Management") - - // Create help text - help := helpStyle.Width(m.width).Render("Press 'q', 'esc', or 'ctrl+c' to quit") - - // Calculate vertical centering - content := lipgloss.JoinVertical(lipgloss.Center, title, subtitle, help) - contentHeight := strings.Count(content, "\n") + 1 - - // Add vertical padding to center content - verticalPadding := (m.height - contentHeight) / 2 - if verticalPadding > 0 { - padding := strings.Repeat("\n", verticalPadding) - content = padding + content + + if m.showInput { + // Show input prompt + prompt := subtitleStyle.Width(m.width).Render("Enter the directory containing partmaster CSV files:") + + // Style the text input + input := inputStyle.Render(m.textInput.View()) + + var errorMsg string + if m.error != "" { + errorMsg = errorStyle.Width(m.width).Render(m.error) + } + + help := helpStyle.Width(m.width).Render("Press Enter to confirm, Ctrl+C to cancel") + + // Join all components + var content string + if errorMsg != "" { + content = lipgloss.JoinVertical(lipgloss.Center, title, prompt, input, errorMsg, help) + } else { + content = lipgloss.JoinVertical(lipgloss.Center, title, prompt, input, help) + } + + // Calculate vertical centering + contentHeight := strings.Count(content, "\n") + 1 + verticalPadding := (m.height - contentHeight) / 2 + if verticalPadding > 0 { + padding := strings.Repeat("\n", verticalPadding) + content = padding + content + } + + return content + } else { + // Show normal GitPLM screen with partmaster table + subtitle := subtitleStyle.Width(m.width).Render("Git Product Lifecycle Management") + + // Show partmaster directory if available + var pmDirInfo string + if m.pmDir != "" { + pmDirInfo = subtitleStyle.Width(m.width).Render("Partmaster Directory: " + m.pmDir) + } + + // Show error if any + var errorMsg string + if m.error != "" { + errorMsg = errorStyle.Width(m.width).Render(m.error) + } + + // Show table if partmaster is loaded + var tableView string + if len(m.partmaster) > 0 { + tableView = tableStyle.Render(m.table.View()) + } + + help := helpStyle.Width(m.width).Render("Press 'q', 'esc', or 'ctrl+c' to quit • Use ↑/↓ to navigate") + + // Join all components + var content string + components := []string{title, subtitle} + + if pmDirInfo != "" { + components = append(components, pmDirInfo) + } + if errorMsg != "" { + components = append(components, errorMsg) + } + if tableView != "" { + components = append(components, tableView) + } + + components = append(components, help) + content = lipgloss.JoinVertical(lipgloss.Center, components...) + + return content } - - return content } -func runTUI() error { - p := tea.NewProgram(initialModel(), tea.WithAltScreen()) +func runTUI(pmDir string) error { + needsPMDir := pmDir == "" + p := tea.NewProgram(initialModel(needsPMDir, pmDir), tea.WithAltScreen()) _, err := p.Run() return err -} \ No newline at end of file +} + +func saveConfig(pmDir string) error { + config := Config{ + PMDir: pmDir, + } + + data, err := yaml.Marshal(&config) + if err != nil { + return err + } + + return os.WriteFile("gitplm.yml", data, 0644) +} From 72ca5130adcd5227cc48a992a81d21d17860d3c6 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 30 Jun 2025 22:19:17 -0400 Subject: [PATCH 05/13] update gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 61b41c9..c67f8df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ gitplm dist +gitplm.yaml +gitplm.yml +.gitplm.yaml +.gitplm.yml From 67760afedb35c41a6432c81fc4341844850ad3cb Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 15:19:23 -0400 Subject: [PATCH 06/13] convert pm to individual files --- example/ana.csv | 2 ++ example/asy.csv | 4 ++++ example/cap.csv | 7 +++++++ example/dio.csv | 2 ++ example/mch.csv | 2 ++ example/partmaster.csv | 17 ----------------- example/pca.csv | 2 ++ example/pcb.csv | 2 ++ example/res.csv | 2 ++ example/scr.csv | 2 ++ 10 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 example/ana.csv create mode 100644 example/asy.csv create mode 100644 example/cap.csv create mode 100644 example/dio.csv create mode 100644 example/mch.csv delete mode 100644 example/partmaster.csv create mode 100644 example/pca.csv create mode 100644 example/pcb.csv create mode 100644 example/res.csv create mode 100644 example/scr.csv diff --git a/example/ana.csv b/example/ana.csv new file mode 100644 index 0000000..e657e4e --- /dev/null +++ b/example/ana.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +ANA-000-0000,LT1716,Package_TO_SOT_SMD:SOT-23-5,Linear Technology,LT1716IS5#TRPBF,https://www.analog.com/media/en/technical-documentation/data-sheets/LT1716.pdf, diff --git a/example/asy.csv b/example/asy.csv new file mode 100644 index 0000000..9bf4c54 --- /dev/null +++ b/example/asy.csv @@ -0,0 +1,4 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +ASY-001-0000,productA,productA,mycompany, +ASY-002-0001,endplate,endplate,mycompany,Y +ASY-012-0012,endcap assembly,mycompany, diff --git a/example/cap.csv b/example/cap.csv new file mode 100644 index 0000000..cef787a --- /dev/null +++ b/example/cap.csv @@ -0,0 +1,7 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +CAP-000-1001,1nF 50V cap,1nF_50V,Bogus Caps Inc,1234,2, +CAP-000-1001,1nF 50V cap,Capacitor_SMD:C_0805_2012Metric,AVX,08055C102JAT2A,http://datasheets.avx.com/X7RDielectric.pdf,1,Y +CAP-000-1002,10nF 50V,Capacitor_SMD:C_0805_2012Metric,10nF_50V,AVX,08055C103JAT2A,http://datasheets.avx.com/X7RDielectric.pdf, +CAP-000-1005,1.0uF,Capacitor_SMD:C_0805_2012Metric,1.0uF_50V,AVX,08055C105KAT2A,http://datasheets.avx.com/X7RDielectric.pdf, +CAP-000-470R,470pF 50V,Capacitor_SMD:C_0603_1608Metric,470pF_50V,AVX,06035C471JAT2A,https://datasheets.avx.com/X7RDielectric.pdf,Y +CAP-015-1002,10nF/50V,Capacitor_SMD:C_0603_1608Metric,10nF_50V,AVX,06035C103JAT2A,http://datasheets.avx.com/X7RDielectric.pdf, diff --git a/example/dio.csv b/example/dio.csv new file mode 100644 index 0000000..e368261 --- /dev/null +++ b/example/dio.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +DIO-002-0000,SMD diode,Diode_SMD:D_SOT-23_ANK,MMBZ5245B-7-F,Diodes Incorporated,MMBZ5245B-7-F,https://www.diodes.com/assets/Datasheets/MMBZ5221B-MMBZ5259B.pdf,Y diff --git a/example/mch.csv b/example/mch.csv new file mode 100644 index 0000000..147adff --- /dev/null +++ b/example/mch.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +MCH-001-0001,bracket,bracket,bracketsRus,1051023,https://www.brackets.com/pdfs/21523.pdf,Y diff --git a/example/partmaster.csv b/example/partmaster.csv deleted file mode 100644 index 060a780..0000000 --- a/example/partmaster.csv +++ /dev/null @@ -1,17 +0,0 @@ -IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked -ANA-000-0000,LT1716,Package_TO_SOT_SMD:SOT-23-5,Linear Technology,LT1716IS5#TRPBF,https://www.analog.com/media/en/technical-documentation/data-sheets/LT1716.pdf, -ASY-001-0000,productA,productA,mycompany, -ASY-002-0001,endplate,endplate,mycompany,Y -ASY-012-0012,endcap assembly,mycompany, -CAP-000-1001,1nF 50V cap,1nF_50V,Bogus Caps Inc,1234,2, -CAP-000-1001,1nF 50V cap,Capacitor_SMD:C_0805_2012Metric,AVX,08055C102JAT2A,http://datasheets.avx.com/X7RDielectric.pdf,1,Y -CAP-000-1002,10nF 50V,Capacitor_SMD:C_0805_2012Metric,10nF_50V,AVX,08055C103JAT2A,http://datasheets.avx.com/X7RDielectric.pdf, -CAP-000-1005,1.0uF,Capacitor_SMD:C_0805_2012Metric,1.0uF_50V,AVX,08055C105KAT2A,http://datasheets.avx.com/X7RDielectric.pdf, -CAP-000-470R,470pF 50V,Capacitor_SMD:C_0603_1608Metric,470pF_50V,AVX,06035C471JAT2A,https://datasheets.avx.com/X7RDielectric.pdf,Y -CAP-015-1002,10nF/50V,Capacitor_SMD:C_0603_1608Metric,10nF_50V,AVX,06035C103JAT2A,http://datasheets.avx.com/X7RDielectric.pdf, -DIO-002-0000,SMD diode,Diode_SMD:D_SOT-23_ANK,MMBZ5245B-7-F,Diodes Incorporated,MMBZ5245B-7-F,https://www.diodes.com/assets/Datasheets/MMBZ5221B-MMBZ5259B.pdf,Y -MCH-001-0001,bracket,bracket,bracketsRus,1051023,https://www.brackets.com/pdfs/21523.pdf,Y -PCA-019-0000,PCB assembly,mycompany, -PCB-019-0001,PCB,mycompany, -RES-008-220K,220k Resistor,Resistor_SMD:R_2010_5025Metric,220k_500mW,KOA SPEER Electronics,HV732HTTE2203F,https://www.koaspeer.com/pdfs/HV73.pdf, -SCR-002-0002,#4 screw,screw #4 2,screwsRus,18a02SDF,https://www.screws.com/pdfs/abc.pdf, diff --git a/example/pca.csv b/example/pca.csv new file mode 100644 index 0000000..ba6a019 --- /dev/null +++ b/example/pca.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +PCA-019-0000,PCB assembly,mycompany, diff --git a/example/pcb.csv b/example/pcb.csv new file mode 100644 index 0000000..9753aa2 --- /dev/null +++ b/example/pcb.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +PCB-019-0001,PCB,mycompany, diff --git a/example/res.csv b/example/res.csv new file mode 100644 index 0000000..c8e60bf --- /dev/null +++ b/example/res.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +RES-008-220K,220k Resistor,Resistor_SMD:R_2010_5025Metric,220k_500mW,KOA SPEER Electronics,HV732HTTE2203F,https://www.koaspeer.com/pdfs/HV73.pdf, diff --git a/example/scr.csv b/example/scr.csv new file mode 100644 index 0000000..0bfbb93 --- /dev/null +++ b/example/scr.csv @@ -0,0 +1,2 @@ +IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked +SCR-002-0002,#4 screw,screw #4 2,screwsRus,18a02SDF,https://www.screws.com/pdfs/abc.pdf, From 914e2b88139490a6183498c56d92bf26dc24449d Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 15:20:45 -0400 Subject: [PATCH 07/13] add a release for variations --- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 18 +++++++++++++++--- rel-script.go | 4 ++-- release.go | 21 ++++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b65221c..c46220d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ For more details or to discuss releases, please visit the ## [Unreleased] +### Added + +- Interactive TUI (Terminal User Interface) mode when no command line arguments + are provided +- TUI prompt for configuring partmaster directory when not set in configuration +- Automatic saving of partmaster directory configuration to `gitplm.yml` +- Scrollable table display of partmaster data in TUI with columns: IPN, + Description, Manufacturer, MPN, Value +- Enhanced file search pattern supporting `CCC-NNN-VV.csv` and `CCC-NNN-VV.yml` + formats +- YAML configuration file support (`gitplm.yaml`, `gitplm.yml`, `.gitplm.yaml`, + `.gitplm.yml`) + +### Enhanced + +- Source file discovery now supports variation-based file naming using first two + digits of variation number +- File search priority: base pattern (`CCC-NNN.csv`) first, then variation + pattern (`CCC-NNN-VV.csv`) +- Improved user experience with seamless configuration flow in TUI mode +- Quantity fields now support fractional values (e.g., 0.5, 1.25) for more precise BOM specifications + ## [[0.6.0] - 2024-02-02](https://github.com/git-plm/gitplm/releases/tag/v0.6.0) - add `-pmDir` command line parameter to specify parts database directory diff --git a/README.md b/README.md index 1df75d2..6ac5f53 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,22 @@ hierarchy of release directories for the entire product. For parts you produce, GitPLM scans the directory tree looking for source directories which are identified by one or both of the following files: -- an input BOM. Ex: `ASY-023.csv` -- a release configuration file. Ex: `PCB-019.yml` +- an input BOM. Ex: `ASY-023.csv` or `ASY-023-01.csv` +- a release configuration file. Ex: `PCB-019.yml` or `PCB-019-02.yml` -If either of these is found, GitPLM considers this a source directory and will +GitPLM supports two file naming patterns for source files: + +1. **Base pattern**: `CCC-NNN.csv` and `CCC-NNN.yml` (e.g., `PCB-019.csv`, `ASY-023.yml`) +2. **Variation pattern**: `CCC-NNN-VV.csv` and `CCC-NNN-VV.yml` (e.g., `PCB-019-01.csv`, `ASY-023-02.yml`) + +The variation pattern uses the first two digits of the variation number, allowing you to organize files by variation ranges. For example: +- `PCB-019-00.csv` for variations 0000-0099 +- `PCB-019-01.csv` for variations 0100-0199 +- `PCB-019-02.csv` for variations 0200-0299 + +When processing a release, GitPLM first searches for the base pattern, then falls back to the variation pattern if the base pattern is not found. + +If either of these files is found, GitPLM considers this a source directory and will use this directory to generate release directories. A source directory might contain: diff --git a/rel-script.go b/rel-script.go index 2a9a1ee..744ff45 100644 --- a/rel-script.go +++ b/rel-script.go @@ -50,9 +50,9 @@ func (rs *relScript) processBom(b bom) (bom, error) { for _, a := range rs.Add { refs := strings.Split(a.Ref, ",") - a.Qty = len(refs) + a.Qty = float64(len(refs)) if a.Qty < 0 { - a.Qty = 1 + a.Qty = 1.0 } // for some reason we need to make a copy or it // will alias the last one diff --git a/release.go b/release.go index 2e848d7..778355b 100644 --- a/release.go +++ b/release.go @@ -14,30 +14,49 @@ import ( ) func processRelease(relPn string, relLog *strings.Builder, pmDir string) (string, error) { - c, n, _, err := ipn(relPn).parse() + c, n, v, err := ipn(relPn).parse() if err != nil { return "", fmt.Errorf("error parsing bom %v IPN : %v", relPn, err) } relPnBase := fmt.Sprintf("%v-%03v", c, n) + relPnBaseWithVar := fmt.Sprintf("%v-%03v-%02v", c, n, v/100) // First two digits of variation bomFile := relPnBase + ".csv" + bomFileWithVar := relPnBaseWithVar + ".csv" bomFileGenerated := relPn + ".csv" ymlFile := relPnBase + ".yml" + ymlFileWithVar := relPnBaseWithVar + ".yml" bomExists := false ymlExists := false sourceDir := "" + // Try to find BOM file - first try CCC-NNN.csv, then CCC-NNN-VV.csv bomFilePath, err := findFile(bomFile) if err == nil { bomExists = true sourceDir = filepath.Dir(bomFilePath) + } else { + // Try with variation pattern + bomFilePath, err = findFile(bomFileWithVar) + if err == nil { + bomExists = true + sourceDir = filepath.Dir(bomFilePath) + } } + // Try to find YML file - first try CCC-NNN.yml, then CCC-NNN-VV.yml ymlFilePath, err := findFile(ymlFile) if err == nil { ymlExists = true sourceDir = filepath.Dir(ymlFilePath) + } else { + // Try with variation pattern + ymlFilePath, err = findFile(ymlFileWithVar) + if err == nil { + ymlExists = true + sourceDir = filepath.Dir(ymlFilePath) + } } if !ymlExists && !bomExists { From b586d3c14cbe903b2d34488d6399aa59ffb1b238 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 15:21:17 -0400 Subject: [PATCH 08/13] support fractional quantities --- bom.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bom.go b/bom.go index 58b1ca4..d2ce29c 100644 --- a/bom.go +++ b/bom.go @@ -10,18 +10,18 @@ import ( ) type bomLine struct { - IPN ipn `csv:"IPN" yaml:"ipn"` - Qty int `csv:"Qty" yaml:"qty"` - MPN string `csv:"MPN" yaml:"mpn"` - Manufacturer string `csv:"Manufacturer" yaml:"manufacturer"` - Ref string `csv:"Ref" yaml:"ref"` - Value string `csv:"Value" yaml:"value"` - CmpName string `csv:"Cmp name" yaml:"cmpName"` - Footprint string `csv:"Footprint" yaml:"footprint"` - Description string `csv:"Description" yaml:"description"` - Vendor string `csv:"Vendor" yaml:"vendor"` - Datasheet string `csv:"Datasheet" yaml:"datasheet"` - Checked string `csv:"Checked" yaml:"checked"` + IPN ipn `csv:"IPN" yaml:"ipn"` + Qty float64 `csv:"Qty" yaml:"qty"` + MPN string `csv:"MPN" yaml:"mpn"` + Manufacturer string `csv:"Manufacturer" yaml:"manufacturer"` + Ref string `csv:"Ref" yaml:"ref"` + Value string `csv:"Value" yaml:"value"` + CmpName string `csv:"Cmp name" yaml:"cmpName"` + Footprint string `csv:"Footprint" yaml:"footprint"` + Description string `csv:"Description" yaml:"description"` + Vendor string `csv:"Vendor" yaml:"vendor"` + Datasheet string `csv:"Datasheet" yaml:"datasheet"` + Checked string `csv:"Checked" yaml:"checked"` } func (bl *bomLine) String() string { @@ -50,7 +50,7 @@ func (bl *bomLine) removeRef(ref string) { } } bl.Ref = strings.Join(refsOut, " ") - bl.Qty = len(refsOut) + bl.Qty = float64(len(refsOut)) } func sortReferenceDesignators(input string) string { @@ -132,7 +132,7 @@ func (b *bom) copy() bom { return ret } -func (b *bom) processOurIPN(pn ipn, qty int) error { +func (b *bom) processOurIPN(pn ipn, qty float64) error { log.Println("processing our IPN: ", pn, qty) // check if BOM exists @@ -180,7 +180,7 @@ func (b *bom) addItem(newItem *bomLine) { func (b *bom) addItemMPN(newItem *bomLine, includeRef bool) { if newItem.Qty <= 0 { - newItem.Qty = 1 + newItem.Qty = 1.0 } for i, l := range *b { From 155adc46e3d715b3086fa52eb5601ad849ea1218 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 15:21:30 -0400 Subject: [PATCH 09/13] move save config --- config.go | 13 +++++++++++++ tui.go | 13 ------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/config.go b/config.go index fdec443..f24ad7c 100644 --- a/config.go +++ b/config.go @@ -52,4 +52,17 @@ func loadConfig() (*Config, error) { } return config, nil +} + +func saveConfig(pmDir string) error { + config := Config{ + PMDir: pmDir, + } + + data, err := yaml.Marshal(&config) + if err != nil { + return err + } + + return os.WriteFile("gitplm.yml", data, 0644) } \ No newline at end of file diff --git a/tui.go b/tui.go index b095712..0334eb1 100644 --- a/tui.go +++ b/tui.go @@ -8,7 +8,6 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "gopkg.in/yaml.v2" ) var ( @@ -285,15 +284,3 @@ func runTUI(pmDir string) error { return err } -func saveConfig(pmDir string) error { - config := Config{ - PMDir: pmDir, - } - - data, err := yaml.Marshal(&config) - if err != nil { - return err - } - - return os.WriteFile("gitplm.yml", data, 0644) -} From 22fdf922df0ea259e3c86d4103409c727478e1c9 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 15:22:17 -0400 Subject: [PATCH 10/13] work on more advanced category UI categories are displayed in left pane, and parts in right --- csv_data.go | 211 ++++++++++++++++++++ go.mod | 3 +- go.sum | 8 + main.go | 2 +- tui_new.go | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 csv_data.go create mode 100644 tui_new.go diff --git a/csv_data.go b/csv_data.go new file mode 100644 index 0000000..afac9a5 --- /dev/null +++ b/csv_data.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// CSVFile represents a single CSV file with its headers and data +type CSVFile struct { + Name string + Path string + Headers []string + Rows [][]string +} + +// CSVFileCollection represents all CSV files loaded from a directory +type CSVFileCollection struct { + Files []*CSVFile +} + +// loadCSVRaw loads a CSV file without struct mapping, preserving all columns +func loadCSVRaw(filePath string) (*CSVFile, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("error opening file %s: %v", filePath, err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.Comma = ',' + reader.LazyQuotes = true + reader.FieldsPerRecord = -1 // Allow variable number of fields per record + + // Read headers + headers, err := reader.Read() + if err != nil { + return nil, fmt.Errorf("error reading headers from %s: %v", filePath, err) + } + + // Trim whitespace from headers + for i := range headers { + headers[i] = strings.TrimSpace(headers[i]) + } + + // Read all rows + var rows [][]string + lineNum := 1 // Start at 1 since headers are line 0 + for { + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + // Skip malformed rows and continue + fmt.Printf("Warning: error reading row %d from %s: %v\n", lineNum, filePath, err) + lineNum++ + continue + } + rows = append(rows, row) + lineNum++ + } + + return &CSVFile{ + Name: filepath.Base(filePath), + Path: filePath, + Headers: headers, + Rows: rows, + }, nil +} + +// loadAllCSVFiles loads all CSV files from a directory +func loadAllCSVFiles(dir string) (*CSVFileCollection, error) { + collection := &CSVFileCollection{ + Files: []*CSVFile{}, + } + + files, err := filepath.Glob(filepath.Join(dir, "*.csv")) + if err != nil { + return nil, fmt.Errorf("error finding CSV files in directory %s: %v", dir, err) + } + + for _, filePath := range files { + csvFile, err := loadCSVRaw(filePath) + if err != nil { + // Log error but continue loading other files + fmt.Printf("Warning: error loading CSV file %s: %v\n", filePath, err) + continue + } + collection.Files = append(collection.Files, csvFile) + } + + if len(collection.Files) == 0 { + return nil, fmt.Errorf("no valid CSV files found in directory %s", dir) + } + + return collection, nil +} + +// GetCombinedPartmaster returns all parts from all CSV files as a partmaster +func (c *CSVFileCollection) GetCombinedPartmaster() (partmaster, error) { + pm := partmaster{} + + for _, file := range c.Files { + // Try to parse each file as partmaster format + filePM, err := c.parseFileAsPartmaster(file) + if err != nil { + // Skip files that don't match partmaster format + continue + } + pm = append(pm, filePM...) + } + + return pm, nil +} + +// parseFileAsPartmaster attempts to parse a CSV file as partmaster format +func (c *CSVFileCollection) parseFileAsPartmaster(file *CSVFile) (partmaster, error) { + pm := partmaster{} + + // Find column indices for partmaster fields + ipnIdx := -1 + descIdx := -1 + footprintIdx := -1 + valueIdx := -1 + mfrIdx := -1 + mpnIdx := -1 + datasheetIdx := -1 + priorityIdx := -1 + checkedIdx := -1 + + for i, header := range file.Headers { + switch header { + case "IPN": + ipnIdx = i + case "Description": + descIdx = i + case "Footprint": + footprintIdx = i + case "Value": + valueIdx = i + case "Manufacturer": + mfrIdx = i + case "MPN": + mpnIdx = i + case "Datasheet": + datasheetIdx = i + case "Priority": + priorityIdx = i + case "Checked": + checkedIdx = i + } + } + + // Must have at least IPN column to be valid + if ipnIdx == -1 { + return nil, fmt.Errorf("no IPN column found") + } + + // Parse rows + for _, row := range file.Rows { + if len(row) == 0 || len(row) <= ipnIdx { + continue + } + + line := &partmasterLine{} + + // Parse IPN + ipnVal, err := newIpn(row[ipnIdx]) + if err != nil { + continue // Skip invalid IPNs + } + line.IPN = ipnVal + + // Parse other fields if they exist + if descIdx >= 0 && len(row) > descIdx { + line.Description = row[descIdx] + } + if footprintIdx >= 0 && len(row) > footprintIdx { + line.Footprint = row[footprintIdx] + } + if valueIdx >= 0 && len(row) > valueIdx { + line.Value = row[valueIdx] + } + if mfrIdx >= 0 && len(row) > mfrIdx { + line.Manufacturer = row[mfrIdx] + } + if mpnIdx >= 0 && len(row) > mpnIdx { + line.MPN = row[mpnIdx] + } + if datasheetIdx >= 0 && len(row) > datasheetIdx { + line.Datasheet = row[datasheetIdx] + } + if priorityIdx >= 0 && len(row) > priorityIdx { + // Parse priority as int, default to 0 if invalid + var priority int + fmt.Sscanf(row[priorityIdx], "%d", &priority) + line.Priority = priority + } + if checkedIdx >= 0 && len(row) > checkedIdx { + line.Checked = row[checkedIdx] + } + + pm = append(pm, line) + } + + return pm, nil +} \ No newline at end of file diff --git a/go.mod b/go.mod index 7650b57..ec35e57 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.24.4 require ( + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 @@ -16,7 +17,6 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect @@ -30,6 +30,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 // indirect golang.org/x/sync v0.13.0 // indirect diff --git a/go.sum b/go.sum index c034e3f..1a43b4b 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= @@ -14,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -24,6 +28,8 @@ github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -52,6 +58,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/samber/lo v1.33.0 h1:2aKucr+rQV6gHpY3bpeZu69uYoQOzVhGT3J22Op6Cjk= github.com/samber/lo v1.33.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= diff --git a/main.go b/main.go index c8f0d07..f0c23b1 100644 --- a/main.go +++ b/main.go @@ -145,7 +145,7 @@ func main() { // If no flags were provided, show the TUI if len(os.Args) == 1 { - err := runTUI(*flagPMDir) + err := runTUINew(*flagPMDir) if err != nil { log.Fatal("Error running TUI: ", err) } diff --git a/tui_new.go b/tui_new.go new file mode 100644 index 0000000..4c8454b --- /dev/null +++ b/tui_new.go @@ -0,0 +1,545 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "io" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + viewStateInput = iota + viewStateBrowse +) + +const allFilesOption = "All Parts (Combined)" + +var ( + titleStyle2 = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + Align(lipgloss.Center) + + subtitleStyle2 = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Align(lipgloss.Center) + + helpStyle2 = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Align(lipgloss.Center) + + inputStyle2 = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Width(60) + + errorStyle2 = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Align(lipgloss.Center). + MarginTop(1) + + listStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(1, 2) + + tableStyle2 = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + + selectedItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")) + + normalItemStyle = lipgloss.NewStyle() +) + +type fileItem struct { + name string + isAllOption bool +} + +func (i fileItem) FilterValue() string { return i.name } + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(fileItem) + if !ok { + return + } + + str := fmt.Sprintf("%s", i.name) + + fn := normalItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + +type modelNew struct { + width int + height int + viewState int + textInput textinput.Model + fileList list.Model + table table.Model + pmDir string + error string + csvCollection *CSVFileCollection + selectedFile string + listFocused bool +} + +func initialModelNew(needsPMDir bool, pmDir string) modelNew { + ti := textinput.New() + ti.Placeholder = "/path/to/partmaster/directory" + ti.Focus() + ti.CharLimit = 256 + ti.Width = 50 + + // Create file list + items := []list.Item{} + l := list.New(items, itemDelegate{}, 0, 0) + l.Title = "CSV Files" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + + // Create table with default columns + columns := []table.Column{ + {Title: "No data", Width: 20}, + } + + t := table.New( + table.WithColumns(columns), + table.WithRows([]table.Row{}), + table.WithHeight(10), + table.WithFocused(false), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + m := modelNew{ + textInput: ti, + fileList: l, + table: t, + viewState: viewStateInput, + pmDir: pmDir, + listFocused: true, + } + + if pmDir != "" && !needsPMDir { + m.viewState = viewStateBrowse + // Don't load CSV files here, wait for WindowSizeMsg + } else if needsPMDir { + m.viewState = viewStateInput + } + + return m +} + +func (m *modelNew) loadCSVFiles() { + if m.pmDir == "" { + return + } + + collection, err := loadAllCSVFiles(m.pmDir) + if err != nil { + m.error = "Error loading CSV files: " + err.Error() + return + } + + m.csvCollection = collection + + // Update file list + items := []list.Item{ + fileItem{name: allFilesOption, isAllOption: true}, + } + for _, file := range collection.Files { + items = append(items, fileItem{name: file.Name, isAllOption: false}) + } + m.fileList.SetItems(items) + + // Select first item (All Parts) but don't update table yet + // Let the first WindowSizeMsg handle table initialization + if len(items) > 0 { + m.selectedFile = allFilesOption + } +} + +func (m *modelNew) updateTableForSelectedFile() { + if m.csvCollection == nil || len(m.csvCollection.Files) == 0 { + return + } + + // Don't update if we haven't received window size yet + if m.width == 0 || m.height == 0 { + return + } + + if m.selectedFile == allFilesOption { + // Show combined partmaster view + pm, err := m.csvCollection.GetCombinedPartmaster() + if err != nil { + m.error = "Error loading combined partmaster: " + err.Error() + return + } + + // Update table columns for partmaster + columns := []table.Column{ + {Title: "IPN", Width: 15}, + {Title: "Description", Width: 30}, + {Title: "Manufacturer", Width: 20}, + {Title: "MPN", Width: 20}, + {Title: "Value", Width: 10}, + } + m.table.SetColumns(columns) + + // Update rows + rows := []table.Row{} + if len(pm) == 0 { + // Show message when no parts found + m.table.SetColumns([]table.Column{{Title: "No partmaster data found", Width: 50}}) + m.table.SetRows([]table.Row{}) + } else { + for _, part := range pm { + rows = append(rows, table.Row{ + string(part.IPN), + part.Description, + part.Manufacturer, + part.MPN, + part.Value, + }) + } + m.table.SetRows(rows) + } + } else { + // Show individual CSV file + var csvFile *CSVFile + for _, file := range m.csvCollection.Files { + if file.Name == m.selectedFile { + csvFile = file + break + } + } + + if csvFile == nil { + m.error = "File not found: " + m.selectedFile + return + } + + // Update table columns based on CSV headers + if len(csvFile.Headers) == 0 { + // Handle empty CSV file + columns := []table.Column{{Title: "Empty file", Width: 30}} + m.table.SetColumns(columns) + m.table.SetRows([]table.Row{}) + } else { + columns := []table.Column{} + for i, header := range csvFile.Headers { + // Handle empty headers + columnTitle := header + if columnTitle == "" { + columnTitle = fmt.Sprintf("Column %d", i+1) + } + + width := 15 + if i == 0 { + width = 20 + } else if header == "Description" { + width = 30 + } + columns = append(columns, table.Column{Title: columnTitle, Width: width}) + } + // Update rows first, ensuring they match column count + rows := []table.Row{} + for _, row := range csvFile.Rows { + // Skip completely empty rows + if len(row) == 0 { + continue + } + + // Ensure row has correct number of columns + tableRow := make([]string, len(columns)) + for i := 0; i < len(columns); i++ { + if i < len(row) { + tableRow[i] = strings.TrimSpace(row[i]) + } else { + tableRow[i] = "" + } + } + rows = append(rows, tableRow) + } + + // Ensure we have at least one row to avoid crashes + if len(rows) == 0 { + rows = append(rows, make([]string, len(columns))) + } + + // Reset table state before updating + m.table.SetRows([]table.Row{}) + m.table.SetColumns(columns) + m.table.SetRows(rows) + m.table.SetCursor(0) // Reset cursor to first row + } + } + + m.error = "" +} + +func (m modelNew) Init() tea.Cmd { + return nil +} + +func (m modelNew) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update component sizes with minimum sizes + listWidth := m.width / 4 + if listWidth < 20 { + listWidth = 20 + } + tableWidth := m.width - listWidth - 4 + if tableWidth < 30 { + tableWidth = 30 + } + + // Calculate available height for panes (similar to View method) + listHeight := m.height - 10 // Conservative estimate for header/footer + if listHeight < 5 { + listHeight = 5 + } + + m.fileList.SetWidth(listWidth) + m.fileList.SetHeight(listHeight) + + // Update table width + if m.viewState == viewStateBrowse { + m.table.SetWidth(tableWidth) + m.table.SetHeight(listHeight) + + // Load CSV files if not loaded yet + if m.csvCollection == nil && m.pmDir != "" { + m.loadCSVFiles() + } + + // Update table content if we have a selected file but haven't displayed it yet + if m.selectedFile != "" && m.csvCollection != nil { + m.updateTableForSelectedFile() + } + } + + return m, nil + + case tea.KeyMsg: + if m.viewState == viewStateInput { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "enter": + dir := strings.TrimSpace(m.textInput.Value()) + if dir == "" { + m.error = "Directory path cannot be empty" + return m, nil + } + + // Check if directory exists + if _, err := os.Stat(dir); os.IsNotExist(err) { + m.error = "Directory does not exist: " + dir + return m, nil + } + + // Save config to gitplm.yml + if err := saveConfig(dir); err != nil { + m.error = "Error saving config: " + err.Error() + return m, nil + } + + m.pmDir = dir + m.viewState = viewStateBrowse + m.error = "" + m.loadCSVFiles() + return m, nil + } + } else { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "tab": + // Toggle focus between list and table + m.listFocused = !m.listFocused + if m.listFocused { + m.table.Blur() + } else { + m.table.Focus() + } + return m, nil + case "enter": + if m.listFocused { + selected := m.fileList.SelectedItem() + if item, ok := selected.(fileItem); ok { + m.selectedFile = item.name + m.updateTableForSelectedFile() + } + } + return m, nil + } + } + } + + if m.viewState == viewStateInput { + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + } else { + if m.listFocused { + m.fileList, cmd = m.fileList.Update(msg) + cmds = append(cmds, cmd) + } else { + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +func (m modelNew) View() string { + if m.width == 0 { + return "" + } + + // Create the GitPLM title + title := titleStyle2.Width(m.width).Render("GitPLM") + + if m.viewState == viewStateInput { + // Show input prompt + prompt := subtitleStyle2.Width(m.width).Render("Enter the directory containing partmaster CSV files:") + + // Style the text input + input := inputStyle2.Render(m.textInput.View()) + + var errorMsg string + if m.error != "" { + errorMsg = errorStyle2.Width(m.width).Render(m.error) + } + + help := helpStyle2.Width(m.width).Render("Press Enter to confirm, Ctrl+C to cancel") + + // Join all components + var content string + if errorMsg != "" { + content = lipgloss.JoinVertical(lipgloss.Center, title, prompt, input, errorMsg, help) + } else { + content = lipgloss.JoinVertical(lipgloss.Center, title, prompt, input, help) + } + + // Calculate vertical centering + contentHeight := strings.Count(content, "\n") + 1 + verticalPadding := (m.height - contentHeight) / 2 + if verticalPadding > 0 { + padding := strings.Repeat("\n", verticalPadding) + content = padding + content + } + + return content + } else { + // Show browse view with file list and table + subtitle := subtitleStyle2.Width(m.width).Render("Git Product Lifecycle Management") + + // Show partmaster directory + var pmDirInfo string + if m.pmDir != "" { + pmDirInfo = subtitleStyle.Width(m.width).Render("Partmaster Directory: " + m.pmDir) + } + + // Show error if any + var errorMsg string + if m.error != "" { + errorMsg = errorStyle2.Width(m.width).Render(m.error) + } + + // Calculate widths + listWidth := m.width / 4 + tableWidth := m.width - listWidth - 4 + + // Calculate available height for panes + // Account for title (3 lines), subtitle (3 lines), pmDirInfo (3 lines), help (3 lines) + // Plus some padding + headerHeight := 4 // title + subtitle + if pmDirInfo != "" { + headerHeight += 3 + } + if errorMsg != "" { + headerHeight += 2 + } + helpHeight := 3 + availableHeight := m.height - headerHeight - helpHeight - 4 // 4 for padding + if availableHeight < 5 { + availableHeight = 5 + } + + // Style the list + listView := listStyle.Width(listWidth).Height(availableHeight).Render(m.fileList.View()) + + // Style the table + tableView := tableStyle2.Width(tableWidth).Height(availableHeight).Render(m.table.View()) + + // Join list and table horizontally + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, listView, tableView) + + help := helpStyle2.Width(m.width).Render("Press Tab to switch focus • ↑/↓ to navigate • Enter to select • q or Ctrl+C to quit") + + // Join all components + components := []string{title, subtitle} + + if pmDirInfo != "" { + components = append(components, pmDirInfo) + } + if errorMsg != "" { + components = append(components, errorMsg) + } + + components = append(components, mainContent, help) + content := lipgloss.JoinVertical(lipgloss.Top, components...) + + return content + } +} + +func runTUINew(pmDir string) error { + needsPMDir := pmDir == "" + p := tea.NewProgram(initialModelNew(needsPMDir, pmDir), tea.WithAltScreen()) + _, err := p.Run() + return err +} \ No newline at end of file From 67499af4472f68610ca2288032f1235c002eedc4 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 17:55:27 -0400 Subject: [PATCH 11/13] start of KiCad HTTP API --- example/ana.csv | 2 +- kicad_api.go | 422 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 24 +++ partmaster.go | 7 + 4 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 kicad_api.go diff --git a/example/ana.csv b/example/ana.csv index e657e4e..fed2043 100644 --- a/example/ana.csv +++ b/example/ana.csv @@ -1,2 +1,2 @@ IPN,Description,Footprint,Value,Manufacturer,MPN,Datasheet,Priority,Checked -ANA-000-0000,LT1716,Package_TO_SOT_SMD:SOT-23-5,Linear Technology,LT1716IS5#TRPBF,https://www.analog.com/media/en/technical-documentation/data-sheets/LT1716.pdf, +ANA-000-0000,LT1716,Package_TO_SOT_SMD:SOT-23-5,LT1716IS5#TRPBF,Linear Technology,LT1716IS5#TRPBF,https://www.analog.com/media/en/technical-documentation/data-sheets/LT1716.pdf,, diff --git a/kicad_api.go b/kicad_api.go new file mode 100644 index 0000000..4bfbe7c --- /dev/null +++ b/kicad_api.go @@ -0,0 +1,422 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "sort" + "strings" + + "github.com/samber/lo" +) + +// KiCad HTTP Library API data structures + +// KiCadCategory represents a category in the KiCad HTTP API +type KiCadCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// KiCadPartSummary represents a part summary in the parts list +type KiCadPartSummary struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// KiCadPartDetail represents a detailed part in the KiCad HTTP API +type KiCadPartDetail struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + SymbolIDStr string `json:"symbolIdStr,omitempty"` + ExcludeFromBOM string `json:"exclude_from_bom,omitempty"` + Fields map[string]KiCadPartField `json:"fields,omitempty"` +} + +// KiCadPartField represents a field in a KiCad part +type KiCadPartField struct { + Value string `json:"value"` + Visible string `json:"visible,omitempty"` +} + +// KiCadRootResponse represents the root API response +type KiCadRootResponse struct { + Categories string `json:"categories"` + Parts string `json:"parts"` +} + +// KiCadServer represents the KiCad HTTP API server +type KiCadServer struct { + pmDir string + partmaster partmaster + token string +} + +// NewKiCadServer creates a new KiCad HTTP API server +func NewKiCadServer(pmDir, token string) (*KiCadServer, error) { + server := &KiCadServer{ + pmDir: pmDir, + token: token, + } + + // Load partmaster data + if err := server.loadPartmaster(); err != nil { + return nil, fmt.Errorf("failed to load partmaster: %w", err) + } + + return server, nil +} + +// loadPartmaster loads the partmaster data from the configured directory +func (s *KiCadServer) loadPartmaster() error { + if s.pmDir == "" { + return fmt.Errorf("partmaster directory not configured") + } + + pm, err := loadPartmasterFromDir(s.pmDir) + if err != nil { + return fmt.Errorf("failed to load partmaster from %s: %w", s.pmDir, err) + } + + s.partmaster = pm + return nil +} + +// authenticate checks if the request has a valid token +func (s *KiCadServer) authenticate(r *http.Request) bool { + if s.token == "" { + return true // No authentication required if no token set + } + + auth := r.Header.Get("Authorization") + expectedAuth := "Token " + s.token + return auth == expectedAuth +} + +// getCategories extracts unique categories from the partmaster data +func (s *KiCadServer) getCategories() []KiCadCategory { + categoryMap := make(map[string]bool) + + // Extract categories from IPNs (CCC component) + for _, part := range s.partmaster { + if part.IPN != "" { + category := s.extractCategory(string(part.IPN)) + if category != "" { + categoryMap[category] = true + } + } + } + + // Convert to sorted slice + categoryNames := lo.Keys(categoryMap) + sort.Strings(categoryNames) + + categories := make([]KiCadCategory, len(categoryNames)) + for i, name := range categoryNames { + categories[i] = KiCadCategory{ + ID: name, + Name: s.getCategoryDisplayName(name), + Description: s.getCategoryDescription(name), + } + } + + return categories +} + +// extractCategory extracts the CCC component from an IPN +func (s *KiCadServer) extractCategory(ipnStr string) string { + // IPN format: CCC-NNN-VVVV + re := regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d\d\d)-(\d\d\d\d)$`) + matches := re.FindStringSubmatch(ipnStr) + if len(matches) >= 2 { + return matches[1] + } + return "" +} + +// getCategoryDisplayName returns a human-readable name for a category +func (s *KiCadServer) getCategoryDisplayName(category string) string { + displayNames := map[string]string{ + "CAP": "Capacitors", + "RES": "Resistors", + "DIO": "Diodes", + "LED": "LEDs", + "SCR": "Screws", + "MCH": "Mechanical", + "PCA": "PCB Assemblies", + "PCB": "Printed Circuit Boards", + "ASY": "Assemblies", + "DOC": "Documentation", + "DFW": "Firmware", + "DSW": "Software", + "DCL": "Declarations", + "FIX": "Fixtures", + "CNT": "Connectors", + "IC": "Integrated Circuits", + "OSC": "Oscillators", + "XTL": "Crystals", + "IND": "Inductors", + "FER": "Ferrites", + "FUS": "Fuses", + "SW": "Switches", + "REL": "Relays", + "TRF": "Transformers", + "SNS": "Sensors", + "DSP": "Displays", + "SPK": "Speakers", + "MIC": "Microphones", + "ANT": "Antennas", + "CBL": "Cables", + } + + if displayName, exists := displayNames[category]; exists { + return displayName + } + return category +} + +// getCategoryDescription returns a description for a category +func (s *KiCadServer) getCategoryDescription(category string) string { + descriptions := map[string]string{ + "CAP": "Capacitor components", + "RES": "Resistor components", + "DIO": "Diode components", + "LED": "Light emitting diode components", + "SCR": "Screw and fastener components", + "MCH": "Mechanical components", + "PCA": "Printed circuit board assemblies", + "PCB": "Printed circuit boards", + "ASY": "Assembly components", + "DOC": "Documentation components", + "DFW": "Firmware components", + "DSW": "Software components", + "DCL": "Declaration components", + "FIX": "Fixture components", + "CNT": "Connector components", + "IC": "Integrated circuit components", + "OSC": "Oscillator components", + "XTL": "Crystal components", + "IND": "Inductor components", + "FER": "Ferrite components", + "FUS": "Fuse components", + "SW": "Switch components", + "REL": "Relay components", + "TRF": "Transformer components", + "SNS": "Sensor components", + "DSP": "Display components", + "SPK": "Speaker components", + "MIC": "Microphone components", + "ANT": "Antenna components", + "CBL": "Cable components", + } + + if description, exists := descriptions[category]; exists { + return description + } + return fmt.Sprintf("%s components", category) +} + +// getPartsByCategory returns parts filtered by category +func (s *KiCadServer) getPartsByCategory(categoryID string) []KiCadPartSummary { + var parts []KiCadPartSummary + + for _, part := range s.partmaster { + if part.IPN != "" && s.extractCategory(string(part.IPN)) == categoryID { + parts = append(parts, KiCadPartSummary{ + ID: string(part.IPN), + Name: part.Description, + Description: part.Description, + }) + } + } + + return parts +} + +// getPartDetail returns detailed information for a specific part +func (s *KiCadServer) getPartDetail(partID string) *KiCadPartDetail { + for _, part := range s.partmaster { + if string(part.IPN) == partID { + fields := make(map[string]KiCadPartField) + + // Add standard fields + if part.Value != "" { + fields["Value"] = KiCadPartField{Value: part.Value} + } + if part.Manufacturer != "" { + fields["Manufacturer"] = KiCadPartField{Value: part.Manufacturer} + } + if part.MPN != "" { + fields["MPN"] = KiCadPartField{Value: part.MPN} + } + if part.Datasheet != "" { + fields["Datasheet"] = KiCadPartField{Value: part.Datasheet} + } + if part.Footprint != "" { + fields["Footprint"] = KiCadPartField{Value: part.Footprint} + } + + // Add IPN as a field + fields["IPN"] = KiCadPartField{Value: string(part.IPN)} + + return &KiCadPartDetail{ + ID: string(part.IPN), + Name: part.Description, + SymbolIDStr: s.getSymbolID(part), + ExcludeFromBOM: "false", // Default to include in BOM + Fields: fields, + } + } + } + + return nil +} + +// getSymbolID generates a symbol ID for a part based on its category and properties +func (s *KiCadServer) getSymbolID(part *partmasterLine) string { + category := s.extractCategory(string(part.IPN)) + + // Map categories to common KiCad symbol library symbols + symbolMap := map[string]string{ + "CAP": "Device:C", + "RES": "Device:R", + "DIO": "Device:D", + "LED": "Device:LED", + "IC": "Device:IC", + "OSC": "Device:Oscillator", + "XTL": "Device:Crystal", + "IND": "Device:L", + "FER": "Device:Ferrite_Bead", + "FUS": "Device:Fuse", + "SW": "Switch:SW_Push", + "REL": "Relay:Relay_SPDT", + "TRF": "Device:Transformer", + "SNS": "Sensor:Sensor", + "CNT": "Connector:Conn_01x02", + "ANT": "Device:Antenna", + } + + if symbol, exists := symbolMap[category]; exists { + return symbol + } + + // Default symbol + return "Device:Device" +} + +// HTTP Handlers + +// rootHandler handles the root API endpoint +func (s *KiCadServer) rootHandler(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Extract base URL from request + baseURL := fmt.Sprintf("%s://%s%s", getScheme(r), r.Host, strings.TrimSuffix(r.URL.Path, "/")) + + response := KiCadRootResponse{ + Categories: baseURL + "/categories.json", + Parts: baseURL + "/parts", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// categoriesHandler handles the categories endpoint +func (s *KiCadServer) categoriesHandler(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + categories := s.getCategories() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(categories) +} + +// partsByCategoryHandler handles the parts by category endpoint +func (s *KiCadServer) partsByCategoryHandler(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Extract category ID from URL path + path := strings.TrimPrefix(r.URL.Path, "/v1/parts/category/") + categoryID := strings.TrimSuffix(path, ".json") + + parts := s.getPartsByCategory(categoryID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(parts) +} + +// partDetailHandler handles the part detail endpoint +func (s *KiCadServer) partDetailHandler(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Extract part ID from URL path + path := strings.TrimPrefix(r.URL.Path, "/v1/parts/") + partID := strings.TrimSuffix(path, ".json") + + part := s.getPartDetail(partID) + if part == nil { + http.Error(w, "Part not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(part) +} + +// getScheme determines the URL scheme (http or https) +func getScheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + if r.Header.Get("X-Forwarded-Proto") == "https" { + return "https" + } + return "http" +} + +// StartKiCadServer starts the KiCad HTTP API server +func StartKiCadServer(pmDir, token string, port int) error { + server, err := NewKiCadServer(pmDir, token) + if err != nil { + return fmt.Errorf("failed to create KiCad server: %w", err) + } + + // Set up routes + http.HandleFunc("/v1/", server.rootHandler) + http.HandleFunc("/v1/categories.json", server.categoriesHandler) + http.HandleFunc("/v1/parts/category/", server.partsByCategoryHandler) + http.HandleFunc("/v1/parts/", server.partDetailHandler) + + // Add a health check endpoint + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + addr := fmt.Sprintf(":%d", port) + log.Printf("Starting KiCad HTTP Library API server on %s", addr) + log.Printf("API endpoints:") + log.Printf(" Root: http://localhost%s/v1/", addr) + log.Printf(" Categories: http://localhost%s/v1/categories.json", addr) + log.Printf(" Parts by category: http://localhost%s/v1/parts/category/{category_id}.json", addr) + log.Printf(" Part detail: http://localhost%s/v1/parts/{part_id}.json", addr) + + return http.ListenAndServe(addr, nil) +} \ No newline at end of file diff --git a/main.go b/main.go index f0c23b1..36efcac 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,9 @@ func main() { flagOutput := flag.String("out", "", "output file") flagCombine := flag.String("combine", "", "adds BOM to output bom") flagPMDir := flag.String("pmDir", config.PMDir, "specify location of partmaster CSV files") + flagHTTPServer := flag.Bool("http", false, "start KiCad HTTP Library API server") + flagHTTPPort := flag.Int("port", 8080, "HTTP server port") + flagHTTPToken := flag.String("token", "", "authentication token for HTTP API") flag.Parse() if *flagVersion { @@ -143,6 +146,27 @@ func main() { return } + // Start HTTP server if requested + if *flagHTTPServer { + if *flagPMDir == "" { + log.Fatal("Error: partmaster directory not specified. Use -pmDir flag or configure gitplm.yml") + } + + log.Printf("Starting KiCad HTTP Library API server...") + log.Printf("Partmaster directory: %s", *flagPMDir) + if *flagHTTPToken != "" { + log.Printf("Authentication enabled with token") + } else { + log.Printf("No authentication token specified - server will be open") + } + + err := StartKiCadServer(*flagPMDir, *flagHTTPToken, *flagHTTPPort) + if err != nil { + log.Fatal("Error starting HTTP server: ", err) + } + return + } + // If no flags were provided, show the TUI if len(os.Args) == 1 { err := runTUINew(*flagPMDir) diff --git a/partmaster.go b/partmaster.go index d0b56eb..7badc35 100644 --- a/partmaster.go +++ b/partmaster.go @@ -89,6 +89,13 @@ func loadPartmasterFromDir(dir string) (partmaster, error) { return pm, fmt.Errorf("error loading CSV file %s: %v", file, err) } + // Post-process to fix missing values + for _, part := range temp { + if part.Value == "" && part.MPN != "" { + part.Value = part.MPN + } + } + pm = append(pm, temp...) } From 8121ed906b4a0189c2f7533fb3d4f70a9c414935 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Mon, 7 Jul 2025 17:56:21 -0400 Subject: [PATCH 12/13] update cl --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46220d..fab9669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ For more details or to discuss releases, please visit the formats - YAML configuration file support (`gitplm.yaml`, `gitplm.yml`, `.gitplm.yaml`, `.gitplm.yml`) +- Start of KiCad HTTP library API support ### Enhanced @@ -31,7 +32,8 @@ For more details or to discuss releases, please visit the - File search priority: base pattern (`CCC-NNN.csv`) first, then variation pattern (`CCC-NNN-VV.csv`) - Improved user experience with seamless configuration flow in TUI mode -- Quantity fields now support fractional values (e.g., 0.5, 1.25) for more precise BOM specifications +- Quantity fields now support fractional values (e.g., 0.5, 1.25) for more + precise BOM specifications ## [[0.6.0] - 2024-02-02](https://github.com/git-plm/gitplm/releases/tag/v0.6.0) From a6848a0ea65a8758a051422e3f98f7f36d179ac9 Mon Sep 17 00:00:00 2001 From: Cliff Brake Date: Fri, 11 Jul 2025 08:58:01 -0400 Subject: [PATCH 13/13] update kicad api --- kicad_api.go | 266 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 169 insertions(+), 97 deletions(-) diff --git a/kicad_api.go b/kicad_api.go index 4bfbe7c..fcdf394 100644 --- a/kicad_api.go +++ b/kicad_api.go @@ -30,11 +30,11 @@ type KiCadPartSummary struct { // KiCadPartDetail represents a detailed part in the KiCad HTTP API type KiCadPartDetail struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - SymbolIDStr string `json:"symbolIdStr,omitempty"` - ExcludeFromBOM string `json:"exclude_from_bom,omitempty"` - Fields map[string]KiCadPartField `json:"fields,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + SymbolIDStr string `json:"symbolIdStr,omitempty"` + ExcludeFromBOM string `json:"exclude_from_bom,omitempty"` + Fields map[string]KiCadPartField `json:"fields,omitempty"` } // KiCadPartField represents a field in a KiCad part @@ -51,9 +51,9 @@ type KiCadRootResponse struct { // KiCadServer represents the KiCad HTTP API server type KiCadServer struct { - pmDir string - partmaster partmaster - token string + pmDir string + csvCollection *CSVFileCollection + token string } // NewKiCadServer creates a new KiCad HTTP API server @@ -62,27 +62,27 @@ func NewKiCadServer(pmDir, token string) (*KiCadServer, error) { pmDir: pmDir, token: token, } - - // Load partmaster data - if err := server.loadPartmaster(); err != nil { - return nil, fmt.Errorf("failed to load partmaster: %w", err) + + // Load CSV collection data + if err := server.loadCSVCollection(); err != nil { + return nil, fmt.Errorf("failed to load CSV collection: %w", err) } - + return server, nil } -// loadPartmaster loads the partmaster data from the configured directory -func (s *KiCadServer) loadPartmaster() error { +// loadCSVCollection loads the CSV collection from the configured directory +func (s *KiCadServer) loadCSVCollection() error { if s.pmDir == "" { return fmt.Errorf("partmaster directory not configured") } - - pm, err := loadPartmasterFromDir(s.pmDir) + + collection, err := loadAllCSVFiles(s.pmDir) if err != nil { - return fmt.Errorf("failed to load partmaster from %s: %w", s.pmDir, err) + return fmt.Errorf("failed to load CSV files from %s: %w", s.pmDir, err) } - - s.partmaster = pm + + s.csvCollection = collection return nil } @@ -91,30 +91,40 @@ func (s *KiCadServer) authenticate(r *http.Request) bool { if s.token == "" { return true // No authentication required if no token set } - + auth := r.Header.Get("Authorization") expectedAuth := "Token " + s.token return auth == expectedAuth } -// getCategories extracts unique categories from the partmaster data +// getCategories extracts unique categories from the CSV collection func (s *KiCadServer) getCategories() []KiCadCategory { categoryMap := make(map[string]bool) - - // Extract categories from IPNs (CCC component) - for _, part := range s.partmaster { - if part.IPN != "" { - category := s.extractCategory(string(part.IPN)) - if category != "" { - categoryMap[category] = true + + // Extract categories from CSV files and IPNs + for _, file := range s.csvCollection.Files { + // Try to extract category from filename (e.g., cap.csv -> CAP) + if fileName := strings.TrimSuffix(strings.ToUpper(file.Name), ".CSV"); fileName != "" && len(fileName) == 3 { + categoryMap[fileName] = true + } + + // Also extract from IPNs if they exist + if ipnIdx := s.findColumnIndex(file, "IPN"); ipnIdx >= 0 { + for _, row := range file.Rows { + if len(row) > ipnIdx && row[ipnIdx] != "" { + category := s.extractCategory(row[ipnIdx]) + if category != "" { + categoryMap[category] = true + } + } } } } - + // Convert to sorted slice categoryNames := lo.Keys(categoryMap) sort.Strings(categoryNames) - + categories := make([]KiCadCategory, len(categoryNames)) for i, name := range categoryNames { categories[i] = KiCadCategory{ @@ -123,10 +133,20 @@ func (s *KiCadServer) getCategories() []KiCadCategory { Description: s.getCategoryDescription(name), } } - + return categories } +// findColumnIndex finds the index of a column by name in a CSV file +func (s *KiCadServer) findColumnIndex(file *CSVFile, columnName string) int { + for i, header := range file.Headers { + if header == columnName { + return i + } + } + return -1 +} + // extractCategory extracts the CCC component from an IPN func (s *KiCadServer) extractCategory(ipnStr string) string { // IPN format: CCC-NNN-VVVV @@ -172,7 +192,7 @@ func (s *KiCadServer) getCategoryDisplayName(category string) string { "ANT": "Antennas", "CBL": "Cables", } - + if displayName, exists := displayNames[category]; exists { return displayName } @@ -213,7 +233,7 @@ func (s *KiCadServer) getCategoryDescription(category string) string { "ANT": "Antenna components", "CBL": "Cable components", } - + if description, exists := descriptions[category]; exists { return description } @@ -223,63 +243,112 @@ func (s *KiCadServer) getCategoryDescription(category string) string { // getPartsByCategory returns parts filtered by category func (s *KiCadServer) getPartsByCategory(categoryID string) []KiCadPartSummary { var parts []KiCadPartSummary - - for _, part := range s.partmaster { - if part.IPN != "" && s.extractCategory(string(part.IPN)) == categoryID { - parts = append(parts, KiCadPartSummary{ - ID: string(part.IPN), - Name: part.Description, - Description: part.Description, - }) + + for _, file := range s.csvCollection.Files { + // Check if this file belongs to the category + fileName := strings.TrimSuffix(strings.ToUpper(file.Name), ".CSV") + fileCategory := "" + + // Try to get category from filename + if len(fileName) == 3 { + fileCategory = fileName + } + + // Check parts within this file + ipnIdx := s.findColumnIndex(file, "IPN") + descIdx := s.findColumnIndex(file, "Description") + + for _, row := range file.Rows { + if len(row) == 0 { + continue + } + + // Determine part category + partCategory := fileCategory + if ipnIdx >= 0 && len(row) > ipnIdx && row[ipnIdx] != "" { + partCategory = s.extractCategory(row[ipnIdx]) + } + + // Include if category matches + if partCategory == categoryID { + partID := "" + partName := "" + partDesc := "" + + // Get part ID (prefer IPN, fallback to row index) + if ipnIdx >= 0 && len(row) > ipnIdx && row[ipnIdx] != "" { + partID = row[ipnIdx] + } else { + partID = fmt.Sprintf("%s-unknown-%d", categoryID, len(parts)) + } + + // Get description + if descIdx >= 0 && len(row) > descIdx { + partName = row[descIdx] + partDesc = row[descIdx] + } + + parts = append(parts, KiCadPartSummary{ + ID: partID, + Name: partName, + Description: partDesc, + }) + } } } - + return parts } // getPartDetail returns detailed information for a specific part func (s *KiCadServer) getPartDetail(partID string) *KiCadPartDetail { - for _, part := range s.partmaster { - if string(part.IPN) == partID { - fields := make(map[string]KiCadPartField) - - // Add standard fields - if part.Value != "" { - fields["Value"] = KiCadPartField{Value: part.Value} - } - if part.Manufacturer != "" { - fields["Manufacturer"] = KiCadPartField{Value: part.Manufacturer} - } - if part.MPN != "" { - fields["MPN"] = KiCadPartField{Value: part.MPN} - } - if part.Datasheet != "" { - fields["Datasheet"] = KiCadPartField{Value: part.Datasheet} + for _, file := range s.csvCollection.Files { + ipnIdx := s.findColumnIndex(file, "IPN") + + for _, row := range file.Rows { + if len(row) == 0 { + continue } - if part.Footprint != "" { - fields["Footprint"] = KiCadPartField{Value: part.Footprint} + + // Check if this is the right part + rowPartID := "" + if ipnIdx >= 0 && len(row) > ipnIdx { + rowPartID = row[ipnIdx] } - - // Add IPN as a field - fields["IPN"] = KiCadPartField{Value: string(part.IPN)} - - return &KiCadPartDetail{ - ID: string(part.IPN), - Name: part.Description, - SymbolIDStr: s.getSymbolID(part), - ExcludeFromBOM: "false", // Default to include in BOM - Fields: fields, + + if rowPartID == partID { + fields := make(map[string]KiCadPartField) + partName := "" + category := s.extractCategory(partID) + + // Add all fields from the CSV dynamically + for i, header := range file.Headers { + if i < len(row) && row[i] != "" && header != "" { + fields[header] = KiCadPartField{Value: row[i]} + + // Set name from Description field + if header == "Description" { + partName = row[i] + } + } + } + + return &KiCadPartDetail{ + ID: partID, + Name: partName, + SymbolIDStr: s.getSymbolIDFromCategory(category), + ExcludeFromBOM: "false", // Default to include in BOM + Fields: fields, + } } } } - + return nil } -// getSymbolID generates a symbol ID for a part based on its category and properties -func (s *KiCadServer) getSymbolID(part *partmasterLine) string { - category := s.extractCategory(string(part.IPN)) - +// getSymbolIDFromCategory generates a symbol ID based on category +func (s *KiCadServer) getSymbolIDFromCategory(category string) string { // Map categories to common KiCad symbol library symbols symbolMap := map[string]string{ "CAP": "Device:C", @@ -298,12 +367,15 @@ func (s *KiCadServer) getSymbolID(part *partmasterLine) string { "SNS": "Sensor:Sensor", "CNT": "Connector:Conn_01x02", "ANT": "Device:Antenna", + "ANA": "Device:IC", // Analog IC + "SCR": "Mechanical:MountingHole", + "MCH": "Mechanical:MountingHole", } - + if symbol, exists := symbolMap[category]; exists { return symbol } - + // Default symbol return "Device:Device" } @@ -316,17 +388,17 @@ func (s *KiCadServer) rootHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - + // Extract base URL from request baseURL := fmt.Sprintf("%s://%s%s", getScheme(r), r.Host, strings.TrimSuffix(r.URL.Path, "/")) - + response := KiCadRootResponse{ Categories: baseURL + "/categories.json", Parts: baseURL + "/parts", } - + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // categoriesHandler handles the categories endpoint @@ -335,11 +407,11 @@ func (s *KiCadServer) categoriesHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - + categories := s.getCategories() - + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(categories) + _ = json.NewEncoder(w).Encode(categories) } // partsByCategoryHandler handles the parts by category endpoint @@ -348,15 +420,15 @@ func (s *KiCadServer) partsByCategoryHandler(w http.ResponseWriter, r *http.Requ http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - + // Extract category ID from URL path path := strings.TrimPrefix(r.URL.Path, "/v1/parts/category/") categoryID := strings.TrimSuffix(path, ".json") - + parts := s.getPartsByCategory(categoryID) - + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(parts) + _ = json.NewEncoder(w).Encode(parts) } // partDetailHandler handles the part detail endpoint @@ -365,17 +437,17 @@ func (s *KiCadServer) partDetailHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - + // Extract part ID from URL path path := strings.TrimPrefix(r.URL.Path, "/v1/parts/") partID := strings.TrimSuffix(path, ".json") - + part := s.getPartDetail(partID) if part == nil { http.Error(w, "Part not found", http.StatusNotFound) return } - + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(part) } @@ -397,19 +469,19 @@ func StartKiCadServer(pmDir, token string, port int) error { if err != nil { return fmt.Errorf("failed to create KiCad server: %w", err) } - + // Set up routes http.HandleFunc("/v1/", server.rootHandler) http.HandleFunc("/v1/categories.json", server.categoriesHandler) http.HandleFunc("/v1/parts/category/", server.partsByCategoryHandler) http.HandleFunc("/v1/parts/", server.partDetailHandler) - + // Add a health check endpoint http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) - + addr := fmt.Sprintf(":%d", port) log.Printf("Starting KiCad HTTP Library API server on %s", addr) log.Printf("API endpoints:") @@ -417,6 +489,6 @@ func StartKiCadServer(pmDir, token string, port int) error { log.Printf(" Categories: http://localhost%s/v1/categories.json", addr) log.Printf(" Parts by category: http://localhost%s/v1/parts/category/{category_id}.json", addr) log.Printf(" Part detail: http://localhost%s/v1/parts/{part_id}.json", addr) - + return http.ListenAndServe(addr, nil) -} \ No newline at end of file +}