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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d21769..fab9669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ 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`) +- Start of KiCad HTTP library API support + +### 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 @@ -21,6 +45,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) diff --git a/README.md b/README.md index 26e1d0c..6ac5f53 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 @@ -129,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` + +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 is found, GitPLM considers this a source directory and will +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/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 { diff --git a/config.go b/config.go new file mode 100644 index 0000000..f24ad7c --- /dev/null +++ b/config.go @@ -0,0 +1,68 @@ +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 +} + +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/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/example/ana.csv b/example/ana.csv new file mode 100644 index 0000000..fed2043 --- /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,LT1716IS5#TRPBF,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, diff --git a/go.mod b/go.mod index 8f45bc6..ec35e57 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,13 @@ 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/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 github.com/otiai10/copy v1.9.0 github.com/samber/lo v1.33.0 @@ -12,6 +15,25 @@ require ( ) require ( - golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + github.com/atotto/clipboard v0.1.4 // 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/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 + 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..1a43b4b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,49 @@ +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/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= +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/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= 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/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= +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 +55,30 @@ 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/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= 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/kicad_api.go b/kicad_api.go new file mode 100644 index 0000000..fcdf394 --- /dev/null +++ b/kicad_api.go @@ -0,0 +1,494 @@ +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 + csvCollection *CSVFileCollection + token string +} + +// NewKiCadServer creates a new KiCad HTTP API server +func NewKiCadServer(pmDir, token string) (*KiCadServer, error) { + server := &KiCadServer{ + pmDir: pmDir, + token: token, + } + + // Load CSV collection data + if err := server.loadCSVCollection(); err != nil { + return nil, fmt.Errorf("failed to load CSV collection: %w", err) + } + + return server, nil +} + +// loadCSVCollection loads the CSV collection from the configured directory +func (s *KiCadServer) loadCSVCollection() error { + if s.pmDir == "" { + return fmt.Errorf("partmaster directory not configured") + } + + collection, err := loadAllCSVFiles(s.pmDir) + if err != nil { + return fmt.Errorf("failed to load CSV files from %s: %w", s.pmDir, err) + } + + s.csvCollection = collection + 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 CSV collection +func (s *KiCadServer) getCategories() []KiCadCategory { + categoryMap := make(map[string]bool) + + // 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{ + ID: name, + Name: s.getCategoryDisplayName(name), + 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 + 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 _, 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 _, file := range s.csvCollection.Files { + ipnIdx := s.findColumnIndex(file, "IPN") + + for _, row := range file.Rows { + if len(row) == 0 { + continue + } + + // Check if this is the right part + rowPartID := "" + if ipnIdx >= 0 && len(row) > ipnIdx { + rowPartID = row[ipnIdx] + } + + 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 +} + +// 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", + "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", + "ANA": "Device:IC", // Analog IC + "SCR": "Mechanical:MountingHole", + "MCH": "Mechanical:MountingHole", + } + + 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) +} diff --git a/main.go b/main.go index 86be045..36efcac 100644 --- a/main.go +++ b/main.go @@ -15,12 +15,22 @@ 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") + 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 { @@ -136,6 +146,36 @@ 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) + if err != nil { + log.Fatal("Error running TUI: ", err) + } + return + } + fmt.Println("Error, please specify an action") flag.Usage() } 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...) } 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 { diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..0334eb1 --- /dev/null +++ b/tui.go @@ -0,0 +1,286 @@ +package main + +import ( + "os" + "strings" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + 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) + + 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 + textInput textinput.Model + table table.Model + showInput bool + pmDir string + error string + done bool + partmaster partmaster +} + +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 { + return nil +} + +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: + 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 + } + } + } + + 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 { + if m.width == 0 { + return "" + } + + // Create the GitPLM title + title := titleStyle.Width(m.width).Render("GitPLM") + + 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 + } +} + +func runTUI(pmDir string) error { + needsPMDir := pmDir == "" + p := tea.NewProgram(initialModel(needsPMDir, pmDir), tea.WithAltScreen()) + _, err := p.Run() + return 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