diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e66aec --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Silo Environment Configuration +# Copy this file to .env and update values as needed + +# PostgreSQL +POSTGRES_PASSWORD=silodev + +# MinIO +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin + +# Silo API (optional overrides) +# SILO_SERVER_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78d33ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Binaries +/silo +/silod +*.exe +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace +go.work +go.work.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Config with secrets +config.yaml +*.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +*.egg-info/ +.eggs/ +dist/ +build/ + +# FreeCAD +*.FCStd1 +*.FCBak diff --git a/HolePattern.FCMacro b/HolePattern.FCMacro new file mode 100644 index 0000000..ed4aa8e --- /dev/null +++ b/HolePattern.FCMacro @@ -0,0 +1,68 @@ +import FreeCAD as App +import Sketcher +import Part + +def generate_hole_pattern(): + """Generate parametric hole grid from spreadsheet values.""" + + doc = App.ActiveDocument + if not doc: + App.Console.PrintError("No active document\n") + return + + # Create/get spreadsheet + sheet = doc.getObject('Spreadsheet') + if not sheet: + sheet = doc.addObject('Spreadsheet::Sheet', 'Spreadsheet') + sheet.set('A1', 'hole_spacing'); sheet.set('B1', '10 mm'); sheet.setAlias('B1', 'hole_spacing') + sheet.set('A2', 'hole_radius'); sheet.set('B2', '2.5 mm'); sheet.setAlias('B2', 'hole_radius') + sheet.set('A3', 'grid_offset_x'); sheet.set('B3', '10 mm'); sheet.setAlias('B3', 'grid_offset_x') + sheet.set('A4', 'grid_offset_y'); sheet.set('B4', '10 mm'); sheet.setAlias('B4', 'grid_offset_y') + sheet.set('A5', 'grid_cols'); sheet.set('B5', '5'); sheet.setAlias('B5', 'grid_cols') + sheet.set('A6', 'grid_rows'); sheet.set('B6', '5'); sheet.setAlias('B6', 'grid_rows') + doc.recompute() + App.Console.PrintMessage("Created Spreadsheet with default parameters\n") + + # Read grid size + cols = int(sheet.grid_cols) + rows = int(sheet.grid_rows) + + # Get/create sketch + sketch = doc.getObject('HolePatternSketch') + if not sketch: + body = doc.getObject('Body') + if body: + sketch = body.newObject('Sketcher::SketchObject', 'HolePatternSketch') + sketch.AttachmentSupport = [(body.Origin.XY_Plane, '')] + sketch.MapMode = 'FlatFace' + else: + sketch = doc.addObject('Sketcher::SketchObject', 'HolePatternSketch') + App.Console.PrintMessage("Created HolePatternSketch\n") + + # Clear existing geometry + for i in range(sketch.GeometryCount - 1, -1, -1): + sketch.delGeometry(i) + + # Generate pattern + for i in range(cols): + for j in range(rows): + circle_idx = sketch.addGeometry( + Part.Circle(App.Vector(0, 0, 0), App.Vector(0, 0, 1), 1), + False + ) + + cx = sketch.addConstraint(Sketcher.Constraint('DistanceX', -1, 1, circle_idx, 3, 10)) + sketch.setExpression(f'Constraints[{cx}]', f'Spreadsheet.grid_offset_x + {i} * Spreadsheet.hole_spacing') + + cy = sketch.addConstraint(Sketcher.Constraint('DistanceY', -1, 1, circle_idx, 3, 10)) + sketch.setExpression(f'Constraints[{cy}]', f'Spreadsheet.grid_offset_y + {j} * Spreadsheet.hole_spacing') + + r = sketch.addConstraint(Sketcher.Constraint('Radius', circle_idx, 1)) + sketch.setExpression(f'Constraints[{r}]', 'Spreadsheet.hole_radius') + + doc.recompute() + App.Console.PrintMessage(f"Generated {cols}x{rows} hole pattern ({cols*rows} holes)\n") + +# Run when macro is executed +if __name__ == '__main__': + generate_hole_pattern() diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e559364 --- /dev/null +++ b/Makefile @@ -0,0 +1,226 @@ +.PHONY: build run test clean migrate fmt lint \ + docker-build docker-up docker-down docker-logs docker-ps \ + docker-clean docker-rebuild + +# ============================================================================= +# Local Development +# ============================================================================= + +# Build all binaries +build: + go build -o silo ./cmd/silo + go build -o silod ./cmd/silod + +# Run the API server locally +run: + go run ./cmd/silod -config config.yaml + +# Run the CLI +cli: + go run ./cmd/silo $(ARGS) + +# Run tests +test: + go test -v ./... + +# Clean build artifacts +clean: + rm -f silo silod + rm -f *.out + +# Format code +fmt: + go fmt ./... + goimports -w . + +# Lint code +lint: + golangci-lint run + +# Tidy dependencies +tidy: + go mod tidy + +# ============================================================================= +# Database +# ============================================================================= + +# Run database migrations (requires SILO_DB_* environment variables) +migrate: + ./scripts/init-db.sh + +# Connect to database (requires psql) +db-shell: + PGPASSWORD=$${SILO_DB_PASSWORD:-silodev} psql -h $${SILO_DB_HOST:-localhost} -U $${SILO_DB_USER:-silo} -d $${SILO_DB_NAME:-silo} + +# ============================================================================= +# Docker +# ============================================================================= + +# Build Docker image +docker-build: + docker build -t silo:latest -f build/package/Dockerfile . + +# Start the full stack (postgres + minio + silo) +docker-up: + docker compose -f deployments/docker-compose.yaml up -d + +# Start with rebuild +docker-up-build: + docker compose -f deployments/docker-compose.yaml up -d --build + +# Stop the stack +docker-down: + docker compose -f deployments/docker-compose.yaml down + +# Stop and remove volumes (WARNING: deletes data) +docker-clean: + docker compose -f deployments/docker-compose.yaml down -v + +# View logs +docker-logs: + docker compose -f deployments/docker-compose.yaml logs -f + +# View logs for specific service +docker-logs-silo: + docker compose -f deployments/docker-compose.yaml logs -f silo + +docker-logs-postgres: + docker compose -f deployments/docker-compose.yaml logs -f postgres + +docker-logs-minio: + docker compose -f deployments/docker-compose.yaml logs -f minio + +# Show running containers +docker-ps: + docker compose -f deployments/docker-compose.yaml ps + +# Rebuild and restart +docker-rebuild: docker-down docker-build docker-up + +# Shell into silo container +docker-shell: + docker compose -f deployments/docker-compose.yaml exec silo /bin/sh + +# ============================================================================= +# FreeCAD Integration +# ============================================================================= + +# Detect FreeCAD Mod directory (Flatpak or native) +# Flatpak app ID can be org.freecad.FreeCAD or org.freecadweb.FreeCAD +FREECAD_MOD_DIR_FLATPAK := $(HOME)/.var/app/org.freecad.FreeCAD/data/FreeCAD/Mod +FREECAD_MOD_DIR_NATIVE := $(HOME)/.local/share/FreeCAD/Mod +FREECAD_MOD_DIR_LEGACY := $(HOME)/.FreeCAD/Mod + +# Install FreeCAD workbench (auto-detect Flatpak or native) +install-freecad: + @if [ -d "$(HOME)/.var/app/org.freecad.FreeCAD" ]; then \ + echo "Detected Flatpak FreeCAD (org.freecad.FreeCAD)"; \ + mkdir -p $(FREECAD_MOD_DIR_FLATPAK); \ + rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo; \ + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo; \ + echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo"; \ + else \ + echo "Using native FreeCAD installation"; \ + mkdir -p $(FREECAD_MOD_DIR_NATIVE); \ + mkdir -p $(FREECAD_MOD_DIR_LEGACY); \ + rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo; \ + rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo; \ + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo; \ + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo; \ + echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo"; \ + fi + @echo "" + @echo "Restart FreeCAD to load the Silo workbench" + +# Install for Flatpak FreeCAD explicitly +install-freecad-flatpak: + mkdir -p $(FREECAD_MOD_DIR_FLATPAK) + rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo + @echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo" + @echo "Restart FreeCAD to load the Silo workbench" + +# Install for native FreeCAD explicitly +install-freecad-native: + mkdir -p $(FREECAD_MOD_DIR_NATIVE) + mkdir -p $(FREECAD_MOD_DIR_LEGACY) + rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo + rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo + ln -sf $(PWD)/pkg/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo + @echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo" + +# Uninstall FreeCAD workbench +uninstall-freecad: + rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo + rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo + rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo + @echo "Uninstalled Silo workbench" + +# ============================================================================= +# API Testing +# ============================================================================= + +# Test health endpoint +api-health: + curl -s http://localhost:8080/health | jq . + +# Test ready endpoint +api-ready: + curl -s http://localhost:8080/ready | jq . + +# List schemas +api-schemas: + curl -s http://localhost:8080/api/schemas | jq . + +# List items +api-items: + curl -s http://localhost:8080/api/items | jq . + +# Create sample item +api-create-item: + curl -s -X POST http://localhost:8080/api/items \ + -H "Content-Type: application/json" \ + -d '{"schema":"kindred-rd","project":"CS100","category":"F01","material":"316","description":"Test screw"}' | jq . + +# ============================================================================= +# Help +# ============================================================================= + +help: + @echo "Silo Makefile targets:" + @echo "" + @echo "Local Development:" + @echo " build - Build CLI and server binaries" + @echo " run - Run API server locally" + @echo " cli ARGS=... - Run CLI with arguments" + @echo " test - Run tests" + @echo " fmt - Format code" + @echo " lint - Run linter" + @echo " tidy - Tidy go.mod" + @echo "" + @echo "Docker:" + @echo " docker-build - Build Docker image" + @echo " docker-up - Start full stack (postgres + minio + silo)" + @echo " docker-down - Stop the stack" + @echo " docker-clean - Stop and remove volumes (deletes data)" + @echo " docker-logs - View all logs" + @echo " docker-ps - Show running containers" + @echo " docker-rebuild - Rebuild and restart" + @echo "" + @echo "Database:" + @echo " migrate - Run database migrations" + @echo " db-shell - Connect to database with psql" + @echo "" + @echo "FreeCAD:" + @echo " install-freecad - Install workbench (auto-detect Flatpak/native)" + @echo " install-freecad-flatpak - Install for Flatpak FreeCAD" + @echo " install-freecad-native - Install for native FreeCAD" + @echo " uninstall-freecad - Remove workbench symlinks" + @echo "" + @echo "API Testing:" + @echo " api-health - Test health endpoint" + @echo " api-schemas - List schemas" + @echo " api-items - List items" + @echo " api-create-item - Create a test item" diff --git a/README.md b/README.md index e69de29..f595c0d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,76 @@ +# Silo + +Item database and part management system for FreeCAD. + +## Overview + +Silo is an R&D-oriented item database with: + +- **Configurable part number generation** via YAML schemas +- **FreeCAD integration** with git-like commands (checkout, commit, status) +- **Revision tracking** with append-only history +- **BOM management** with reference designators and alternates +- **Physical inventory** tracking with hierarchical locations + +## Components + +``` +silo/ +├── cmd/ +│ ├── silo/ # CLI tool +│ └── silod/ # API server +├── internal/ +│ ├── config/ # Configuration loading +│ ├── db/ # PostgreSQL access +│ ├── schema/ # YAML schema parsing +│ ├── storage/ # MinIO file storage +│ ├── partnum/ # Part number generation +│ ├── inventory/ # Location and stock management +│ └── api/ # HTTP handlers +├── pkg/ +│ └── freecad/ # FreeCAD workbench (Python) +├── web/ +│ ├── templates/ # HTML templates +│ └── static/ # CSS, JS assets +├── migrations/ # Database migrations +├── schemas/ # Example YAML schemas +└── docs/ # Documentation +``` + +## Quick Start + +```bash +# Database setup +psql -h psql.kindred.internal -U silo -d silo -f migrations/001_initial.sql + +# Configure +cp config.example.yaml config.yaml +# Edit config.yaml with your settings + +# Run server +go run ./cmd/silod + +# CLI usage +go run ./cmd/silo register --schema kindred-rd --project PROTO --type AS +``` + +## Configuration + +See `config.example.yaml` for all options. + +## FreeCAD Integration + +Install the workbench: + +```bash +ln -s $(pwd)/pkg/freecad ~/.local/share/FreeCAD/Mod/Silo +``` + +Then in FreeCAD: +- `silo checkout PROTO-AS-0001` +- `silo commit -m "Updated dimensions"` +- `silo status` + +## License + +Proprietary - Kindred Systems LLC diff --git a/cmd/silo/main.go b/cmd/silo/main.go new file mode 100644 index 0000000..7567cb2 --- /dev/null +++ b/cmd/silo/main.go @@ -0,0 +1,313 @@ +// Command silo provides CLI access to the Silo item database. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/kindredsystems/silo/internal/config" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/partnum" + "github.com/kindredsystems/silo/internal/schema" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + ctx := context.Background() + cmd := os.Args[1] + + switch cmd { + case "help", "-h", "--help": + printUsage() + case "register": + cmdRegister(ctx) + case "list": + cmdList(ctx) + case "show": + cmdShow(ctx) + case "revisions": + cmdRevisions(ctx) + case "schemas": + cmdSchemas(ctx) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Println(`Silo - Item database CLI + +Usage: silo [options] + +Commands: + register Generate a new part number and create item + list List items + show Show item details + revisions Show item revision history + schemas List available schemas + +Examples: + silo register --schema kindred-rd --project PROTO --type AS + silo list --type assembly + silo show PROTO-AS-0001 + silo revisions PROTO-AS-0001`) +} + +func loadConfig() *config.Config { + cfg, err := config.Load("config.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } + return cfg +} + +func connectDB(ctx context.Context, cfg *config.Config) *db.DB { + database, err := db.Connect(ctx, db.Config{ + Host: cfg.Database.Host, + Port: cfg.Database.Port, + Name: cfg.Database.Name, + User: cfg.Database.User, + Password: cfg.Database.Password, + SSLMode: cfg.Database.SSLMode, + MaxConnections: cfg.Database.MaxConnections, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err) + os.Exit(1) + } + return database +} + +func cmdRegister(ctx context.Context) { + // Parse flags + var schemaName, project, partType, description string + args := os.Args[2:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--schema", "-s": + i++ + schemaName = args[i] + case "--project", "-p": + i++ + project = args[i] + case "--type", "-t": + i++ + partType = args[i] + case "--description", "-d": + i++ + description = args[i] + } + } + + if schemaName == "" || project == "" || partType == "" { + fmt.Fprintln(os.Stderr, "Usage: silo register --schema NAME --project CODE --type TYPE [--description DESC]") + os.Exit(1) + } + + cfg := loadConfig() + database := connectDB(ctx, cfg) + defer database.Close() + + // Load schemas + schemas, err := schema.LoadAll(cfg.Schemas.Directory) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err) + os.Exit(1) + } + + // Create generator with DB-backed sequence store + seqStore := &dbSequenceStore{db: database, schemas: schemas} + gen := partnum.NewGenerator(schemas, seqStore) + + // Generate part number + pn, err := gen.Generate(ctx, partnum.Input{ + SchemaName: schemaName, + Values: map[string]string{ + "project": project, + "part_type": partType, + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating part number: %v\n", err) + os.Exit(1) + } + + // Create item in database + repo := db.NewItemRepository(database) + schemaRecord := schemas[schemaName] + schemaID := getSchemaID(ctx, database, schemaRecord.Name) + + item := &db.Item{ + PartNumber: pn, + SchemaID: schemaID, + ItemType: mapPartType(partType), + Description: description, + } + + if err := repo.Create(ctx, item, map[string]any{ + "project": project, + "part_type": partType, + }); err != nil { + fmt.Fprintf(os.Stderr, "Error creating item: %v\n", err) + os.Exit(1) + } + + fmt.Println(pn) +} + +func cmdList(ctx context.Context) { + cfg := loadConfig() + database := connectDB(ctx, cfg) + defer database.Close() + + var itemType, search string + args := os.Args[2:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--type", "-t": + i++ + itemType = args[i] + case "--search", "-s": + i++ + search = args[i] + } + } + + repo := db.NewItemRepository(database) + items, err := repo.List(ctx, db.ListOptions{ + ItemType: itemType, + Search: search, + Limit: 100, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing items: %v\n", err) + os.Exit(1) + } + + for _, item := range items { + fmt.Printf("%s\t%s\t%s\n", item.PartNumber, item.ItemType, item.Description) + } +} + +func cmdShow(ctx context.Context) { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: silo show PART-NUMBER") + os.Exit(1) + } + partNumber := os.Args[2] + + cfg := loadConfig() + database := connectDB(ctx, cfg) + defer database.Close() + + repo := db.NewItemRepository(database) + item, err := repo.GetByPartNumber(ctx, partNumber) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if item == nil { + fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber) + os.Exit(1) + } + + out, _ := json.MarshalIndent(item, "", " ") + fmt.Println(string(out)) +} + +func cmdRevisions(ctx context.Context) { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: silo revisions PART-NUMBER") + os.Exit(1) + } + partNumber := os.Args[2] + + cfg := loadConfig() + database := connectDB(ctx, cfg) + defer database.Close() + + repo := db.NewItemRepository(database) + item, err := repo.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber) + os.Exit(1) + } + + revisions, err := repo.GetRevisions(ctx, item.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + for _, rev := range revisions { + comment := "" + if rev.Comment != nil { + comment = *rev.Comment + } + fmt.Printf("Rev %d\t%s\t%s\n", rev.RevisionNumber, rev.CreatedAt.Format("2006-01-02 15:04"), comment) + } +} + +func cmdSchemas(ctx context.Context) { + cfg := loadConfig() + schemas, err := schema.LoadAll(cfg.Schemas.Directory) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err) + os.Exit(1) + } + + for name, s := range schemas { + fmt.Printf("%s\tv%d\t%s\n", name, s.Version, s.Description) + } +} + +// dbSequenceStore implements partnum.SequenceStore using the database. +type dbSequenceStore struct { + db *db.DB + schemas map[string]*schema.Schema +} + +func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) { + schemaID := getSchemaID(ctx, s.db, schemaName) + if schemaID == nil { + return 0, fmt.Errorf("schema not found: %s", schemaName) + } + return s.db.NextSequenceValue(ctx, *schemaID, scope) +} + +func getSchemaID(ctx context.Context, database *db.DB, name string) *string { + var id string + err := database.Pool().QueryRow(ctx, + "SELECT id FROM schemas WHERE name = $1", name, + ).Scan(&id) + if err != nil { + return nil + } + return &id +} + +func mapPartType(code string) string { + types := map[string]string{ + "AS": "assembly", + "PT": "part", + "DW": "drawing", + "DC": "document", + "TB": "tooling", + "PC": "purchased", + "EL": "electrical", + "SW": "software", + } + if t, ok := types[code]; ok { + return t + } + return code +} diff --git a/cmd/silod/main.go b/cmd/silod/main.go new file mode 100644 index 0000000..6c1d0fc --- /dev/null +++ b/cmd/silod/main.go @@ -0,0 +1,126 @@ +// Command silod is the Silo HTTP API server. +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/kindredsystems/silo/internal/api" + "github.com/kindredsystems/silo/internal/config" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/storage" + "github.com/rs/zerolog" +) + +func main() { + // Parse flags + configPath := flag.String("config", "config.yaml", "Path to configuration file") + flag.Parse() + + // Setup logger + logger := zerolog.New(os.Stdout).With().Timestamp().Logger() + + // Load configuration + cfg, err := config.Load(*configPath) + if err != nil { + logger.Fatal().Err(err).Msg("failed to load configuration") + } + + logger.Info(). + Str("host", cfg.Server.Host). + Int("port", cfg.Server.Port). + Str("database", cfg.Database.Host). + Str("storage", cfg.Storage.Endpoint). + Msg("starting silo server") + + // Connect to database + ctx := context.Background() + database, err := db.Connect(ctx, db.Config{ + Host: cfg.Database.Host, + Port: cfg.Database.Port, + Name: cfg.Database.Name, + User: cfg.Database.User, + Password: cfg.Database.Password, + SSLMode: cfg.Database.SSLMode, + MaxConnections: cfg.Database.MaxConnections, + }) + if err != nil { + logger.Fatal().Err(err).Msg("failed to connect to database") + } + defer database.Close() + logger.Info().Msg("connected to database") + + // Connect to storage (optional - may be externally managed) + var store *storage.Storage + if cfg.Storage.Endpoint != "" { + store, err = storage.Connect(ctx, storage.Config{ + Endpoint: cfg.Storage.Endpoint, + AccessKey: cfg.Storage.AccessKey, + SecretKey: cfg.Storage.SecretKey, + Bucket: cfg.Storage.Bucket, + UseSSL: cfg.Storage.UseSSL, + Region: cfg.Storage.Region, + }) + if err != nil { + logger.Warn().Err(err).Msg("failed to connect to storage - file operations disabled") + store = nil + } else { + logger.Info().Msg("connected to storage") + } + } else { + logger.Info().Msg("storage not configured - file operations disabled") + } + + // Load schemas + schemas, err := schema.LoadAll(cfg.Schemas.Directory) + if err != nil { + logger.Fatal().Err(err).Str("directory", cfg.Schemas.Directory).Msg("failed to load schemas") + } + logger.Info().Int("count", len(schemas)).Msg("loaded schemas") + + // Create API server + server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store) + router := api.NewRouter(server, logger) + + // Create HTTP server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + httpServer := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + logger.Info().Str("addr", addr).Msg("listening") + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal().Err(err).Msg("server error") + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info().Msg("shutting down server") + + // Graceful shutdown with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.Fatal().Err(err).Msg("server forced to shutdown") + } + + logger.Info().Msg("server stopped") +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..cd16777 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,42 @@ +# Silo Configuration +# Copy to config.yaml and adjust for your environment + +server: + host: "0.0.0.0" + port: 8080 + base_url: "http://localhost:8080" + +database: + host: "psql.kindred.internal" + port: 5432 + name: "silo" + user: "silo" + password: "" # Use SILO_DB_PASSWORD env var + sslmode: "require" + max_connections: 10 + +storage: + endpoint: "minio.kindred.internal:9000" + access_key: "" # Use SILO_MINIO_ACCESS_KEY env var + secret_key: "" # Use SILO_MINIO_SECRET_KEY env var + bucket: "silo-files" + use_ssl: true + region: "us-east-1" + +schemas: + # Directory containing YAML schema files + directory: "/etc/silo/schemas" + # Default schema for new items + default: "kindred-rd" + +freecad: + # URI scheme for "Open in FreeCAD" links + uri_scheme: "silo" + # Path to FreeCAD executable (for CLI operations) + executable: "/usr/bin/freecad" + +# Future: LDAP authentication +# auth: +# provider: "ldap" +# server: "ldaps://ipa.kindred.internal" +# base_dn: "dc=kindred,dc=internal" diff --git a/deployments/docker-compose.yaml b/deployments/docker-compose.yaml new file mode 100644 index 0000000..cffe1ed --- /dev/null +++ b/deployments/docker-compose.yaml @@ -0,0 +1,82 @@ +services: + postgres: + image: postgres:16-alpine + container_name: silo-postgres + environment: + POSTGRES_DB: silo + POSTGRES_USER: silo + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silodev} + volumes: + - postgres_data:/var/lib/postgresql/data + - ../migrations:/docker-entrypoint-initdb.d:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U silo -d silo"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - silo-network + + minio: + image: minio/minio:RELEASE.2023-05-04T21-44-30Z + container_name: silo-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-silominio} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-silominiosecret} + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - silo-network + + silo: + build: + context: .. + dockerfile: build/package/Dockerfile + container_name: silo-api + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + environment: + SILO_DB_HOST: postgres + SILO_DB_PORT: 5432 + SILO_DB_NAME: silo + SILO_DB_USER: silo + SILO_DB_PASSWORD: ${POSTGRES_PASSWORD:-silodev} + SILO_MINIO_ENDPOINT: minio:9000 + SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-silominio} + SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret} + SILO_MINIO_BUCKET: silo-files + SILO_MINIO_USE_SSL: "false" + ports: + - "8080:8080" + volumes: + - ../schemas:/etc/silo/schemas:ro + - ../configs/config.yaml:/etc/silo/config.yaml:ro + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - silo-network + +volumes: + postgres_data: + minio_data: + +networks: + silo-network: + driver: bridge diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..b81bf67 --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,833 @@ +# Silo: Item Database and Part Management System for FreeCAD + +**Version:** 0.1 Draft +**Date:** January 2026 +**Author:** Kindred Systems LLC + +--- + +## 1. Overview + +Silo is an item database with configurable part number generation, designed for R&D-oriented workflows. It integrates with FreeCAD 1.0+ to provide git-like object management, revision tracking, and physical inventory location management. + +### 1.1 Core Philosophy + +Silo treats **part numbering schemas as configuration, not code**. Multiple numbering schemes can coexist, each defined in YAML. The system is schema-agnostic—it doesn't impose a particular part numbering philosophy (intelligent vs. non-intelligent numbers) but instead provides the machinery to implement whatever scheme the organization requires. + +### 1.2 Key Principles + +- **Items are the atomic unit**: Everything is an item (parts, assemblies, drawings, documents) +- **Schemas are mutable**: Part numbering schemas can evolve, though migration tooling is out of scope for MVP +- **Append-only history**: All parameter changes are recorded; item state is reconstructable at any point in time +- **Configuration over convention**: Hierarchies, relationships, and behaviors are YAML-defined + +--- + +## 2. Architecture + +### 2.1 Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FreeCAD 1.0+ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Silo Workbench (Python) │ │ +│ │ - silo checkout / commit / status / log │ │ +│ │ - Part number generation │ │ +│ │ - Property sync with FreeCAD objects │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Silo Core (CLI/Library) │ +│ - Schema parsing and validation │ +│ - Part number generation engine │ +│ - Revision management │ +│ - Relationship graph │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────┐ +│ PostgreSQL │ │ MinIO │ +│ (psql.kindred.internal)│ │ - .FCStd file storage │ +│ - Item metadata │ │ - Versioned objects │ +│ - Relationships │ │ - Thumbnails │ +│ - Revision history │ │ │ +│ - Location hierarchy │ │ │ +└─────────────────────────┘ └─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Web UI (Browse/Search) │ +│ - Item browser with hierarchy navigation │ +│ - Search and filtering │ +│ - "Open in FreeCAD" links (freecad:// URI handler) │ +│ - BOM viewer │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Technology Stack + +| Component | Technology | Notes | +|-----------|------------|-------| +| Database | PostgreSQL | Existing instance at psql.kindred.internal | +| File Storage | MinIO | S3-compatible, versioning enabled | +| FreeCAD Integration | Python workbench | Macro-style commands | +| CLI | Go or Python | TBD based on complexity | +| Web UI | Go + htmx | Lightweight, minimal JS | + +--- + +## 3. Data Model + +### 3.1 Items + +An **item** is the fundamental entity. Items have: + +- A **part number** (generated according to a schema) +- A **type** (part, assembly, drawing, document, etc.) +- **Properties** (key-value pairs, schema-defined and custom) +- **Relationships** to other items +- **Revisions** (append-only history) +- **Files** (optional, stored in MinIO) +- **Location** (optional physical inventory location) + +### 3.2 Database Schema (Conceptual) + +```sql +-- Part numbering schemas (YAML stored as text, parsed at runtime) +CREATE TABLE schemas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + definition JSONB NOT NULL, -- parsed YAML + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Items (core entity) +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + part_number TEXT UNIQUE NOT NULL, + schema_id UUID REFERENCES schemas(id), + item_type TEXT NOT NULL, -- 'part', 'assembly', 'drawing', etc. + created_at TIMESTAMPTZ DEFAULT now(), + current_revision_id UUID -- points to latest revision +); + +-- Append-only revision history +CREATE TABLE revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID REFERENCES items(id) NOT NULL, + revision_number INTEGER NOT NULL, + properties JSONB NOT NULL, -- all properties at this revision + file_version TEXT, -- MinIO version ID if applicable + created_at TIMESTAMPTZ DEFAULT now(), + created_by TEXT, -- user identifier (future: LDAP DN) + comment TEXT, + UNIQUE(item_id, revision_number) +); + +-- Item relationships (BOM structure) +CREATE TABLE relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_item_id UUID REFERENCES items(id) NOT NULL, + child_item_id UUID REFERENCES items(id) NOT NULL, + relationship_type TEXT NOT NULL, -- 'component', 'alternate', 'reference' + quantity DECIMAL, + reference_designator TEXT, -- e.g., "R1", "C3" for electronics + metadata JSONB, -- assembly-specific relationship config + revision_id UUID REFERENCES revisions(id), -- which revision this applies to + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Location hierarchy (configurable via YAML) +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT UNIQUE NOT NULL, -- e.g., "lab/shelf-a/bin-3" + name TEXT NOT NULL, + parent_id UUID REFERENCES locations(id), + location_type TEXT NOT NULL, -- defined in location schema + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Item inventory (quantity at location) +CREATE TABLE inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID REFERENCES items(id) NOT NULL, + location_id UUID REFERENCES locations(id) NOT NULL, + quantity DECIMAL NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(item_id, location_id) +); + +-- Sequence counters for part number generation +CREATE TABLE sequences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + schema_id UUID REFERENCES schemas(id), + scope TEXT NOT NULL, -- scope key (e.g., project code, type code) + current_value INTEGER NOT NULL DEFAULT 0, + UNIQUE(schema_id, scope) +); +``` + +--- + +## 4. YAML Configuration System + +### 4.1 Part Numbering Schema + +Schemas define how part numbers are generated. Each schema consists of **segments** that are concatenated with a **separator**. + +```yaml +# /etc/silo/schemas/kindred-rd.yaml +schema: + name: kindred-rd + version: 1 + description: "Kindred Systems R&D part numbering" + + # Separator between segments (default: "-") + separator: "-" + + # Uniqueness enforcement + uniqueness: + scope: global # or "per-project", "per-type", "per-schema" + + segments: + - name: project + type: string + length: 5 + description: "Project identifier" + validation: + pattern: "^[A-Z0-9]{5}$" + required: true + + - name: part_type + type: enum + description: "Type of item" + values: + AS: "Assembly" + PT: "Part" + DW: "Drawing" + DC: "Document" + TB: "Tooling/Fixture" + PC: "Purchased Component" + required: true + + - name: sequence + type: serial + length: 4 + padding: "0" # left-pad with zeros + description: "Sequential number" + scope: "{project}-{part_type}" # counter scope (template) + + # Format template (optional, defaults to joining segments with separator) + format: "{project}-{part_type}-{sequence}" + + # Example outputs: + # PROTO-AS-0001 (first assembly in PROTO project) + # PROTO-PT-0001 (first part in PROTO project) + # ALPHA-AS-0001 (first assembly in ALPHA project) +``` + +### 4.2 Segment Types + +| Type | Description | Options | +|------|-------------|---------| +| `string` | Fixed or variable length string | `length`, `min_length`, `max_length`, `pattern`, `case` | +| `enum` | Predefined set of values | `values` (map of code → description) | +| `serial` | Auto-incrementing integer | `length`, `padding`, `start`, `scope` | +| `date` | Date-based segment | `format` (strftime-style) | +| `constant` | Fixed value | `value` | + +### 4.3 Serial Scope Templates + +The `scope` field in serial segments supports template variables referencing other segments: + +```yaml +# Sequence per project +scope: "{project}" + +# Sequence per project AND type (recommended for R&D) +scope: "{project}-{part_type}" + +# Global sequence (no scope) +scope: null +``` + +### 4.4 Alternative Schema Example (Simple Sequential) + +```yaml +# /etc/silo/schemas/simple.yaml +schema: + name: simple + version: 1 + description: "Simple non-intelligent numbering" + + segments: + - name: prefix + type: constant + value: "P" + + - name: sequence + type: serial + length: 6 + padding: "0" + scope: null # global counter + + format: "{prefix}{sequence}" + separator: "" + + # Output: P000001, P000002, ... +``` + +### 4.5 Location Hierarchy Schema + +```yaml +# /etc/silo/schemas/locations.yaml +location_schema: + name: kindred-lab + version: 1 + + hierarchy: + - level: 0 + type: facility + name_pattern: "^[a-z-]+$" + + - level: 1 + type: area + name_pattern: "^[a-z-]+$" + + - level: 2 + type: shelf + name_pattern: "^shelf-[a-z]$" + + - level: 3 + type: bin + name_pattern: "^bin-[0-9]+$" + + # Path format + path_separator: "/" + + # Example paths: + # lab/main-area/shelf-a/bin-1 + # lab/storage/shelf-b/bin-12 +``` + +### 4.6 Assembly Metadata Schema + +Each assembly can define its own relationship tracking behavior: + +```yaml +# Stored in item properties or as a linked document +assembly_config: + # What relationship types this assembly uses + relationship_types: + - component # standard BOM entry + - alternate # interchangeable substitute + - reference # related but not part of BOM + + # Whether to track reference designators + use_reference_designators: true + designator_format: "^[A-Z]+[0-9]+$" # e.g., R1, C3, U12 + + # Revision linking behavior + child_revision_tracking: specific # or "latest" + + # Custom properties for relationships + relationship_properties: + - name: mounting_orientation + type: enum + values: [top, bottom, left, right, front, back] + - name: notes + type: text +``` + +--- + +## 5. FreeCAD Integration + +### 5.1 Workbench Commands + +The Silo workbench provides git-like commands accessible via toolbar, menu, and Python console: + +| Command | Description | +|---------|-------------| +| `silo init` | Initialize Silo tracking for current document | +| `silo status` | Show tracked/untracked objects, modifications | +| `silo checkout ` | Load item from Silo into current document | +| `silo commit` | Save current state as new revision | +| `silo log` | Show revision history | +| `silo diff` | Compare current state to last committed revision | +| `silo register` | Generate part number for selected object(s) | +| `silo link` | Create relationship between objects | +| `silo bom` | Generate BOM from current assembly | + +### 5.2 Property Synchronization + +Silo properties map to FreeCAD custom properties: + +```python +# FreeCAD object properties (synced from Silo) +obj.addProperty("App::PropertyString", "SiloPartNumber", "Silo", "Part number") +obj.addProperty("App::PropertyString", "SiloRevision", "Silo", "Current revision") +obj.addProperty("App::PropertyString", "SiloDescription", "Silo", "Item description") +# ... additional properties as defined in schema +``` + +### 5.3 File Storage Strategy + +FreeCAD `.FCStd` files are ZIP archives. Storage options: + +1. **Whole file storage** (MVP): Store complete .FCStd in MinIO with versioning +2. **Exploded storage** (future): Unpack and store components separately for better diffing + +For MVP, whole file storage is simpler and MinIO versioning handles history. + +### 5.4 Checkout Locking (Future) + +MVP operates as single-user. Future multi-user support will need locking strategy: + +- **Pessimistic locking**: Checkout acquires exclusive lock +- **Optimistic locking**: Allow concurrent edits, handle conflicts on commit + +Recommendation for future: Pessimistic locking for CAD files (merge is impractical). + +--- + +## 6. Web Interface + +### 6.1 Features + +- **Browse**: Navigate item hierarchy (project → assembly → subassembly → part) +- **Search**: Full-text search across part numbers, descriptions, properties +- **View**: Item details, revision history, relationships, location +- **BOM Viewer**: Expandable tree view of assembly structure +- **"Open in FreeCAD"**: Launch FreeCAD with specific item via URI handler + +### 6.2 URI Handler + +Register `silo://` protocol handler: + +``` +silo://open/PROTO-AS-0001 # Open latest revision +silo://open/PROTO-AS-0001?rev=3 # Open specific revision +``` + +### 6.3 Technology + +- **Backend**: Go with standard library HTTP +- **Frontend**: htmx for interactivity, minimal JavaScript +- **Templates**: Go html/template +- **Search**: PostgreSQL full-text search (pg_trgm for fuzzy matching) + +--- + +## 7. Revision Tracking + +### 7.1 Append-Only Model + +Every property change creates a new revision record. The current state is always the latest revision, but any historical state can be reconstructed. + +``` +Item: PROTO-AS-0001 + +Revision 1 (2026-01-15): Initial creation + - description: "Main chassis assembly" + - material: null + - weight: null + +Revision 2 (2026-01-20): Updated properties + - description: "Main chassis assembly" + - material: "6061-T6 Aluminum" + - weight: 2.5 + +Revision 3 (2026-02-01): Design change + - description: "Main chassis assembly v2" + - material: "6061-T6 Aluminum" + - weight: 2.3 +``` + +### 7.2 Revision Creation + +Revisions are created explicitly by user action (not automatic): + +- `silo commit` from FreeCAD +- "Save Revision" button in web UI +- API call with explicit revision flag + +### 7.3 Revision vs. File Version + +- **Revision**: Silo metadata revision (tracked in PostgreSQL) +- **File Version**: MinIO object version (automatic on upload) + +A single Silo revision may span multiple file uploads during editing. Only committed revisions create formal revision records. + +--- + +## 8. Relationships and BOM + +### 8.1 Relationship Types + +| Type | Description | Use Case | +|------|-------------|----------| +| `component` | Part is used in assembly | Standard BOM entry | +| `alternate` | Interchangeable substitute | Alternative sourcing | +| `reference` | Related item, not in BOM | Drawings, specs, tools | + +### 8.2 Reference Designators + +For assemblies that require them (electronics, complex mechanisms): + +```yaml +# Relationship record +parent: PROTO-AS-0001 +child: PROTO-PT-0042 +type: component +quantity: 4 +reference_designators: ["R1", "R2", "R3", "R4"] +``` + +### 8.3 Revision-Specific Relationships + +Relationships can link to specific child revisions or track latest: + +```yaml +# Locked to specific revision +child: PROTO-PT-0042 +child_revision: 3 + +# Always use latest (default for R&D) +child: PROTO-PT-0042 +child_revision: null # means "latest" +``` + +Assembly metadata YAML controls default behavior per assembly. + +--- + +## 9. Physical Inventory + +### 9.1 Location Management + +Locations are hierarchical, defined by YAML schema. Each item can exist at multiple locations with quantities. + +``` +Location: lab/main-area/shelf-a/bin-3 + - PROTO-PT-0001: 15 units + - PROTO-PT-0002: 8 units + +Location: lab/storage/shelf-b/bin-1 + - PROTO-PT-0001: 50 units (spare stock) +``` + +### 9.2 Inventory Operations + +- **Add**: Increase quantity at location +- **Remove**: Decrease quantity at location +- **Move**: Transfer between locations +- **Adjust**: Set absolute quantity (for cycle counts) + +All operations logged for audit trail (future consideration). + +--- + +## 10. Authentication (Future) + +### 10.1 Current State (MVP) + +Single-user, no authentication required. + +### 10.2 Future: LDAPS Integration + +Plan for FreeIPA integration: + +```yaml +# /etc/silo/auth.yaml +auth: + provider: ldap + server: ldaps://ipa.kindred.internal + base_dn: "dc=kindred,dc=internal" + user_dn_template: "uid={username},cn=users,cn=accounts,dc=kindred,dc=internal" + group_base: "cn=groups,cn=accounts,dc=kindred,dc=internal" + + # Role mapping + roles: + admin: + groups: ["silo-admins"] + editor: + groups: ["silo-users", "engineers"] + viewer: + groups: ["silo-viewers"] +``` + +--- + +## 11. API Design (Sketch) + +### 11.1 REST Endpoints + +``` +# Items +GET /api/items # List/search items +POST /api/items # Create item +GET /api/items/{part_number} # Get item details +PUT /api/items/{part_number} # Update item (creates revision) +DELETE /api/items/{part_number} # Archive item + +# Revisions +GET /api/items/{part_number}/revisions +GET /api/items/{part_number}/revisions/{rev} + +# Relationships +GET /api/items/{part_number}/bom +POST /api/items/{part_number}/relationships +DELETE /api/items/{part_number}/relationships/{id} + +# Files +GET /api/items/{part_number}/file +PUT /api/items/{part_number}/file +GET /api/items/{part_number}/file?rev={rev} + +# Schemas +GET /api/schemas +POST /api/schemas +GET /api/schemas/{name} + +# Locations +GET /api/locations +POST /api/locations +GET /api/locations/{path} + +# Inventory +GET /api/inventory/{part_number} +POST /api/inventory/{part_number}/adjust + +# Part number generation +POST /api/generate-part-number + Body: { "schema": "kindred-rd", "project": "PROTO", "part_type": "AS" } + Response: { "part_number": "PROTO-AS-0001" } +``` + +--- + +## 12. MVP Scope + +### 12.1 Included + +- [ ] PostgreSQL database schema +- [ ] YAML schema parser for part numbering +- [ ] Part number generation engine +- [ ] Basic CLI for item CRUD +- [ ] FreeCAD workbench with core commands (checkout, commit, status, register) +- [ ] MinIO integration for file storage +- [ ] Single-level and multi-level BOM support +- [ ] Reference designator tracking +- [ ] Alternate part tracking +- [ ] Revision history (append-only) +- [ ] Location hierarchy (YAML-defined) +- [ ] Basic inventory tracking (quantity at location) +- [ ] Web UI for browsing and search +- [ ] "Open in FreeCAD" URI handler + +### 12.2 Excluded (Future) + +- [ ] Schema migration tooling +- [ ] Multi-user with authentication +- [ ] Checkout locking +- [ ] Approval workflows +- [ ] External system integrations (ERP, purchasing) +- [ ] Exploded file storage with diffing +- [ ] Audit logging +- [ ] Notifications +- [ ] Reporting/analytics + +--- + +## 13. Open Questions + +1. **CLI language**: Go for consistency with web UI, or Python for FreeCAD ecosystem alignment? + +2. **Property schema**: Should item properties be schema-defined (like part numbers) or freeform? Recommendation: Support both—schema defines expected properties, but allow ad-hoc additions. + +3. **Thumbnail generation**: Generate thumbnails from .FCStd on commit? Useful for web UI browsing. + +4. **Search indexing**: PostgreSQL full-text search sufficient, or add dedicated search (Meilisearch, etc.)? + +5. **Offline operation**: Should FreeCAD workbench support offline mode with sync? Adds significant complexity. + +--- + +## 14. References + +### 14.1 Design Influences + +- **CycloneDX BOM specification**: JSON/YAML schema patterns for component identification, relationships, and metadata (https://cyclonedx.org) +- **OpenBOM data model**: Reference-instance separation, flexible property schemas +- **FreeCAD DynamicData workbench**: Custom property patterns in FreeCAD +- **Ansible inventory YAML**: Hierarchical configuration patterns with variable inheritance + +### 14.2 Related Standards + +- **ISO 10303 (STEP)**: Product data representation +- **IPC-2581**: Electronics assembly BOM format +- **Package URL (PURL)**: Standardized component identification + +--- + +## Appendix A: Example YAML Files + +### A.1 Complete Part Numbering Schema + +```yaml +# kindred-rd-schema.yaml +schema: + name: kindred-rd + version: 1 + description: "Kindred Systems R&D part numbering for prototype development" + + separator: "-" + + uniqueness: + scope: global + case_sensitive: false + + segments: + - name: project + type: string + length: 5 + case: upper + description: "5-character project identifier" + validation: + pattern: "^[A-Z0-9]{5}$" + message: "Project code must be exactly 5 alphanumeric characters" + required: true + + - name: part_type + type: enum + description: "Two-character type code" + required: true + values: + AS: "Assembly - multi-part unit" + PT: "Part - single manufactured item" + DW: "Drawing - technical drawing" + DC: "Document - specification, procedure, etc." + TB: "Tooling - jigs, fixtures, molds" + PC: "Purchased - externally sourced component" + EL: "Electrical - wiring, PCB, electronics" + SW: "Software - firmware, configuration" + + - name: sequence + type: serial + length: 4 + padding: "0" + start: 1 + description: "Sequential number within project/type" + scope: "{project}-{part_type}" + + format: "{project}-{part_type}-{sequence}" + + # Validation rules applied to complete part number + validation: + min_length: 14 + max_length: 14 + + # Metadata for UI/documentation + examples: + - "PROTO-AS-0001" + - "ALPHA-PT-0042" + - "BETA1-EL-0003" +``` + +### A.2 Complete Location Schema + +```yaml +# kindred-locations.yaml +location_schema: + name: kindred-lab + version: 1 + description: "Kindred Systems lab and storage locations" + + path_separator: "/" + + hierarchy: + - level: 0 + type: facility + description: "Building or site" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["lab", "warehouse", "office"] + + - level: 1 + type: area + description: "Room or zone within facility" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["main-lab", "storage", "assembly"] + + - level: 2 + type: shelf + description: "Shelving unit" + name_pattern: "^shelf-[a-z]$" + examples: ["shelf-a", "shelf-b"] + + - level: 3 + type: bin + description: "Individual container or bin" + name_pattern: "^bin-[0-9]{1,3}$" + examples: ["bin-1", "bin-42", "bin-100"] + + # Properties tracked per location type + properties: + facility: + - name: address + type: text + required: false + area: + - name: climate_controlled + type: boolean + default: false + shelf: + - name: max_weight_kg + type: number + required: false + bin: + - name: bin_size + type: enum + values: [small, medium, large] + default: medium +``` + +### A.3 Assembly Configuration + +```yaml +# Stored as item property or linked document +# Example: assembly PROTO-AS-0001 +assembly_config: + name: "Main Chassis Assembly" + + relationship_types: + - component + - alternate + - reference + + use_reference_designators: false + + child_revision_tracking: latest + + # Assembly-specific BOM properties + relationship_properties: + - name: installation_notes + type: text + - name: torque_spec + type: text + - name: adhesive_required + type: boolean + default: false + + # Validation rules + validation: + require_quantity: true + min_components: 1 +``` diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..408d076 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,137 @@ +# Silo Development Status + +**Date:** 2026-01-23 +**Last Updated By:** Claude Code Session + +--- + +## Current State: MinIO File Upload Implementation + +### Completed Work + +#### 1. Docker Compose - MinIO Service Added +- File: `deployments/docker-compose.yaml` +- Added MinIO service with versioning enabled +- Configured healthcheck and environment variables +- Note: Using `minio/minio:RELEASE.2024-01-16T16-07-38Z` for CPU compatibility + +#### 2. API Endpoints - File Upload/Download +- File: `internal/api/handlers.go` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/items/{partNumber}/file` | POST | Upload file and create revision | +| `/api/items/{partNumber}/file` | GET | Download latest revision file | +| `/api/items/{partNumber}/file/{revision}` | GET | Download specific revision file | +| `/api/items/{partNumber}/revisions` | POST | Create revision without file | + +#### 3. Routes Added +- File: `internal/api/routes.go` +- All new endpoints wired up + +#### 4. FreeCAD Client Updated +- File: `pkg/freecad/silo_commands.py` +- Added `_upload_file()` method for multipart form upload +- Updated `create_revision()` to optionally upload files +- Updated `Silo_Commit` command to save document and upload to MinIO + +#### 5. Build Status +- **Go code compiles successfully** - `go build ./...` passes + +--- + +## Where We Left Off + +### Problem +MinIO container failing to start due to CPU architecture: +``` +Fatal glibc error: CPU does not support x86-64-v2 +``` + +### Solution in Progress +- VM being rebooted to newer architecture +- Already configured older MinIO image as fallback + +--- + +## Next Steps After VM Reboot + +### 1. Start Services +```bash +cd /home/forbes/projects/silo-0062/deployments +sudo docker compose up -d +``` + +### 2. Verify Services +```bash +# Check all services +sudo docker compose ps + +# Check MinIO health +curl http://localhost:9000/minio/health/live + +# Check Silo API with storage +curl http://localhost:8080/ready +``` + +### 3. Test File Upload +```bash +# Create test file +echo "Test content" > /tmp/test.FCStd + +# Upload to existing item +curl -X POST \ + -F "file=@/tmp/test.FCStd" \ + -F "comment=Test upload" \ + -F 'properties={"test": true}' \ + http://localhost:8080/api/items/3DX15-A01-0002/file +``` + +### 4. Test File Download +```bash +# Download latest revision +curl http://localhost:8080/api/items/3DX15-A01-0002/file -o downloaded.FCStd + +# Download specific revision +curl http://localhost:8080/api/items/3DX15-A01-0002/file/2 -o rev2.FCStd +``` + +### 5. Test from FreeCAD +1. Open FreeCAD with Silo workbench +2. Open an existing item: `Silo_Open` command +3. Make changes +4. Commit with file upload: `Silo_Commit` command +5. Verify file appears in MinIO console at http://localhost:9001 + +--- + +## Remaining MVP Tasks + +| Task | Status | Priority | +|------|--------|----------| +| Start docker-compose with MinIO | Pending | **Next** | +| Test full upload/download flow | Pending | High | +| Implement date segment support | Pending | Medium | +| Implement part number validation | Pending | Medium | +| Add unit tests | Pending | Medium | + +--- + +## File Changes This Session + +``` +modified: deployments/docker-compose.yaml (added MinIO service) +modified: internal/api/handlers.go (added file handlers) +modified: internal/api/routes.go (added file routes) +modified: pkg/freecad/silo_commands.py (added file upload) +``` + +--- + +## MinIO Console Access + +Once running: +- **URL:** http://localhost:9001 +- **Username:** silominio +- **Password:** silominiosecret +- **Bucket:** silo-files diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e70569 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module github.com/kindredsystems/silo + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/cors v1.2.1 + github.com/jackc/pgx/v5 v5.5.0 + github.com/minio/minio-go/v7 v7.0.66 + github.com/rs/zerolog v1.32.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a49900e --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= +github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= +github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/csv.go b/internal/api/csv.go new file mode 100644 index 0000000..b34ae55 --- /dev/null +++ b/internal/api/csv.go @@ -0,0 +1,516 @@ +package api + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/partnum" +) + +// CSV Export/Import handlers for bulk data management + +// CSVExportOptions controls what fields to include in export. +type CSVExportOptions struct { + IncludeProperties bool + IncludeRevisions bool +} + +// CSVImportResult represents the result of an import operation. +type CSVImportResult struct { + TotalRows int `json:"total_rows"` + SuccessCount int `json:"success_count"` + ErrorCount int `json:"error_count"` + Errors []CSVImportErr `json:"errors,omitempty"` + CreatedItems []string `json:"created_items,omitempty"` +} + +// CSVImportErr represents an error on a specific row. +type CSVImportErr struct { + Row int `json:"row"` + Field string `json:"field,omitempty"` + Message string `json:"message"` +} + +// Standard CSV columns for export/import +var csvColumns = []string{ + "part_number", + "item_type", + "description", + "current_revision", + "created_at", + "updated_at", + "project", + "category", +} + +// HandleExportCSV exports items to CSV format. +func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse query options + opts := db.ListOptions{ + ItemType: r.URL.Query().Get("type"), + Search: r.URL.Query().Get("search"), + Project: r.URL.Query().Get("project"), + Limit: 10000, // Max export limit + } + + includeProps := r.URL.Query().Get("include_properties") == "true" + + // Fetch items + items, err := s.items.List(ctx, opts) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list items for export") + writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) + return + } + + // Collect all property keys if including properties + propKeys := make(map[string]bool) + itemProps := make(map[string]map[string]any) // part_number -> properties + + if includeProps { + for _, item := range items { + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err != nil { + continue + } + for _, rev := range revisions { + if rev.RevisionNumber == item.CurrentRevision && rev.Properties != nil { + itemProps[item.PartNumber] = rev.Properties + for k := range rev.Properties { + propKeys[k] = true + } + break + } + } + } + } + + // Build header row + headers := make([]string, len(csvColumns)) + copy(headers, csvColumns) + + // Add property columns (sorted for consistency) + sortedPropKeys := make([]string, 0, len(propKeys)) + for k := range propKeys { + // Skip internal/system properties + if !strings.HasPrefix(k, "_") { + sortedPropKeys = append(sortedPropKeys, k) + } + } + sort.Strings(sortedPropKeys) + headers = append(headers, sortedPropKeys...) + + // Set response headers for CSV download + filename := fmt.Sprintf("silo-export-%s.csv", time.Now().Format("2006-01-02")) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // Write CSV + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + if err := writer.Write(headers); err != nil { + s.logger.Error().Err(err).Msg("failed to write CSV header") + return + } + + // Write data rows + for _, item := range items { + row := make([]string, len(headers)) + + // Parse part number to extract project and category + project, category := parsePartNumber(item.PartNumber) + + // Standard columns + row[0] = item.PartNumber + row[1] = item.ItemType + row[2] = item.Description + row[3] = strconv.Itoa(item.CurrentRevision) + row[4] = item.CreatedAt.Format(time.RFC3339) + row[5] = item.UpdatedAt.Format(time.RFC3339) + row[6] = project + row[7] = category + + // Property columns + if includeProps { + props := itemProps[item.PartNumber] + for i, key := range sortedPropKeys { + colIdx := len(csvColumns) + i + if props != nil { + if val, ok := props[key]; ok { + row[colIdx] = formatPropertyValue(val) + } + } + } + } + + if err := writer.Write(row); err != nil { + s.logger.Error().Err(err).Str("part_number", item.PartNumber).Msg("failed to write CSV row") + continue + } + } + + s.logger.Info().Int("count", len(items)).Msg("exported items to CSV") +} + +// HandleImportCSV imports items from a CSV file. +func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse multipart form (max 10MB) + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid_form", err.Error()) + return + } + + // Get file + file, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing_file", "CSV file is required") + return + } + defer file.Close() + + // Get options + dryRun := r.FormValue("dry_run") == "true" + schemaName := r.FormValue("schema") + if schemaName == "" { + schemaName = "kindred-rd" + } + + // Parse CSV + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + + // Read header + headers, err := reader.Read() + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_csv", "Failed to read CSV header") + return + } + + // Build column index map + colIndex := make(map[string]int) + for i, h := range headers { + colIndex[strings.ToLower(strings.TrimSpace(h))] = i + } + + // Validate required columns + requiredCols := []string{"project", "category"} + for _, col := range requiredCols { + if _, ok := colIndex[col]; !ok { + writeError(w, http.StatusBadRequest, "missing_column", fmt.Sprintf("Required column '%s' not found", col)) + return + } + } + + result := CSVImportResult{ + Errors: make([]CSVImportErr, 0), + CreatedItems: make([]string, 0), + } + + // Process rows + rowNum := 1 // Start at 1 (header is row 0) + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Message: fmt.Sprintf("Failed to parse row: %v", err), + }) + result.ErrorCount++ + rowNum++ + continue + } + + result.TotalRows++ + rowNum++ + + // Extract values + project := getCSVValue(record, colIndex, "project") + category := getCSVValue(record, colIndex, "category") + description := getCSVValue(record, colIndex, "description") + partNumber := getCSVValue(record, colIndex, "part_number") + + // Validate project + if project == "" { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Field: "project", + Message: "Project code is required", + }) + result.ErrorCount++ + continue + } + + // Validate category + if category == "" { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Field: "category", + Message: "Category code is required", + }) + result.ErrorCount++ + continue + } + + // Build properties from extra columns + properties := make(map[string]any) + properties["project"] = strings.ToUpper(project) + properties["category"] = strings.ToUpper(category) + + for col, idx := range colIndex { + // Skip standard columns + if isStandardColumn(col) { + continue + } + if idx < len(record) && record[idx] != "" { + properties[col] = parsePropertyValue(record[idx]) + } + } + + // If part_number is provided, check if it exists + if partNumber != "" { + existing, _ := s.items.GetByPartNumber(ctx, partNumber) + if existing != nil { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Field: "part_number", + Message: fmt.Sprintf("Part number '%s' already exists", partNumber), + }) + result.ErrorCount++ + continue + } + } + + if dryRun { + // In dry-run mode, just validate + result.SuccessCount++ + continue + } + + // Generate part number if not provided + if partNumber == "" { + input := partnum.Input{ + SchemaName: schemaName, + Values: map[string]string{ + "project": strings.ToUpper(project), + "category": strings.ToUpper(category), + }, + } + + partNumber, err = s.partgen.Generate(ctx, input) + if err != nil { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Message: fmt.Sprintf("Failed to generate part number: %v", err), + }) + result.ErrorCount++ + continue + } + } + + // Determine item type from category + itemType := "part" + if len(category) > 0 { + switch category[0] { + case 'A': + itemType = "assembly" + case 'T': + itemType = "tooling" + } + } + + // Create item + item := &db.Item{ + PartNumber: partNumber, + ItemType: itemType, + Description: description, + } + + if err := s.items.Create(ctx, item, properties); err != nil { + result.Errors = append(result.Errors, CSVImportErr{ + Row: rowNum, + Message: fmt.Sprintf("Failed to create item: %v", err), + }) + result.ErrorCount++ + continue + } + + result.SuccessCount++ + result.CreatedItems = append(result.CreatedItems, partNumber) + } + + s.logger.Info(). + Int("total", result.TotalRows). + Int("success", result.SuccessCount). + Int("errors", result.ErrorCount). + Bool("dry_run", dryRun). + Msg("CSV import completed") + + writeJSON(w, http.StatusOK, result) +} + +// HandleCSVTemplate returns an empty CSV template with headers. +func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) { + schemaName := r.URL.Query().Get("schema") + if schemaName == "" { + schemaName = "kindred-rd" + } + + sch, ok := s.schemas[schemaName] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + + // Build headers: standard columns + default property columns from schema + headers := []string{ + "project", + "category", + "description", + } + + // Add default property columns from schema + if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil { + propNames := make([]string, 0, len(sch.PropertySchemas.Defaults)) + for name := range sch.PropertySchemas.Defaults { + propNames = append(propNames, name) + } + sort.Strings(propNames) + headers = append(headers, propNames...) + } + + // Set response headers + filename := fmt.Sprintf("silo-import-template-%s.csv", schemaName) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header row + if err := writer.Write(headers); err != nil { + s.logger.Error().Err(err).Msg("failed to write CSV template header") + return + } + + // Write example row + exampleRow := make([]string, len(headers)) + exampleRow[0] = "PROJ1" // project + exampleRow[1] = "F01" // category + exampleRow[2] = "Example Item Description" + // Leave property columns empty + + if err := writer.Write(exampleRow); err != nil { + s.logger.Error().Err(err).Msg("failed to write CSV template example") + return + } +} + +// Helper functions + +func parsePartNumber(pn string) (project, category string) { + parts := strings.Split(pn, "-") + if len(parts) >= 2 { + project = parts[0] + category = parts[1] + } + return +} + +func formatPropertyValue(v any) string { + switch val := v.(type) { + case string: + return val + case float64: + if val == float64(int64(val)) { + return strconv.FormatInt(int64(val), 10) + } + return strconv.FormatFloat(val, 'f', -1, 64) + case int: + return strconv.Itoa(val) + case int64: + return strconv.FormatInt(val, 10) + case bool: + return strconv.FormatBool(val) + case nil: + return "" + default: + // For complex types, use JSON + b, _ := json.Marshal(val) + return string(b) + } +} + +func parsePropertyValue(s string) any { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + // Try boolean + if s == "true" { + return true + } + if s == "false" { + return false + } + + // Try integer + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i + } + + // Try float + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + + // Try JSON (for arrays/objects) + if (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) || + (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) { + var v any + if err := json.Unmarshal([]byte(s), &v); err == nil { + return v + } + } + + // Default to string + return s +} + +func getCSVValue(record []string, colIndex map[string]int, column string) string { + if idx, ok := colIndex[column]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} + +func isStandardColumn(col string) bool { + standardCols := map[string]bool{ + "part_number": true, + "item_type": true, + "description": true, + "current_revision": true, + "created_at": true, + "updated_at": true, + "project": true, + "category": true, + } + return standardCols[col] +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..1ca9348 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,1076 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/partnum" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/storage" + "github.com/rs/zerolog" + "gopkg.in/yaml.v3" +) + +// Server holds dependencies for HTTP handlers. +type Server struct { + logger zerolog.Logger + db *db.DB + items *db.ItemRepository + schemas map[string]*schema.Schema + schemasDir string + partgen *partnum.Generator + storage *storage.Storage +} + +// NewServer creates a new API server. +func NewServer( + logger zerolog.Logger, + database *db.DB, + schemas map[string]*schema.Schema, + schemasDir string, + store *storage.Storage, +) *Server { + items := db.NewItemRepository(database) + seqStore := &dbSequenceStore{db: database, schemas: schemas} + partgen := partnum.NewGenerator(schemas, seqStore) + + return &Server{ + logger: logger, + db: database, + items: items, + schemas: schemas, + schemasDir: schemasDir, + partgen: partgen, + storage: store, + } +} + +// dbSequenceStore implements partnum.SequenceStore using the database. +type dbSequenceStore struct { + db *db.DB + schemas map[string]*schema.Schema +} + +func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) { + // For now, use schema name as ID. In production, you'd look up the schema UUID. + return s.db.NextSequenceValue(ctx, schemaName, scope) +} + +// Error response structure. +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +// writeJSON writes a JSON response. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// writeError writes an error JSON response. +func writeError(w http.ResponseWriter, status int, err string, message string) { + writeJSON(w, status, ErrorResponse{Error: err, Message: message}) +} + +// Health check handlers + +// HandleHealth returns basic health status. +func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// HandleReady checks database and storage connectivity. +func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Check database + if err := s.db.Pool().Ping(ctx); err != nil { + writeError(w, http.StatusServiceUnavailable, "database_unavailable", err.Error()) + return + } + + // Storage check would go here if we had a ping method + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ready", + "database": "ok", + "storage": "ok", + }) +} + +// Schema handlers + +// SchemaResponse represents a schema in API responses. +type SchemaResponse struct { + Name string `json:"name"` + Version int `json:"version"` + Description string `json:"description"` + Separator string `json:"separator"` + Format string `json:"format"` + Segments []SegmentResponse `json:"segments"` + Examples []string `json:"examples,omitempty"` +} + +// SegmentResponse represents a schema segment. +type SegmentResponse struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + Values map[string]string `json:"values,omitempty"` + Length int `json:"length,omitempty"` +} + +// HandleListSchemas lists all available schemas. +func (s *Server) HandleListSchemas(w http.ResponseWriter, r *http.Request) { + schemas := make([]SchemaResponse, 0, len(s.schemas)) + for _, sch := range s.schemas { + schemas = append(schemas, schemaToResponse(sch)) + } + writeJSON(w, http.StatusOK, schemas) +} + +// HandleGetSchema returns a specific schema. +func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + sch, ok := s.schemas[name] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + writeJSON(w, http.StatusOK, schemaToResponse(sch)) +} + +// HandleGetPropertySchema returns the property schema for a category. +func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) { + schemaName := chi.URLParam(r, "name") + category := r.URL.Query().Get("category") + + sch, ok := s.schemas[schemaName] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + + if sch.PropertySchemas == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "version": 0, + "properties": map[string]any{}, + }) + return + } + + props := sch.PropertySchemas.GetPropertiesForCategory(category) + writeJSON(w, http.StatusOK, map[string]any{ + "version": sch.PropertySchemas.Version, + "properties": props, + }) +} + +func schemaToResponse(sch *schema.Schema) SchemaResponse { + segments := make([]SegmentResponse, len(sch.Segments)) + for i, seg := range sch.Segments { + segments[i] = SegmentResponse{ + Name: seg.Name, + Type: seg.Type, + Description: seg.Description, + Required: seg.Required, + Values: seg.Values, + Length: seg.Length, + } + } + return SchemaResponse{ + Name: sch.Name, + Version: sch.Version, + Description: sch.Description, + Separator: sch.Separator, + Format: sch.Format, + Segments: segments, + Examples: sch.Examples, + } +} + +// Item handlers + +// ItemResponse represents an item in API responses. +type ItemResponse struct { + ID string `json:"id"` + PartNumber string `json:"part_number"` + ItemType string `json:"item_type"` + Description string `json:"description"` + CurrentRevision int `json:"current_revision"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Properties map[string]any `json:"properties,omitempty"` +} + +// CreateItemRequest represents a request to create an item. +type CreateItemRequest struct { + Schema string `json:"schema"` + Project string `json:"project"` + Category string `json:"category"` + Description string `json:"description"` + Properties map[string]any `json:"properties,omitempty"` +} + +// HandleListItems lists items with optional filtering. +func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + opts := db.ListOptions{ + ItemType: r.URL.Query().Get("type"), + Search: r.URL.Query().Get("search"), + Project: r.URL.Query().Get("project"), + } + + if limit := r.URL.Query().Get("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil { + opts.Limit = l + } + } + if offset := r.URL.Query().Get("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil { + opts.Offset = o + } + } + + items, err := s.items.List(ctx, opts) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list items") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list items") + return + } + + response := make([]ItemResponse, len(items)) + for i, item := range items { + response[i] = itemToResponse(item) + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleListProjects returns distinct project codes from existing items. +func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + projects, err := s.items.ListProjects(ctx) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list projects") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects") + return + } + + writeJSON(w, http.StatusOK, projects) +} + +// HandleCreateItem creates a new item with generated part number. +func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req CreateItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + // Default schema + schemaName := req.Schema + if schemaName == "" { + schemaName = "kindred-rd" + } + + // Generate part number + input := partnum.Input{ + SchemaName: schemaName, + Values: map[string]string{ + "project": req.Project, + "category": req.Category, + }, + } + + partNumber, err := s.partgen.Generate(ctx, input) + if err != nil { + s.logger.Error().Err(err).Msg("failed to generate part number") + writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) + return + } + + // Determine item type from category + itemType := "part" + if len(req.Category) > 0 { + switch req.Category[0] { + case 'A': + itemType = "assembly" + case 'D': + itemType = "document" + case 'T': + itemType = "tooling" + } + } + + // Create item + item := &db.Item{ + PartNumber: partNumber, + ItemType: itemType, + Description: req.Description, + } + + properties := req.Properties + if properties == nil { + properties = make(map[string]any) + } + properties["project"] = req.Project + properties["category"] = req.Category + + if err := s.items.Create(ctx, item, properties); err != nil { + s.logger.Error().Err(err).Msg("failed to create item") + writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, itemToResponse(item)) +} + +// HandleGetItem retrieves an item by part number. +// Supports query param: ?include=properties to include current revision properties. +func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + response := itemToResponse(item) + + // Include properties from current revision if requested + if r.URL.Query().Get("include") == "properties" { + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err == nil { + for _, rev := range revisions { + if rev.RevisionNumber == item.CurrentRevision { + response.Properties = rev.Properties + break + } + } + } + } + + writeJSON(w, http.StatusOK, response) +} + +// UpdateItemRequest represents a request to update an item. +type UpdateItemRequest struct { + PartNumber string `json:"part_number,omitempty"` + ItemType string `json:"item_type,omitempty"` + Description string `json:"description,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// HandleUpdateItem updates an item's fields and/or creates a new revision. +func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req UpdateItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + // Update item fields if provided + newPartNumber := item.PartNumber + newItemType := item.ItemType + newDescription := item.Description + + if req.PartNumber != "" { + newPartNumber = req.PartNumber + } + if req.ItemType != "" { + newItemType = req.ItemType + } + if req.Description != "" { + newDescription = req.Description + } + + // Update the item record (UUID stays the same) + if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription); err != nil { + s.logger.Error().Err(err).Msg("failed to update item") + writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) + return + } + + // Create new revision if properties provided + if req.Properties != nil { + rev := &db.Revision{ + ItemID: item.ID, + Properties: req.Properties, + Comment: &req.Comment, + } + + if err := s.items.CreateRevision(ctx, rev); err != nil { + s.logger.Error().Err(err).Msg("failed to create revision") + writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) + return + } + } + + // Get updated item (use new part number if changed) + item, _ = s.items.GetByPartNumber(ctx, newPartNumber) + writeJSON(w, http.StatusOK, itemToResponse(item)) +} + +// HandleDeleteItem permanently deletes an item. +// Use query param ?soft=true for soft delete (archive). +func (s *Server) HandleDeleteItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + soft := r.URL.Query().Get("soft") == "true" + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if soft { + if err := s.items.Archive(ctx, item.ID); err != nil { + s.logger.Error().Err(err).Msg("failed to archive item") + writeError(w, http.StatusInternalServerError, "archive_failed", err.Error()) + return + } + } else { + if err := s.items.Delete(ctx, item.ID); err != nil { + s.logger.Error().Err(err).Msg("failed to delete item") + writeError(w, http.StatusInternalServerError, "delete_failed", err.Error()) + return + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// Revision handlers + +// RevisionResponse represents a revision in API responses. +type RevisionResponse struct { + ID string `json:"id"` + RevisionNumber int `json:"revision_number"` + Properties map[string]any `json:"properties"` + FileKey *string `json:"file_key,omitempty"` + FileChecksum *string `json:"file_checksum,omitempty"` + FileSize *int64 `json:"file_size,omitempty"` + CreatedAt string `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +// HandleListRevisions lists revisions for an item. +func (s *Server) HandleListRevisions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get revisions") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") + return + } + + response := make([]RevisionResponse, len(revisions)) + for i, rev := range revisions { + response[i] = revisionToResponse(rev) + } + + writeJSON(w, http.StatusOK, response) +} + +// HandleGetRevision retrieves a specific revision. +func (s *Server) HandleGetRevision(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + revStr := chi.URLParam(r, "revision") + + revNum, err := strconv.Atoi(revStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") + return + } + + for _, rev := range revisions { + if rev.RevisionNumber == revNum { + writeJSON(w, http.StatusOK, revisionToResponse(rev)) + return + } + } + + writeError(w, http.StatusNotFound, "not_found", "Revision not found") +} + +// Part number generation + +// GeneratePartNumberRequest represents a request to generate a part number. +type GeneratePartNumberRequest struct { + Schema string `json:"schema"` + Project string `json:"project"` + Category string `json:"category"` +} + +// HandleGeneratePartNumber generates a part number without creating an item. +func (s *Server) HandleGeneratePartNumber(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req GeneratePartNumberRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + schemaName := req.Schema + if schemaName == "" { + schemaName = "kindred-rd" + } + + input := partnum.Input{ + SchemaName: schemaName, + Values: map[string]string{ + "project": req.Project, + "category": req.Category, + }, + } + + partNumber, err := s.partgen.Generate(ctx, input) + if err != nil { + writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"part_number": partNumber}) +} + +// Schema value management handlers + +// AddSchemaValueRequest represents a request to add a new enum value. +type AddSchemaValueRequest struct { + Code string `json:"code"` + Description string `json:"description"` +} + +// UpdateSchemaValueRequest represents a request to update an enum value description. +type UpdateSchemaValueRequest struct { + Description string `json:"description"` +} + +// HandleAddSchemaValue adds a new value to an enum segment. +func (s *Server) HandleAddSchemaValue(w http.ResponseWriter, r *http.Request) { + schemaName := chi.URLParam(r, "name") + segmentName := chi.URLParam(r, "segment") + + sch, ok := s.schemas[schemaName] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + + // Find the segment + var segment *schema.Segment + for i := range sch.Segments { + if sch.Segments[i].Name == segmentName { + segment = &sch.Segments[i] + break + } + } + if segment == nil { + writeError(w, http.StatusNotFound, "not_found", "Segment not found") + return + } + if segment.Type != "enum" { + writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") + return + } + + var req AddSchemaValueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.Code == "" || req.Description == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Code and description are required") + return + } + + // Check if code already exists + if _, exists := segment.Values[req.Code]; exists { + writeError(w, http.StatusConflict, "already_exists", "Value code already exists") + return + } + + // Add the new value + segment.Values[req.Code] = req.Description + + // Save to file + if err := s.saveSchema(sch); err != nil { + s.logger.Error().Err(err).Msg("failed to save schema") + writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"code": req.Code, "description": req.Description}) +} + +// HandleUpdateSchemaValue updates an enum value's description. +func (s *Server) HandleUpdateSchemaValue(w http.ResponseWriter, r *http.Request) { + schemaName := chi.URLParam(r, "name") + segmentName := chi.URLParam(r, "segment") + code := chi.URLParam(r, "code") + + sch, ok := s.schemas[schemaName] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + + // Find the segment + var segment *schema.Segment + for i := range sch.Segments { + if sch.Segments[i].Name == segmentName { + segment = &sch.Segments[i] + break + } + } + if segment == nil { + writeError(w, http.StatusNotFound, "not_found", "Segment not found") + return + } + if segment.Type != "enum" { + writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") + return + } + + // Check if code exists + if _, exists := segment.Values[code]; !exists { + writeError(w, http.StatusNotFound, "not_found", "Value code not found") + return + } + + var req UpdateSchemaValueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.Description == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Description is required") + return + } + + // Update the value + segment.Values[code] = req.Description + + // Save to file + if err := s.saveSchema(sch); err != nil { + s.logger.Error().Err(err).Msg("failed to save schema") + writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"code": code, "description": req.Description}) +} + +// HandleDeleteSchemaValue removes an enum value. +func (s *Server) HandleDeleteSchemaValue(w http.ResponseWriter, r *http.Request) { + schemaName := chi.URLParam(r, "name") + segmentName := chi.URLParam(r, "segment") + code := chi.URLParam(r, "code") + + sch, ok := s.schemas[schemaName] + if !ok { + writeError(w, http.StatusNotFound, "not_found", "Schema not found") + return + } + + // Find the segment + var segment *schema.Segment + for i := range sch.Segments { + if sch.Segments[i].Name == segmentName { + segment = &sch.Segments[i] + break + } + } + if segment == nil { + writeError(w, http.StatusNotFound, "not_found", "Segment not found") + return + } + if segment.Type != "enum" { + writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") + return + } + + // Check if code exists + if _, exists := segment.Values[code]; !exists { + writeError(w, http.StatusNotFound, "not_found", "Value code not found") + return + } + + // Delete the value + delete(segment.Values, code) + + // Save to file + if err := s.saveSchema(sch); err != nil { + s.logger.Error().Err(err).Msg("failed to save schema") + writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// saveSchema writes the schema back to its YAML file. +func (s *Server) saveSchema(sch *schema.Schema) error { + // Build the schema file structure + schemaFile := schema.SchemaFile{ + Schema: *sch, + } + + data, err := yaml.Marshal(schemaFile) + if err != nil { + return err + } + + filename := filepath.Join(s.schemasDir, sch.Name+".yaml") + return os.WriteFile(filename, data, 0644) +} + +// Helper functions + +func itemToResponse(item *db.Item) ItemResponse { + return ItemResponse{ + ID: item.ID, + PartNumber: item.PartNumber, + ItemType: item.ItemType, + Description: item.Description, + CurrentRevision: item.CurrentRevision, + CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +func revisionToResponse(rev *db.Revision) RevisionResponse { + return RevisionResponse{ + ID: rev.ID, + RevisionNumber: rev.RevisionNumber, + Properties: rev.Properties, + FileKey: rev.FileKey, + FileChecksum: rev.FileChecksum, + FileSize: rev.FileSize, + CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + CreatedBy: rev.CreatedBy, + Comment: rev.Comment, + } +} + +// File upload/download handlers + +// CreateRevisionRequest represents a request to create a new revision. +type CreateRevisionRequest struct { + Properties map[string]any `json:"properties"` + Comment string `json:"comment"` +} + +// HandleCreateRevision creates a new revision for an item (without file). +func (s *Server) HandleCreateRevision(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req CreateRevisionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + rev := &db.Revision{ + ItemID: item.ID, + Properties: req.Properties, + Comment: &req.Comment, + } + + if err := s.items.CreateRevision(ctx, rev); err != nil { + s.logger.Error().Err(err).Msg("failed to create revision") + writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, revisionToResponse(rev)) +} + +// HandleUploadFile uploads a file and creates a new revision. +func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + // Check storage is configured + if s.storage == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + // Parse multipart form (max 100MB) + if err := r.ParseMultipartForm(100 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid_form", err.Error()) + return + } + + // Get the file + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing_file", "File is required") + return + } + defer file.Close() + + // Get optional fields + comment := r.FormValue("comment") + propertiesJSON := r.FormValue("properties") + + var properties map[string]any + if propertiesJSON != "" { + if err := json.Unmarshal([]byte(propertiesJSON), &properties); err != nil { + writeError(w, http.StatusBadRequest, "invalid_properties", "Properties must be valid JSON") + return + } + } else { + properties = make(map[string]any) + } + + // Determine the next revision number + nextRevision := item.CurrentRevision + 1 + + // Generate storage key + fileKey := storage.FileKey(partNumber, nextRevision) + + // Determine content type + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + // Upload to storage + result, err := s.storage.Put(ctx, fileKey, file, header.Size, contentType) + if err != nil { + s.logger.Error().Err(err).Msg("failed to upload file") + writeError(w, http.StatusInternalServerError, "upload_failed", err.Error()) + return + } + + // Create revision with file metadata + rev := &db.Revision{ + ItemID: item.ID, + Properties: properties, + FileKey: &result.Key, + FileVersion: &result.VersionID, + FileChecksum: &result.Checksum, + FileSize: &result.Size, + Comment: &comment, + } + + if err := s.items.CreateRevision(ctx, rev); err != nil { + s.logger.Error().Err(err).Msg("failed to create revision") + writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) + return + } + + s.logger.Info(). + Str("part_number", partNumber). + Int("revision", rev.RevisionNumber). + Str("file_key", fileKey). + Int64("size", result.Size). + Msg("file uploaded") + + writeJSON(w, http.StatusCreated, revisionToResponse(rev)) +} + +// HandleDownloadFile downloads the file for a specific revision. +func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + revStr := chi.URLParam(r, "revision") + + // Check storage is configured + if s.storage == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + // Parse revision number (or use "latest") + var revNum int + if revStr == "latest" { + revNum = item.CurrentRevision + } else { + revNum, err = strconv.Atoi(revStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number or 'latest'") + return + } + } + + // Get revision to find file key + revisions, err := s.items.GetRevisions(ctx, item.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") + return + } + + var revision *db.Revision + for _, rev := range revisions { + if rev.RevisionNumber == revNum { + revision = rev + break + } + } + + if revision == nil { + writeError(w, http.StatusNotFound, "not_found", "Revision not found") + return + } + + if revision.FileKey == nil { + writeError(w, http.StatusNotFound, "no_file", "Revision has no associated file") + return + } + + // Get file from storage + var reader interface { + Read(p []byte) (n int, err error) + Close() error + } + + if revision.FileVersion != nil && *revision.FileVersion != "" { + reader, err = s.storage.GetVersion(ctx, *revision.FileKey, *revision.FileVersion) + } else { + reader, err = s.storage.Get(ctx, *revision.FileKey) + } + + if err != nil { + s.logger.Error().Err(err).Str("key", *revision.FileKey).Msg("failed to get file") + writeError(w, http.StatusInternalServerError, "download_failed", err.Error()) + return + } + defer reader.Close() + + // Set response headers + filename := partNumber + "_rev" + strconv.Itoa(revNum) + ".FCStd" + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + if revision.FileSize != nil { + w.Header().Set("Content-Length", strconv.FormatInt(*revision.FileSize, 10)) + } + + // Stream file to response + buf := make([]byte, 32*1024) + for { + n, readErr := reader.Read(buf) + if n > 0 { + if _, writeErr := w.Write(buf[:n]); writeErr != nil { + s.logger.Error().Err(writeErr).Msg("failed to write response") + return + } + } + if readErr != nil { + break + } + } +} + +// HandleDownloadLatestFile downloads the file for the latest revision. +func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request) { + chi.URLParam(r, "partNumber") // ensure URL param is consumed + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.RouteContext(r.Context()))) + + // Add "latest" as the revision param and delegate + rctx := chi.RouteContext(r.Context()) + rctx.URLParams.Add("revision", "latest") + s.HandleDownloadFile(w, r) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..28e0dc1 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,56 @@ +// Package api provides HTTP handlers and middleware for the Silo API. +package api + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" +) + +// RequestLogger returns a middleware that logs HTTP requests using zerolog. +func RequestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + defer func() { + logger.Info(). + Str("method", r.Method). + Str("path", r.URL.Path). + Str("query", r.URL.RawQuery). + Int("status", ww.Status()). + Int("bytes", ww.BytesWritten()). + Dur("duration", time.Since(start)). + Str("remote", r.RemoteAddr). + Str("user_agent", r.UserAgent()). + Msg("request") + }() + + next.ServeHTTP(ww, r) + }) + } +} + +// Recoverer returns a middleware that recovers from panics and logs them. +func Recoverer(logger zerolog.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + logger.Error(). + Interface("panic", rec). + Str("method", r.Method). + Str("path", r.URL.Path). + Msg("panic recovered") + + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..04996cb --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,95 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/rs/zerolog" +) + +// NewRouter creates a new chi router with all routes registered. +func NewRouter(server *Server, logger zerolog.Logger) http.Handler { + r := chi.NewRouter() + + // Middleware stack + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(RequestLogger(logger)) + r.Use(Recoverer(logger)) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"}, + ExposedHeaders: []string{"Link", "X-Request-ID"}, + AllowCredentials: false, + MaxAge: 300, + })) + + // Web handler for HTML pages + webHandler, err := NewWebHandler(logger) + if err != nil { + logger.Fatal().Err(err).Msg("failed to create web handler") + } + + // Health endpoints + r.Get("/health", server.HandleHealth) + r.Get("/ready", server.HandleReady) + + // Web UI routes + r.Get("/", webHandler.HandleIndex) + r.Get("/schemas", webHandler.HandleSchemasPage) + + // API routes + r.Route("/api", func(r chi.Router) { + // Schemas + r.Route("/schemas", func(r chi.Router) { + r.Get("/", server.HandleListSchemas) + r.Get("/{name}", server.HandleGetSchema) + r.Get("/{name}/properties", server.HandleGetPropertySchema) + + // Schema segment value management + r.Route("/{name}/segments/{segment}/values", func(r chi.Router) { + r.Post("/", server.HandleAddSchemaValue) + r.Put("/{code}", server.HandleUpdateSchemaValue) + r.Delete("/{code}", server.HandleDeleteSchemaValue) + }) + }) + + // Projects (distinct project codes from items) + r.Get("/projects", server.HandleListProjects) + + // Items + r.Route("/items", func(r chi.Router) { + r.Get("/", server.HandleListItems) + r.Post("/", server.HandleCreateItem) + + // CSV Import/Export + r.Get("/export.csv", server.HandleExportCSV) + r.Post("/import", server.HandleImportCSV) + r.Get("/template.csv", server.HandleCSVTemplate) + + r.Route("/{partNumber}", func(r chi.Router) { + r.Get("/", server.HandleGetItem) + r.Put("/", server.HandleUpdateItem) + r.Delete("/", server.HandleDeleteItem) + + // Revisions + r.Get("/revisions", server.HandleListRevisions) + r.Post("/revisions", server.HandleCreateRevision) + r.Get("/revisions/{revision}", server.HandleGetRevision) + + // File upload/download + r.Post("/file", server.HandleUploadFile) + r.Get("/file", server.HandleDownloadLatestFile) + r.Get("/file/{revision}", server.HandleDownloadFile) + }) + }) + + // Part number generation + r.Post("/generate-part-number", server.HandleGeneratePartNumber) + }) + + return r +} diff --git a/internal/api/templates/base.html b/internal/api/templates/base.html new file mode 100644 index 0000000..d01eee0 --- /dev/null +++ b/internal/api/templates/base.html @@ -0,0 +1,501 @@ + + + + + + {{if .Title}}{{.Title}} - {{end}}Silo + + + +
+
+

Silo

+
+ +
+ +
+ {{if eq .Page "items"}} + {{template "items_content" .}} + {{else if eq .Page "schemas"}} + {{template "schemas_content" .}} + {{end}} +
+ + {{if eq .Page "items"}} + {{template "items_scripts" .}} + {{else if eq .Page "schemas"}} + {{template "schemas_scripts" .}} + {{end}} + + diff --git a/internal/api/templates/items.html b/internal/api/templates/items.html new file mode 100644 index 0000000..9dc1bf4 --- /dev/null +++ b/internal/api/templates/items.html @@ -0,0 +1,1993 @@ +{{define "items_content"}} +
+
+
-
+
Total Items
+
+
+
-
+
Parts
+
+
+
-
+
Assemblies
+
+
+
-
+
Documents
+
+
+ +
+
+

Items

+
+ +
+ + +
+ +
+ + +
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + +
Part NumberTypeDescriptionRevisionCreatedActions
+
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + +{{end}} {{define "items_scripts"}} + + +{{end}} diff --git a/internal/api/templates/schemas.html b/internal/api/templates/schemas.html new file mode 100644 index 0000000..96b4918 --- /dev/null +++ b/internal/api/templates/schemas.html @@ -0,0 +1,399 @@ +{{define "schemas_content"}} +
+
+

Part Numbering Schemas

+
+ +
+
+
+
+
+
+ + + + + + + + + +{{end}} {{define "schemas_scripts"}} + + +{{end}} diff --git a/internal/api/web.go b/internal/api/web.go new file mode 100644 index 0000000..15e7dfc --- /dev/null +++ b/internal/api/web.go @@ -0,0 +1,92 @@ +package api + +import ( + "bytes" + "embed" + "html/template" + "net/http" + + "github.com/rs/zerolog" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +// WebHandler serves HTML pages. +type WebHandler struct { + templates *template.Template + logger zerolog.Logger +} + +// NewWebHandler creates a new web handler. +func NewWebHandler(logger zerolog.Logger) (*WebHandler, error) { + // Parse templates from embedded filesystem + tmpl, err := template.ParseFS(templatesFS, "templates/*.html") + if err != nil { + return nil, err + } + + return &WebHandler{ + templates: tmpl, + logger: logger, + }, nil +} + +// PageData holds data for page rendering. +type PageData struct { + Title string + Page string + Data any +} + +// render executes a page template within the base layout. +func (h *WebHandler) render(w http.ResponseWriter, page string, data PageData) { + // First, render the page-specific content + var contentBuf bytes.Buffer + if err := h.templates.ExecuteTemplate(&contentBuf, page+".html", data); err != nil { + h.logger.Error().Err(err).Str("page", page).Msg("failed to render page template") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Now render the base template with the content + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { + h.logger.Error().Err(err).Msg("failed to render base template") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// HandleIndex serves the main items page. +func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) { + // Check if this is the root path + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + data := PageData{ + Title: "Items", + Page: "items", + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { + h.logger.Error().Err(err).Msg("failed to render template") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// HandleSchemasPage serves the schemas page. +func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) { + data := PageData{ + Title: "Schemas", + Page: "schemas", + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { + h.logger.Error().Err(err).Msg("failed to render template") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..93d2f0f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,122 @@ +// Package config handles configuration loading. +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config holds all application configuration. +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Storage StorageConfig `yaml:"storage"` + Schemas SchemasConfig `yaml:"schemas"` + FreeCAD FreeCADConfig `yaml:"freecad"` +} + +// ServerConfig holds HTTP server settings. +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + BaseURL string `yaml:"base_url"` +} + +// DatabaseConfig holds PostgreSQL connection settings. +type DatabaseConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Name string `yaml:"name"` + User string `yaml:"user"` + Password string `yaml:"password"` + SSLMode string `yaml:"sslmode"` + MaxConnections int `yaml:"max_connections"` +} + +// StorageConfig holds MinIO connection settings. +type StorageConfig struct { + Endpoint string `yaml:"endpoint"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + Bucket string `yaml:"bucket"` + UseSSL bool `yaml:"use_ssl"` + Region string `yaml:"region"` +} + +// SchemasConfig holds schema loading settings. +type SchemasConfig struct { + Directory string `yaml:"directory"` + Default string `yaml:"default"` +} + +// FreeCADConfig holds FreeCAD integration settings. +type FreeCADConfig struct { + URIScheme string `yaml:"uri_scheme"` + Executable string `yaml:"executable"` +} + +// Load reads configuration from a YAML file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + // Expand environment variables + data = []byte(os.ExpandEnv(string(data))) + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config YAML: %w", err) + } + + // Apply defaults + if cfg.Server.Port == 0 { + cfg.Server.Port = 8080 + } + if cfg.Database.Port == 0 { + cfg.Database.Port = 5432 + } + if cfg.Database.SSLMode == "" { + cfg.Database.SSLMode = "require" + } + if cfg.Database.MaxConnections == 0 { + cfg.Database.MaxConnections = 10 + } + if cfg.Storage.Region == "" { + cfg.Storage.Region = "us-east-1" + } + if cfg.Schemas.Directory == "" { + cfg.Schemas.Directory = "/etc/silo/schemas" + } + if cfg.FreeCAD.URIScheme == "" { + cfg.FreeCAD.URIScheme = "silo" + } + + // Override with environment variables + if v := os.Getenv("SILO_DB_HOST"); v != "" { + cfg.Database.Host = v + } + if v := os.Getenv("SILO_DB_NAME"); v != "" { + cfg.Database.Name = v + } + if v := os.Getenv("SILO_DB_USER"); v != "" { + cfg.Database.User = v + } + if v := os.Getenv("SILO_DB_PASSWORD"); v != "" { + cfg.Database.Password = v + } + if v := os.Getenv("SILO_MINIO_ENDPOINT"); v != "" { + cfg.Storage.Endpoint = v + } + if v := os.Getenv("SILO_MINIO_ACCESS_KEY"); v != "" { + cfg.Storage.AccessKey = v + } + if v := os.Getenv("SILO_MINIO_SECRET_KEY"); v != "" { + cfg.Storage.SecretKey = v + } + + return &cfg, nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..42bd05f --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,90 @@ +// Package db provides PostgreSQL database access. +package db + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Config holds database connection settings. +type Config struct { + Host string + Port int + Name string + User string + Password string + SSLMode string + MaxConnections int +} + +// DB wraps the connection pool. +type DB struct { + pool *pgxpool.Pool +} + +// Connect establishes a database connection pool. +func Connect(ctx context.Context, cfg Config) (*DB, error) { + dsn := fmt.Sprintf( + "host=%s port=%d dbname=%s user=%s password=%s sslmode=%s pool_max_conns=%d", + cfg.Host, cfg.Port, cfg.Name, cfg.User, cfg.Password, cfg.SSLMode, cfg.MaxConnections, + ) + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("creating connection pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("pinging database: %w", err) + } + + return &DB{pool: pool}, nil +} + +// Close closes the connection pool. +func (db *DB) Close() { + db.pool.Close() +} + +// Pool returns the underlying connection pool for direct access. +func (db *DB) Pool() *pgxpool.Pool { + return db.pool +} + +// Tx executes a function within a transaction. +func (db *DB) Tx(ctx context.Context, fn func(tx pgx.Tx) error) error { + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction: %w", err) + } + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + + return nil +} + +// NextSequenceValue atomically increments and returns the next sequence value. +// Uses schema name (not UUID) for simpler operation. +func (db *DB) NextSequenceValue(ctx context.Context, schemaName string, scope string) (int, error) { + var val int + err := db.pool.QueryRow(ctx, + "SELECT next_sequence_by_name($1, $2)", + schemaName, scope, + ).Scan(&val) + if err != nil { + return 0, fmt.Errorf("getting next sequence: %w", err) + } + return val, nil +} diff --git a/internal/db/items.go b/internal/db/items.go new file mode 100644 index 0000000..c4cf70c --- /dev/null +++ b/internal/db/items.go @@ -0,0 +1,329 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Item represents an item in the database. +type Item struct { + ID string + PartNumber string + SchemaID *string + ItemType string + Description string + CreatedAt time.Time + UpdatedAt time.Time + ArchivedAt *time.Time + CurrentRevision int + CADSyncedAt *time.Time + CADFilePath *string +} + +// Revision represents a revision record. +type Revision struct { + ID string + ItemID string + RevisionNumber int + Properties map[string]any + FileKey *string + FileVersion *string + FileChecksum *string + FileSize *int64 + ThumbnailKey *string + CreatedAt time.Time + CreatedBy *string + Comment *string +} + +// ItemRepository provides item database operations. +type ItemRepository struct { + db *DB +} + +// NewItemRepository creates a new item repository. +func NewItemRepository(db *DB) *ItemRepository { + return &ItemRepository{db: db} +} + +// Create inserts a new item and its initial revision. +func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[string]any) error { + return r.db.Tx(ctx, func(tx pgx.Tx) error { + // Insert item + err := tx.QueryRow(ctx, ` + INSERT INTO items (part_number, schema_id, item_type, description) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at, current_revision + `, item.PartNumber, item.SchemaID, item.ItemType, item.Description).Scan( + &item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision, + ) + if err != nil { + return fmt.Errorf("inserting item: %w", err) + } + + // Insert initial revision + propsJSON, err := json.Marshal(properties) + if err != nil { + return fmt.Errorf("marshaling properties: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO revisions (item_id, revision_number, properties) + VALUES ($1, 1, $2) + `, item.ID, propsJSON) + if err != nil { + return fmt.Errorf("inserting revision: %w", err) + } + + return nil + }) +} + +// GetByPartNumber retrieves an item by part number. +func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) (*Item, error) { + item := &Item{} + err := r.db.pool.QueryRow(ctx, ` + SELECT id, part_number, schema_id, item_type, description, + created_at, updated_at, archived_at, current_revision, + cad_synced_at, cad_file_path + FROM items + WHERE part_number = $1 AND archived_at IS NULL + `, partNumber).Scan( + &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, + &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, + &item.CADSyncedAt, &item.CADFilePath, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("querying item: %w", err) + } + return item, nil +} + +// GetByID retrieves an item by ID. +func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) { + item := &Item{} + err := r.db.pool.QueryRow(ctx, ` + SELECT id, part_number, schema_id, item_type, description, + created_at, updated_at, archived_at, current_revision, + cad_synced_at, cad_file_path + FROM items + WHERE id = $1 + `, id).Scan( + &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, + &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, + &item.CADSyncedAt, &item.CADFilePath, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("querying item: %w", err) + } + return item, nil +} + +// List retrieves items with optional filtering. +func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) { + query := ` + SELECT id, part_number, schema_id, item_type, description, + created_at, updated_at, archived_at, current_revision + FROM items + WHERE archived_at IS NULL + ` + args := []any{} + argNum := 1 + + if opts.ItemType != "" { + query += fmt.Sprintf(" AND item_type = $%d", argNum) + args = append(args, opts.ItemType) + argNum++ + } + + if opts.Project != "" { + // Filter by project code (first 5 characters of part number) + query += fmt.Sprintf(" AND part_number LIKE $%d", argNum) + args = append(args, opts.Project+"%") + argNum++ + } + + if opts.Search != "" { + query += fmt.Sprintf(" AND (part_number ILIKE $%d OR description ILIKE $%d)", argNum, argNum) + args = append(args, "%"+opts.Search+"%") + argNum++ + } + + query += " ORDER BY part_number" + + if opts.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argNum) + args = append(args, opts.Limit) + argNum++ + } + + if opts.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argNum) + args = append(args, opts.Offset) + } + + rows, err := r.db.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("querying items: %w", err) + } + defer rows.Close() + + var items []*Item + for rows.Next() { + item := &Item{} + err := rows.Scan( + &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, + &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, + ) + if err != nil { + return nil, fmt.Errorf("scanning item: %w", err) + } + items = append(items, item) + } + + return items, nil +} + +// ListProjects returns distinct project codes from all items. +func (r *ItemRepository) ListProjects(ctx context.Context) ([]string, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT DISTINCT SUBSTRING(part_number FROM 1 FOR 5) as project_code + FROM items + WHERE archived_at IS NULL + ORDER BY project_code + `) + if err != nil { + return nil, fmt.Errorf("querying projects: %w", err) + } + defer rows.Close() + + var projects []string + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + return nil, fmt.Errorf("scanning project: %w", err) + } + projects = append(projects, code) + } + + return projects, nil +} + +// ListOptions configures item listing. +type ListOptions struct { + ItemType string + Search string + Project string + Limit int + Offset int +} + +// CreateRevision adds a new revision for an item. +func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) error { + propsJSON, err := json.Marshal(rev.Properties) + if err != nil { + return fmt.Errorf("marshaling properties: %w", err) + } + + err = r.db.pool.QueryRow(ctx, ` + INSERT INTO revisions ( + item_id, revision_number, properties, file_key, file_version, + file_checksum, file_size, thumbnail_key, created_by, comment + ) + SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9 + FROM items WHERE id = $1 + RETURNING id, revision_number, created_at + `, rev.ItemID, propsJSON, rev.FileKey, rev.FileVersion, + rev.FileChecksum, rev.FileSize, rev.ThumbnailKey, rev.CreatedBy, rev.Comment, + ).Scan(&rev.ID, &rev.RevisionNumber, &rev.CreatedAt) + if err != nil { + return fmt.Errorf("inserting revision: %w", err) + } + + return nil +} + +// GetRevisions retrieves all revisions for an item. +func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT id, item_id, revision_number, properties, file_key, file_version, + file_checksum, file_size, thumbnail_key, created_at, created_by, comment + FROM revisions + WHERE item_id = $1 + ORDER BY revision_number DESC + `, itemID) + if err != nil { + return nil, fmt.Errorf("querying revisions: %w", err) + } + defer rows.Close() + + var revisions []*Revision + for rows.Next() { + rev := &Revision{} + var propsJSON []byte + err := rows.Scan( + &rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion, + &rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, + ) + if err != nil { + return nil, fmt.Errorf("scanning revision: %w", err) + } + if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil { + return nil, fmt.Errorf("unmarshaling properties: %w", err) + } + revisions = append(revisions, rev) + } + + return revisions, nil +} + +// Archive soft-deletes an item. +func (r *ItemRepository) Archive(ctx context.Context, id string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE items SET archived_at = now() WHERE id = $1 + `, id) + return err +} + +// Update modifies an item's part number, type, and description. +// The UUID remains stable. +func (r *ItemRepository) Update(ctx context.Context, id string, partNumber string, itemType string, description string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE items + SET part_number = $2, item_type = $3, description = $4, updated_at = now() + WHERE id = $1 AND archived_at IS NULL + `, id, partNumber, itemType, description) + if err != nil { + return fmt.Errorf("updating item: %w", err) + } + return nil +} + +// Delete permanently removes an item and all its revisions. +func (r *ItemRepository) Delete(ctx context.Context, id string) error { + _, err := r.db.pool.Exec(ctx, ` + DELETE FROM items WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("deleting item: %w", err) + } + return nil +} + +// Unarchive restores a soft-deleted item. +func (r *ItemRepository) Unarchive(ctx context.Context, id string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE items SET archived_at = NULL, updated_at = now() WHERE id = $1 + `, id) + return err +} diff --git a/internal/migration/properties.go b/internal/migration/properties.go new file mode 100644 index 0000000..4addf8c --- /dev/null +++ b/internal/migration/properties.go @@ -0,0 +1,211 @@ +// Package migration provides property schema migration functionality. +package migration + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/schema" + "github.com/rs/zerolog" +) + +// PropertyMigrator handles property schema migrations. +type PropertyMigrator struct { + db *db.DB + schema *schema.Schema + logger zerolog.Logger +} + +// NewPropertyMigrator creates a new property migrator. +func NewPropertyMigrator(database *db.DB, sch *schema.Schema, logger zerolog.Logger) *PropertyMigrator { + return &PropertyMigrator{ + db: database, + schema: sch, + logger: logger, + } +} + +// MigrationResult contains the results of a migration run. +type MigrationResult struct { + MigrationID string + FromVersion int + ToVersion int + ItemsAffected int + StartedAt time.Time + CompletedAt time.Time + Status string + Error error +} + +// MigrateToVersion migrates all items to the specified property schema version. +// This creates new revisions for items that have outdated property schemas. +func (m *PropertyMigrator) MigrateToVersion(ctx context.Context, targetVersion int) (*MigrationResult, error) { + if m.schema.PropertySchemas == nil { + return nil, fmt.Errorf("no property schema defined for %s", m.schema.Name) + } + + result := &MigrationResult{ + FromVersion: 0, // Will be updated based on what we find + ToVersion: targetVersion, + StartedAt: time.Now(), + Status: "running", + } + + // Record migration start + var migrationID string + err := m.db.Pool().QueryRow(ctx, ` + INSERT INTO property_migrations (schema_name, from_version, to_version, status) + VALUES ($1, $2, $3, 'running') + RETURNING id + `, m.schema.Name, 0, targetVersion).Scan(&migrationID) + if err != nil { + return nil, fmt.Errorf("recording migration start: %w", err) + } + result.MigrationID = migrationID + + // Find items needing migration + rows, err := m.db.Pool().Query(ctx, ` + SELECT DISTINCT i.id, i.part_number, r.properties, r.property_schema_version + FROM items i + JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision + WHERE i.archived_at IS NULL + AND (r.property_schema_version < $1 OR r.property_schema_version IS NULL) + `, targetVersion) + if err != nil { + m.recordMigrationError(ctx, migrationID, err) + return nil, fmt.Errorf("querying items: %w", err) + } + defer rows.Close() + + type itemToMigrate struct { + ID string + PartNumber string + Properties map[string]any + Version *int + } + + var items []itemToMigrate + for rows.Next() { + var item itemToMigrate + var propsJSON []byte + if err := rows.Scan(&item.ID, &item.PartNumber, &propsJSON, &item.Version); err != nil { + m.recordMigrationError(ctx, migrationID, err) + return nil, fmt.Errorf("scanning row: %w", err) + } + if len(propsJSON) > 0 { + if err := json.Unmarshal(propsJSON, &item.Properties); err != nil { + m.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("failed to parse properties") + item.Properties = make(map[string]any) + } + } else { + item.Properties = make(map[string]any) + } + items = append(items, item) + } + + if err := rows.Err(); err != nil { + m.recordMigrationError(ctx, migrationID, err) + return nil, fmt.Errorf("iterating rows: %w", err) + } + + m.logger.Info().Int("count", len(items)).Int("target_version", targetVersion).Msg("migrating items") + + // Migrate each item + for _, item := range items { + if err := m.migrateItem(ctx, item.ID, item.PartNumber, item.Properties, targetVersion); err != nil { + m.logger.Error().Err(err).Str("part_number", item.PartNumber).Msg("failed to migrate item") + // Continue with other items + } else { + result.ItemsAffected++ + } + } + + // Record success + result.CompletedAt = time.Now() + result.Status = "completed" + + _, err = m.db.Pool().Exec(ctx, ` + UPDATE property_migrations + SET completed_at = $1, status = 'completed', items_affected = $2 + WHERE id = $3 + `, result.CompletedAt, result.ItemsAffected, migrationID) + if err != nil { + m.logger.Warn().Err(err).Msg("failed to update migration record") + } + + return result, nil +} + +// migrateItem applies property defaults and creates a new revision. +func (m *PropertyMigrator) migrateItem(ctx context.Context, itemID, partNumber string, properties map[string]any, targetVersion int) error { + // Get category from properties + category, _ := properties["category"].(string) + + // Apply defaults for missing properties + newProps := m.schema.PropertySchemas.ApplyDefaults(properties, category) + + propsJSON, err := json.Marshal(newProps) + if err != nil { + return fmt.Errorf("marshaling properties: %w", err) + } + + // Create new revision with updated properties + comment := fmt.Sprintf("Property schema migration to version %d", targetVersion) + _, err = m.db.Pool().Exec(ctx, ` + INSERT INTO revisions (item_id, revision_number, properties, property_schema_version, comment) + SELECT $1, current_revision + 1, $2, $3, $4 + FROM items WHERE id = $1 + `, itemID, propsJSON, targetVersion, comment) + if err != nil { + return fmt.Errorf("creating revision: %w", err) + } + + m.logger.Debug().Str("part_number", partNumber).Int("version", targetVersion).Msg("migrated item") + return nil +} + +func (m *PropertyMigrator) recordMigrationError(ctx context.Context, migrationID string, err error) { + _, dbErr := m.db.Pool().Exec(ctx, ` + UPDATE property_migrations + SET completed_at = now(), status = 'failed', error_message = $1 + WHERE id = $2 + `, err.Error(), migrationID) + if dbErr != nil { + m.logger.Warn().Err(dbErr).Msg("failed to record migration error") + } +} + +// GetMigrationHistory returns recent property migrations. +func (m *PropertyMigrator) GetMigrationHistory(ctx context.Context, limit int) ([]MigrationResult, error) { + rows, err := m.db.Pool().Query(ctx, ` + SELECT id, from_version, to_version, items_affected, started_at, + COALESCE(completed_at, now()), status, error_message + FROM property_migrations + WHERE schema_name = $1 + ORDER BY started_at DESC + LIMIT $2 + `, m.schema.Name, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []MigrationResult + for rows.Next() { + var r MigrationResult + var errMsg *string + if err := rows.Scan(&r.MigrationID, &r.FromVersion, &r.ToVersion, + &r.ItemsAffected, &r.StartedAt, &r.CompletedAt, &r.Status, &errMsg); err != nil { + return nil, err + } + if errMsg != nil { + r.Error = fmt.Errorf("%s", *errMsg) + } + results = append(results, r) + } + + return results, rows.Err() +} diff --git a/internal/partnum/generator.go b/internal/partnum/generator.go new file mode 100644 index 0000000..ebcc843 --- /dev/null +++ b/internal/partnum/generator.go @@ -0,0 +1,180 @@ +// Package partnum handles part number generation from schemas. +package partnum + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/kindredsystems/silo/internal/schema" +) + +// SequenceStore provides atomic sequence number generation. +type SequenceStore interface { + NextValue(ctx context.Context, schemaID string, scope string) (int, error) +} + +// Generator creates part numbers from schemas. +type Generator struct { + schemas map[string]*schema.Schema + seqStore SequenceStore +} + +// NewGenerator creates a new part number generator. +func NewGenerator(schemas map[string]*schema.Schema, seqStore SequenceStore) *Generator { + return &Generator{ + schemas: schemas, + seqStore: seqStore, + } +} + +// Input provides values for part number generation. +type Input struct { + SchemaName string + Values map[string]string // segment name -> value +} + +// Generate creates a new part number. +func (g *Generator) Generate(ctx context.Context, input Input) (string, error) { + s, ok := g.schemas[input.SchemaName] + if !ok { + return "", fmt.Errorf("unknown schema: %s", input.SchemaName) + } + + segments := make([]string, len(s.Segments)) + resolvedValues := make(map[string]string) + + for i, seg := range s.Segments { + val, err := g.resolveSegment(ctx, s, &seg, input.Values, resolvedValues) + if err != nil { + return "", fmt.Errorf("segment %s: %w", seg.Name, err) + } + segments[i] = val + resolvedValues[seg.Name] = val + } + + // Use format template if provided, otherwise join with separator + if s.Format != "" { + return g.applyFormat(s.Format, resolvedValues), nil + } + + return strings.Join(segments, s.Separator), nil +} + +func (g *Generator) resolveSegment( + ctx context.Context, + s *schema.Schema, + seg *schema.Segment, + input map[string]string, + resolved map[string]string, +) (string, error) { + switch seg.Type { + case "constant": + return seg.Value, nil + + case "string": + val, ok := input[seg.Name] + if !ok && seg.Required { + return "", fmt.Errorf("required value not provided") + } + return g.formatString(seg, val) + + case "enum": + val, ok := input[seg.Name] + if !ok && seg.Required { + return "", fmt.Errorf("required value not provided") + } + if _, valid := seg.Values[val]; !valid { + return "", fmt.Errorf("invalid enum value: %s", val) + } + return val, nil + + case "serial": + scope := g.resolveScope(seg.Scope, resolved) + next, err := g.seqStore.NextValue(ctx, s.Name, scope) + if err != nil { + return "", fmt.Errorf("getting sequence: %w", err) + } + return g.formatSerial(seg, next), nil + + case "date": + // TODO: implement date formatting + return "", fmt.Errorf("date segments not yet implemented") + + default: + return "", fmt.Errorf("unknown segment type: %s", seg.Type) + } +} + +func (g *Generator) formatString(seg *schema.Segment, val string) (string, error) { + // Apply case transformation + switch seg.Case { + case "upper": + val = strings.ToUpper(val) + case "lower": + val = strings.ToLower(val) + } + + // Validate length + if seg.Length > 0 && len(val) != seg.Length { + return "", fmt.Errorf("value must be exactly %d characters", seg.Length) + } + if seg.MinLength > 0 && len(val) < seg.MinLength { + return "", fmt.Errorf("value must be at least %d characters", seg.MinLength) + } + if seg.MaxLength > 0 && len(val) > seg.MaxLength { + return "", fmt.Errorf("value must be at most %d characters", seg.MaxLength) + } + + // Validate pattern + if seg.Validation.Pattern != "" { + re := regexp.MustCompile(seg.Validation.Pattern) + if !re.MatchString(val) { + msg := seg.Validation.Message + if msg == "" { + msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern) + } + return "", fmt.Errorf(msg) + } + } + + return val, nil +} + +func (g *Generator) formatSerial(seg *schema.Segment, val int) string { + format := fmt.Sprintf("%%0%dd", seg.Length) + return fmt.Sprintf(format, val) +} + +func (g *Generator) resolveScope(scopeTemplate string, resolved map[string]string) string { + if scopeTemplate == "" { + return "_global_" + } + + result := scopeTemplate + for name, val := range resolved { + result = strings.ReplaceAll(result, "{"+name+"}", val) + } + return result +} + +func (g *Generator) applyFormat(format string, resolved map[string]string) string { + result := format + for name, val := range resolved { + result = strings.ReplaceAll(result, "{"+name+"}", val) + } + return result +} + +// Validate checks if a part number matches a schema. +func (g *Generator) Validate(partNumber string, schemaName string) error { + s, ok := g.schemas[schemaName] + if !ok { + return fmt.Errorf("unknown schema: %s", schemaName) + } + + // TODO: parse part number and validate each segment + _ = s + return nil +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..89e86f4 --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,235 @@ +// Package schema handles YAML schema parsing and validation for part numbering. +package schema + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// Schema represents a part numbering schema. +type Schema struct { + Name string `yaml:"name"` + Version int `yaml:"version"` + Description string `yaml:"description"` + Separator string `yaml:"separator"` + Uniqueness Uniqueness `yaml:"uniqueness"` + Segments []Segment `yaml:"segments"` + Format string `yaml:"format"` + Examples []string `yaml:"examples"` + PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"` +} + +// PropertySchemas defines property schemas per category. +type PropertySchemas struct { + Version int `yaml:"version" json:"version"` + Defaults map[string]PropertyDefinition `yaml:"defaults" json:"defaults"` + Categories map[string]map[string]PropertyDefinition `yaml:"categories" json:"categories"` +} + +// PropertyDefinition defines a single property's schema. +type PropertyDefinition struct { + Type string `yaml:"type" json:"type"` // string, number, boolean, date + Default any `yaml:"default" json:"default"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` + Unit string `yaml:"unit,omitempty" json:"unit,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` +} + +// GetPropertiesForCategory returns merged properties for a category. +func (ps *PropertySchemas) GetPropertiesForCategory(category string) map[string]PropertyDefinition { + result := make(map[string]PropertyDefinition) + + // Start with defaults + for k, v := range ps.Defaults { + result[k] = v + } + + // Add category-specific (first character of category code) + if len(category) > 0 { + categoryPrefix := string(category[0]) + if catProps, ok := ps.Categories[categoryPrefix]; ok { + for k, v := range catProps { + result[k] = v + } + } + } + + return result +} + +// ApplyDefaults fills in missing properties with defaults. +func (ps *PropertySchemas) ApplyDefaults(properties map[string]any, category string) map[string]any { + if properties == nil { + properties = make(map[string]any) + } + + defs := ps.GetPropertiesForCategory(category) + + for key, def := range defs { + if _, exists := properties[key]; !exists && def.Default != nil { + properties[key] = def.Default + } + } + + return properties +} + +// Uniqueness defines how part number uniqueness is enforced. +type Uniqueness struct { + Scope string `yaml:"scope"` // global, per-project, per-type, per-schema + CaseSensitive bool `yaml:"case_sensitive"` // default false +} + +// Segment represents a part number segment. +type Segment struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // string, enum, serial, date, constant + Length int `yaml:"length"` + MinLength int `yaml:"min_length"` + MaxLength int `yaml:"max_length"` + Case string `yaml:"case"` // upper, lower, preserve + Padding string `yaml:"padding"` + Start int `yaml:"start"` + Scope string `yaml:"scope"` // template for serial scope + Value string `yaml:"value"` // for constant type + Values map[string]string `yaml:"values"` + Validation Validation `yaml:"validation"` + Required bool `yaml:"required"` + Description string `yaml:"description"` +} + +// Validation defines validation rules for a segment. +type Validation struct { + Pattern string `yaml:"pattern"` + Message string `yaml:"message"` +} + +// SchemaFile wraps the schema in a file structure. +type SchemaFile struct { + Schema Schema `yaml:"schema"` +} + +// Load reads a schema from a YAML file. +func Load(path string) (*Schema, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading schema file: %w", err) + } + + var sf SchemaFile + if err := yaml.Unmarshal(data, &sf); err != nil { + return nil, fmt.Errorf("parsing schema YAML: %w", err) + } + + // Set defaults + if sf.Schema.Separator == "" { + sf.Schema.Separator = "-" + } + if sf.Schema.Uniqueness.Scope == "" { + sf.Schema.Uniqueness.Scope = "global" + } + + return &sf.Schema, nil +} + +// LoadAll reads all schemas from a directory. +func LoadAll(dir string) (map[string]*Schema, error) { + schemas := make(map[string]*Schema) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading schema directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") { + continue + } + + path := filepath.Join(dir, entry.Name()) + schema, err := Load(path) + if err != nil { + return nil, fmt.Errorf("loading %s: %w", entry.Name(), err) + } + schemas[schema.Name] = schema + } + + return schemas, nil +} + +// Validate checks that the schema definition is valid. +func (s *Schema) Validate() error { + if s.Name == "" { + return fmt.Errorf("schema name is required") + } + if len(s.Segments) == 0 { + return fmt.Errorf("schema must have at least one segment") + } + + for i, seg := range s.Segments { + if err := seg.Validate(); err != nil { + return fmt.Errorf("segment %d (%s): %w", i, seg.Name, err) + } + } + + return nil +} + +// Validate checks that a segment definition is valid. +func (seg *Segment) Validate() error { + if seg.Name == "" { + return fmt.Errorf("segment name is required") + } + + validTypes := map[string]bool{ + "string": true, + "enum": true, + "serial": true, + "date": true, + "constant": true, + } + if !validTypes[seg.Type] { + return fmt.Errorf("invalid segment type: %s", seg.Type) + } + + switch seg.Type { + case "enum": + if len(seg.Values) == 0 { + return fmt.Errorf("enum segment requires values") + } + case "constant": + if seg.Value == "" { + return fmt.Errorf("constant segment requires value") + } + case "serial": + if seg.Length <= 0 { + return fmt.Errorf("serial segment requires positive length") + } + } + + if seg.Validation.Pattern != "" { + if _, err := regexp.Compile(seg.Validation.Pattern); err != nil { + return fmt.Errorf("invalid validation pattern: %w", err) + } + } + + return nil +} + +// GetSegment returns a segment by name. +func (s *Schema) GetSegment(name string) *Segment { + for i := range s.Segments { + if s.Segments[i].Name == name { + return &s.Segments[i] + } + } + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..c0e9c61 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,121 @@ +// Package storage provides MinIO file storage operations. +package storage + +import ( + "context" + "fmt" + "io" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// Config holds MinIO connection settings. +type Config struct { + Endpoint string + AccessKey string + SecretKey string + Bucket string + UseSSL bool + Region string +} + +// Storage wraps MinIO client operations. +type Storage struct { + client *minio.Client + bucket string +} + +// Connect creates a new MinIO storage client. +func Connect(ctx context.Context, cfg Config) (*Storage, error) { + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + Region: cfg.Region, + }) + if err != nil { + return nil, fmt.Errorf("creating minio client: %w", err) + } + + // Ensure bucket exists with versioning + exists, err := client.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("checking bucket: %w", err) + } + if !exists { + if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{ + Region: cfg.Region, + }); err != nil { + return nil, fmt.Errorf("creating bucket: %w", err) + } + // Enable versioning + if err := client.EnableVersioning(ctx, cfg.Bucket); err != nil { + return nil, fmt.Errorf("enabling versioning: %w", err) + } + } + + return &Storage{client: client, bucket: cfg.Bucket}, nil +} + +// PutResult contains the result of a put operation. +type PutResult struct { + Key string + VersionID string + Size int64 + Checksum string +} + +// Put uploads a file to storage. +func (s *Storage) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error) { + info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return nil, fmt.Errorf("uploading object: %w", err) + } + + return &PutResult{ + Key: key, + VersionID: info.VersionID, + Size: info.Size, + Checksum: info.ChecksumSHA256, + }, nil +} + +// Get downloads a file from storage. +func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) { + obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("getting object: %w", err) + } + return obj, nil +} + +// GetVersion downloads a specific version of a file. +func (s *Storage) GetVersion(ctx context.Context, key, versionID string) (io.ReadCloser, error) { + obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{ + VersionID: versionID, + }) + if err != nil { + return nil, fmt.Errorf("getting object version: %w", err) + } + return obj, nil +} + +// Delete removes a file from storage. +func (s *Storage) Delete(ctx context.Context, key string) error { + if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { + return fmt.Errorf("removing object: %w", err) + } + return nil +} + +// FileKey generates a storage key for an item file. +func FileKey(partNumber string, revision int) string { + return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision) +} + +// ThumbnailKey generates a storage key for a thumbnail. +func ThumbnailKey(partNumber string, revision int) string { + return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision) +} diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..db07271 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,228 @@ +-- Silo Database Schema +-- Migration: 001_initial +-- Date: 2026-01 + +BEGIN; + +-- Enable extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- For fuzzy text search + +-------------------------------------------------------------------------------- +-- Part Numbering Schemas +-------------------------------------------------------------------------------- + +CREATE TABLE schemas ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT UNIQUE NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + description TEXT, + definition JSONB NOT NULL, -- Parsed YAML schema + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_schemas_name ON schemas(name); + +-------------------------------------------------------------------------------- +-- Items (Core Entity) +-------------------------------------------------------------------------------- + +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + part_number TEXT UNIQUE NOT NULL, + schema_id UUID REFERENCES schemas(id), + item_type TEXT NOT NULL, -- 'part', 'assembly', 'drawing', etc. + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + archived_at TIMESTAMPTZ, -- Soft delete + current_revision INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_items_part_number ON items(part_number); +CREATE INDEX idx_items_schema ON items(schema_id); +CREATE INDEX idx_items_type ON items(item_type); +CREATE INDEX idx_items_description_trgm ON items USING gin(description gin_trgm_ops); + +-------------------------------------------------------------------------------- +-- Revisions (Append-Only History) +-------------------------------------------------------------------------------- + +CREATE TABLE revisions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + properties JSONB NOT NULL DEFAULT '{}', + file_key TEXT, -- MinIO object key + file_version TEXT, -- MinIO version ID + file_checksum TEXT, -- SHA256 of file + file_size BIGINT, -- File size in bytes + thumbnail_key TEXT, -- MinIO key for thumbnail + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by TEXT, -- User identifier + comment TEXT, + UNIQUE(item_id, revision_number) +); + +CREATE INDEX idx_revisions_item ON revisions(item_id); +CREATE INDEX idx_revisions_item_rev ON revisions(item_id, revision_number DESC); + +-------------------------------------------------------------------------------- +-- Relationships (BOM Structure) +-------------------------------------------------------------------------------- + +CREATE TYPE relationship_type AS ENUM ('component', 'alternate', 'reference'); + +CREATE TABLE relationships ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + parent_item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + child_item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + rel_type relationship_type NOT NULL DEFAULT 'component', + quantity DECIMAL(12, 4), + unit TEXT, -- Unit of measure + reference_designators TEXT[], -- e.g., ARRAY['R1', 'R2', 'R3'] + child_revision INTEGER, -- NULL means "latest" + metadata JSONB, -- Assembly-specific relationship properties + parent_revision_id UUID REFERENCES revisions(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT no_self_reference CHECK (parent_item_id != child_item_id) +); + +CREATE INDEX idx_relationships_parent ON relationships(parent_item_id); +CREATE INDEX idx_relationships_child ON relationships(child_item_id); +CREATE INDEX idx_relationships_type ON relationships(rel_type); + +-------------------------------------------------------------------------------- +-- Locations (Physical Inventory Hierarchy) +-------------------------------------------------------------------------------- + +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + path TEXT UNIQUE NOT NULL, -- e.g., 'lab/shelf-a/bin-3' + name TEXT NOT NULL, + parent_id UUID REFERENCES locations(id), + location_type TEXT NOT NULL, + depth INTEGER NOT NULL DEFAULT 0, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_locations_path ON locations(path); +CREATE INDEX idx_locations_parent ON locations(parent_id); +CREATE INDEX idx_locations_type ON locations(location_type); + +-------------------------------------------------------------------------------- +-- Inventory (Item Quantities at Locations) +-------------------------------------------------------------------------------- + +CREATE TABLE inventory ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE, + quantity DECIMAL(12, 4) NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(item_id, location_id) +); + +CREATE INDEX idx_inventory_item ON inventory(item_id); +CREATE INDEX idx_inventory_location ON inventory(location_id); + +-------------------------------------------------------------------------------- +-- Sequence Counters (Part Number Generation) +-------------------------------------------------------------------------------- + +CREATE TABLE sequences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + schema_id UUID NOT NULL REFERENCES schemas(id) ON DELETE CASCADE, + scope TEXT NOT NULL, -- Scope key, e.g., 'PROTO-AS' + current_value INTEGER NOT NULL DEFAULT 0, + UNIQUE(schema_id, scope) +); + +CREATE INDEX idx_sequences_schema_scope ON sequences(schema_id, scope); + +-------------------------------------------------------------------------------- +-- Functions +-------------------------------------------------------------------------------- + +-- Get next sequence value atomically +CREATE OR REPLACE FUNCTION next_sequence_value( + p_schema_id UUID, + p_scope TEXT +) RETURNS INTEGER AS $$ +DECLARE + v_next INTEGER; +BEGIN + INSERT INTO sequences (schema_id, scope, current_value) + VALUES (p_schema_id, p_scope, 1) + ON CONFLICT (schema_id, scope) DO UPDATE + SET current_value = sequences.current_value + 1 + RETURNING current_value INTO v_next; + + RETURN v_next; +END; +$$ LANGUAGE plpgsql; + +-- Update item's current revision +CREATE OR REPLACE FUNCTION update_item_revision() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE items + SET current_revision = NEW.revision_number, + updated_at = now() + WHERE id = NEW.item_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_item_revision +AFTER INSERT ON revisions +FOR EACH ROW EXECUTE FUNCTION update_item_revision(); + +-------------------------------------------------------------------------------- +-- Views +-------------------------------------------------------------------------------- + +-- Current item state (latest revision) +CREATE VIEW items_current AS +SELECT + i.id, + i.part_number, + i.item_type, + i.description, + i.schema_id, + i.current_revision, + i.created_at, + i.updated_at, + r.properties, + r.file_key, + r.file_version, + r.thumbnail_key, + r.created_by AS last_modified_by, + r.comment AS last_revision_comment +FROM items i +LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision +WHERE i.archived_at IS NULL; + +-- BOM explosion (single level) +CREATE VIEW bom_single_level AS +SELECT + rel.parent_item_id, + parent.part_number AS parent_part_number, + rel.child_item_id, + child.part_number AS child_part_number, + child.description AS child_description, + rel.rel_type, + rel.quantity, + rel.unit, + rel.reference_designators, + rel.child_revision, + COALESCE(rel.child_revision, child.current_revision) AS effective_revision +FROM relationships rel +JOIN items parent ON parent.id = rel.parent_item_id +JOIN items child ON child.id = rel.child_item_id +WHERE parent.archived_at IS NULL AND child.archived_at IS NULL; + +COMMIT; diff --git a/migrations/002_sequence_by_name.sql b/migrations/002_sequence_by_name.sql new file mode 100644 index 0000000..091ed13 --- /dev/null +++ b/migrations/002_sequence_by_name.sql @@ -0,0 +1,35 @@ +-- Migration: 002_sequence_by_name +-- Adds a sequence table that uses schema name instead of UUID for simpler operation + +BEGIN; + +-- Create a simpler sequences table using schema name +CREATE TABLE IF NOT EXISTS sequences_by_name ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + schema_name TEXT NOT NULL, + scope TEXT NOT NULL, + current_value INTEGER NOT NULL DEFAULT 0, + UNIQUE(schema_name, scope) +); + +CREATE INDEX IF NOT EXISTS idx_sequences_by_name ON sequences_by_name(schema_name, scope); + +-- Function to get next sequence by schema name +CREATE OR REPLACE FUNCTION next_sequence_by_name( + p_schema_name TEXT, + p_scope TEXT +) RETURNS INTEGER AS $$ +DECLARE + v_next INTEGER; +BEGIN + INSERT INTO sequences_by_name (schema_name, scope, current_value) + VALUES (p_schema_name, p_scope, 1) + ON CONFLICT (schema_name, scope) DO UPDATE + SET current_value = sequences_by_name.current_value + 1 + RETURNING current_value INTO v_next; + + RETURN v_next; +END; +$$ LANGUAGE plpgsql; + +COMMIT; diff --git a/migrations/003_remove_material.sql b/migrations/003_remove_material.sql new file mode 100644 index 0000000..927adaf --- /dev/null +++ b/migrations/003_remove_material.sql @@ -0,0 +1,23 @@ +-- Migration: 003_remove_material +-- Removes the material segment from part numbers +-- Old format: {project}-{category}-{material}-{sequence} (e.g., CS100-F01-316-0001) +-- New format: {project}-{category}-{sequence} (e.g., CS100-F01-0001) + +BEGIN; + +-- Transform existing part numbers: remove 3rd segment (material) +-- Pattern: XXXXX-CCC-MMM-NNNN -> XXXXX-CCC-NNNN +UPDATE items +SET part_number = + split_part(part_number, '-', 1) || '-' || + split_part(part_number, '-', 2) || '-' || + split_part(part_number, '-', 4), + updated_at = now() +WHERE part_number ~ '^[A-Z0-9]{5}-[A-Z][0-9]{2}-[A-Z0-9]{3}-[0-9]{4}$'; + +-- Update properties JSONB in revisions to remove material key +UPDATE revisions +SET properties = properties - 'material' +WHERE properties ? 'material'; + +COMMIT; diff --git a/migrations/004_cad_sync_state.sql b/migrations/004_cad_sync_state.sql new file mode 100644 index 0000000..6040ad7 --- /dev/null +++ b/migrations/004_cad_sync_state.sql @@ -0,0 +1,18 @@ +-- Silo Database Schema +-- Migration: 004_cad_sync_state +-- Date: 2026-01 +-- Description: Add CAD file sync tracking for FreeCAD integration + +BEGIN; + +-- Add columns to track CAD file sync state +ALTER TABLE items ADD COLUMN cad_synced_at TIMESTAMPTZ; +ALTER TABLE items ADD COLUMN cad_file_path TEXT; + +-- Index for finding unsynced items +CREATE INDEX idx_items_cad_synced ON items(cad_synced_at) WHERE cad_synced_at IS NULL; + +COMMENT ON COLUMN items.cad_synced_at IS 'Timestamp when the item was last synced with a local FreeCAD file'; +COMMENT ON COLUMN items.cad_file_path IS 'Expected local path for the CAD file (relative to projects dir)'; + +COMMIT; diff --git a/migrations/005_property_schema_version.sql b/migrations/005_property_schema_version.sql new file mode 100644 index 0000000..f4698e2 --- /dev/null +++ b/migrations/005_property_schema_version.sql @@ -0,0 +1,30 @@ +-- Migration: 005_property_schema_version +-- Description: Track property schema version for automated migrations + +BEGIN; + +-- Add property schema version to revisions +-- This tracks which version of the property schema was used when the revision was created +ALTER TABLE revisions ADD COLUMN IF NOT EXISTS property_schema_version INTEGER DEFAULT 1; + +-- Create index for finding revisions that need migration +CREATE INDEX IF NOT EXISTS idx_revisions_property_schema_version ON revisions(property_schema_version); + +-- Create property migration history table +-- Tracks when property schema migrations have been run +CREATE TABLE IF NOT EXISTS property_migrations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + schema_name TEXT NOT NULL, + from_version INTEGER NOT NULL, + to_version INTEGER NOT NULL, + items_affected INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'pending', -- pending, running, completed, failed + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_property_migrations_schema ON property_migrations(schema_name); +CREATE INDEX IF NOT EXISTS idx_property_migrations_status ON property_migrations(status); + +COMMIT; diff --git a/pkg/freecad/Init.py b/pkg/freecad/Init.py new file mode 100644 index 0000000..c5dd7c9 --- /dev/null +++ b/pkg/freecad/Init.py @@ -0,0 +1,8 @@ +"""Silo FreeCAD Workbench - Console initialization. + +This file is loaded when FreeCAD starts (even in console mode). +The GUI-specific initialization is in InitGui.py. +""" + +# No console-only initialization needed for Silo workbench +# All functionality requires the GUI diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py new file mode 100644 index 0000000..42aec92 --- /dev/null +++ b/pkg/freecad/InitGui.py @@ -0,0 +1,109 @@ +"""Silo FreeCAD Workbench - Item database integration.""" + +import os + +import FreeCAD +import FreeCADGui + +FreeCAD.Console.PrintMessage("Silo InitGui.py loading...\n") + + +class SiloWorkbench(FreeCADGui.Workbench): + """Silo workbench for item database integration.""" + + MenuText = "Silo" + ToolTip = "Item database and part management" + Icon = "" + + def __init__(self): + # Find icon path + try: + locations = [ + os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "Silo"), + os.path.join(FreeCAD.getResourceDir(), "Mod", "Silo"), + os.path.expanduser( + "~/.var/app/org.freecad.FreeCAD/data/FreeCAD/Mod/Silo" + ), + os.path.expanduser("~/.FreeCAD/Mod/Silo"), + ] + for silo_dir in locations: + icon_path = os.path.join(silo_dir, "resources", "icons", "silo.svg") + if os.path.exists(icon_path): + self.__class__.Icon = icon_path + FreeCAD.Console.PrintMessage("Silo icon: " + icon_path + "\n") + break + except Exception as e: + FreeCAD.Console.PrintWarning("Silo icon error: " + str(e) + "\n") + + def Initialize(self): + """Called when workbench is first activated.""" + import silo_commands + + self.toolbar_commands = [ + "Silo_Open", + "Silo_New", + "Silo_Save", + "Silo_Commit", + "Silo_Pull", + "Silo_Push", + "Silo_Info", + ] + + self.appendToolbar("Silo", self.toolbar_commands) + self.appendMenu("Silo", self.toolbar_commands) + + def Activated(self): + """Called when workbench is activated.""" + FreeCAD.Console.PrintMessage("Silo workbench activated\n") + FreeCAD.Console.PrintMessage( + " API: SILO_API_URL (default: http://localhost:8080/api)\n" + ) + FreeCAD.Console.PrintMessage( + " Projects: SILO_PROJECTS_DIR (default: ~/projects)\n" + ) + self._show_shortcut_recommendations() + + def Deactivated(self): + pass + + def GetClassName(self): + return "Gui::PythonWorkbench" + + def _show_shortcut_recommendations(self): + """Show keyboard shortcut recommendations dialog on first activation.""" + try: + param_group = FreeCAD.ParamGet( + "User parameter:BaseApp/Preferences/Mod/Silo" + ) + if param_group.GetBool("ShortcutsShown", False): + return + param_group.SetBool("ShortcutsShown", True) + + from PySide import QtGui + + msg = """

Welcome to Silo Workbench!

+

For the best experience, set up these keyboard shortcuts:

+ + + + + +
Ctrl+O - Silo_Open (Search & Open)
Ctrl+N - Silo_New (Register new item)
Ctrl+S - Silo_Save (Save & upload)
Ctrl+Shift+S - Silo_Commit (Save with comment)
+

To set shortcuts: Tools > Customize > Keyboard

+

This message appears once.

""" + + dialog = QtGui.QMessageBox() + dialog.setWindowTitle("Silo Keyboard Shortcuts") + dialog.setTextFormat(QtGui.Qt.RichText) + dialog.setText(msg) + dialog.setIcon(QtGui.QMessageBox.Information) + dialog.addButton("Set Up Now", QtGui.QMessageBox.AcceptRole) + dialog.addButton("Later", QtGui.QMessageBox.RejectRole) + if dialog.exec_() == 0: + FreeCADGui.runCommand("Std_DlgCustomize", 0) + except Exception as e: + FreeCAD.Console.PrintWarning("Silo shortcuts dialog: " + str(e) + "\n") + + +FreeCADGui.addWorkbench(SiloWorkbench()) +FreeCAD.Console.PrintMessage("Silo workbench registered\n") diff --git a/pkg/freecad/package.xml b/pkg/freecad/package.xml new file mode 100644 index 0000000..8317817 --- /dev/null +++ b/pkg/freecad/package.xml @@ -0,0 +1,15 @@ + + + Silo + Item database and part management workbench + 0.1.0 + Kindred Systems + MIT + https://github.com/kindredsystems/silo + + + SiloWorkbench + ./ + + + diff --git a/pkg/freecad/resources/icons/silo-commit.svg b/pkg/freecad/resources/icons/silo-commit.svg new file mode 100644 index 0000000..f49b77c --- /dev/null +++ b/pkg/freecad/resources/icons/silo-commit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/freecad/resources/icons/silo-info.svg b/pkg/freecad/resources/icons/silo-info.svg new file mode 100644 index 0000000..2a48196 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-info.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/pkg/freecad/resources/icons/silo-new.svg b/pkg/freecad/resources/icons/silo-new.svg new file mode 100644 index 0000000..5bf6836 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-new.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/freecad/resources/icons/silo-open.svg b/pkg/freecad/resources/icons/silo-open.svg new file mode 100644 index 0000000..ef555e6 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-open.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/freecad/resources/icons/silo-pull.svg b/pkg/freecad/resources/icons/silo-pull.svg new file mode 100644 index 0000000..8c25cec --- /dev/null +++ b/pkg/freecad/resources/icons/silo-pull.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pkg/freecad/resources/icons/silo-push.svg b/pkg/freecad/resources/icons/silo-push.svg new file mode 100644 index 0000000..585fdd7 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-push.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pkg/freecad/resources/icons/silo-save.svg b/pkg/freecad/resources/icons/silo-save.svg new file mode 100644 index 0000000..f20eb88 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-save.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/freecad/resources/icons/silo.svg b/pkg/freecad/resources/icons/silo.svg new file mode 100644 index 0000000..29dd81d --- /dev/null +++ b/pkg/freecad/resources/icons/silo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py new file mode 100644 index 0000000..9c59301 --- /dev/null +++ b/pkg/freecad/silo_commands.py @@ -0,0 +1,1184 @@ +"""Silo FreeCAD commands - Streamlined workflow for CAD file management.""" + +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import FreeCAD +import FreeCADGui + +# Configuration +SILO_API_URL = os.environ.get("SILO_API_URL", "http://localhost:8080/api") +SILO_PROJECTS_DIR = os.environ.get( + "SILO_PROJECTS_DIR", os.path.expanduser("~/projects") +) + + +# Icon directory +def _get_icon_dir(): + """Get the icons directory path.""" + locations = [ + os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "Silo", "resources", "icons"), + os.path.expanduser( + "~/.var/app/org.freecad.FreeCAD/data/FreeCAD/Mod/Silo/resources/icons" + ), + os.path.expanduser("~/.FreeCAD/Mod/Silo/resources/icons"), + ] + for loc in locations: + if os.path.isdir(loc): + return loc + return "" + + +_ICON_DIR = _get_icon_dir() + + +def _icon(name): + """Get icon path by name.""" + if _ICON_DIR: + path = os.path.join(_ICON_DIR, f"silo-{name}.svg") + if os.path.exists(path): + return path + return "" + + +def get_projects_dir() -> Path: + """Get the projects directory.""" + projects_dir = Path(SILO_PROJECTS_DIR) + projects_dir.mkdir(parents=True, exist_ok=True) + return projects_dir + + +class SiloClient: + """HTTP client for Silo API.""" + + def __init__(self, base_url: str = SILO_API_URL): + self.base_url = base_url.rstrip("/") + + def _request( + self, method: str, path: str, data: Optional[Dict] = None + ) -> Dict[str, Any]: + """Make HTTP request to Silo API.""" + url = f"{self.base_url}{path}" + headers = {"Content-Type": "application/json"} + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + error_body = e.read().decode() + raise RuntimeError(f"API error {e.code}: {error_body}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool: + """Download a file from MinIO storage.""" + url = f"{self.base_url}/items/{part_number}/file/{revision}" + req = urllib.request.Request(url, method="GET") + + try: + with urllib.request.urlopen(req) as resp: + with open(dest_path, "wb") as f: + while True: + chunk = resp.read(8192) + if not chunk: + break + f.write(chunk) + return True + except urllib.error.HTTPError as e: + if e.code == 404: + return False + raise RuntimeError(f"Download error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def _upload_file( + self, part_number: str, file_path: str, properties: Dict, comment: str = "" + ) -> Dict[str, Any]: + """Upload a file and create a new revision.""" + import mimetypes + + url = f"{self.base_url}/items/{part_number}/file" + + with open(file_path, "rb") as f: + file_data = f.read() + + boundary = "----SiloUploadBoundary" + str(hash(file_path))[-8:] + body_parts = [] + + filename = os.path.basename(file_path) + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + body_parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n' + ) + body_parts.append(file_data) + body_parts.append(b"\r\n") + + if comment: + body_parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="comment"\r\n\r\n{comment}\r\n' + ) + + if properties: + # Ensure properties is valid JSON - handle special float values + props_json = json.dumps(properties, allow_nan=False, default=str) + body_parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="properties"\r\n\r\n{props_json}\r\n' + ) + + body_parts.append(f"--{boundary}--\r\n") + + body = b"" + for part in body_parts: + body += part.encode("utf-8") if isinstance(part, str) else part + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)), + } + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}") + except urllib.error.URLError as e: + raise RuntimeError(f"Connection error: {e.reason}") + + def get_item(self, part_number: str) -> Dict[str, Any]: + return self._request("GET", f"/items/{part_number}") + + def list_items( + self, search: str = "", item_type: str = "", project: str = "" + ) -> list: + params = ["limit=100"] + if search: + params.append(f"search={urllib.parse.quote(search)}") + if item_type: + params.append(f"type={item_type}") + if project: + params.append(f"project={project}") + return self._request("GET", "/items?" + "&".join(params)) + + def create_item( + self, schema: str, project: str, category: str, description: str = "" + ) -> Dict[str, Any]: + return self._request( + "POST", + "/items", + { + "schema": schema, + "project": project, + "category": category, + "description": description, + }, + ) + + def update_item( + self, part_number: str, description: str = None, item_type: str = None + ) -> Dict[str, Any]: + data = {} + if description is not None: + data["description"] = description + if item_type is not None: + data["item_type"] = item_type + return self._request("PUT", f"/items/{part_number}", data) + + def get_revisions(self, part_number: str) -> list: + return self._request("GET", f"/items/{part_number}/revisions") + + def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: + return self._request("GET", f"/schemas/{name}") + + def get_projects(self) -> list: + return self._request("GET", "/projects") + + def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]: + """Check if item has files in MinIO.""" + try: + revisions = self.get_revisions(part_number) + for rev in revisions: + if rev.get("file_key"): + return True, rev["revision_number"] + return False, None + except Exception: + return False, None + + +_client = SiloClient() + + +# Utility functions + + +def sanitize_filename(name: str) -> str: + """Sanitize a string for use in filenames.""" + sanitized = re.sub(r'[<>:"/\\|?*]', "_", name) + sanitized = re.sub(r"[\s_]+", "_", sanitized) + sanitized = sanitized.strip("_ ") + return sanitized[:50] + + +def parse_part_number(part_number: str) -> Tuple[str, str, str]: + """Parse part number into (project, category, sequence).""" + parts = part_number.split("-") + if len(parts) >= 3: + return parts[0], parts[1], parts[2] + return part_number, "", "" + + +def get_cad_file_path(part_number: str, description: str = "") -> Path: + """Generate canonical file path for a CAD file.""" + project_code, _, _ = parse_part_number(part_number) + if description: + filename = f"{part_number}_{sanitize_filename(description)}.FCStd" + else: + filename = f"{part_number}.FCStd" + return get_projects_dir() / project_code.lower() / "cad" / filename + + +def find_file_by_part_number(part_number: str) -> Optional[Path]: + """Find existing CAD file for a part number.""" + project_code, _, _ = parse_part_number(part_number) + cad_dir = get_projects_dir() / project_code.lower() / "cad" + if not cad_dir.exists(): + return None + matches = list(cad_dir.glob(f"{part_number}*.FCStd")) + return matches[0] if matches else None + + +def search_local_files(search_term: str = "", project_filter: str = "") -> list: + """Search for CAD files in local projects directory.""" + results = [] + base_dir = get_projects_dir() + if not base_dir.exists(): + return results + + search_lower = search_term.lower() + + for project_dir in base_dir.iterdir(): + if not project_dir.is_dir(): + continue + if project_filter and project_dir.name.lower() != project_filter.lower(): + continue + + cad_dir = project_dir / "cad" + if not cad_dir.exists(): + continue + + for fcstd_file in cad_dir.glob("*.FCStd"): + filename = fcstd_file.stem + parts = filename.split("_", 1) + part_number = parts[0] + description = parts[1].replace("_", " ") if len(parts) > 1 else "" + + if search_term: + searchable = f"{part_number} {description}".lower() + if search_lower not in searchable: + continue + + try: + from datetime import datetime + + mtime = fcstd_file.stat().st_mtime + modified = datetime.fromtimestamp(mtime).isoformat() + except Exception: + modified = None + + results.append( + { + "path": str(fcstd_file), + "part_number": part_number, + "description": description, + "project": project_dir.name.upper(), + "modified": modified, + "source": "local", + } + ) + + results.sort(key=lambda x: x.get("modified") or "", reverse=True) + return results + + +def _safe_float(val): + """Convert float to JSON-safe value, handling NaN and Infinity.""" + import math + + if isinstance(val, float): + if math.isnan(val) or math.isinf(val): + return 0.0 + return val + + +def collect_document_properties(doc) -> Dict[str, Any]: + """Collect properties from all objects in a document.""" + result = { + "_document_name": doc.Name, + "_file_name": doc.FileName or None, + "objects": {}, + } + + for obj in doc.Objects: + if obj.TypeId.startswith("App::") and obj.TypeId not in ("App::Part",): + continue + + props = {"_object_type": obj.TypeId, "_label": obj.Label} + + if hasattr(obj, "Placement"): + p = obj.Placement + props["placement"] = { + "position": { + "x": _safe_float(p.Base.x), + "y": _safe_float(p.Base.y), + "z": _safe_float(p.Base.z), + }, + "rotation": { + "axis": { + "x": _safe_float(p.Rotation.Axis.x), + "y": _safe_float(p.Rotation.Axis.y), + "z": _safe_float(p.Rotation.Axis.z), + }, + "angle": _safe_float(p.Rotation.Angle), + }, + } + + if hasattr(obj, "Shape") and obj.Shape: + try: + bbox = obj.Shape.BoundBox + props["bounding_box"] = { + "x_length": _safe_float(bbox.XLength), + "y_length": _safe_float(bbox.YLength), + "z_length": _safe_float(bbox.ZLength), + } + if hasattr(obj.Shape, "Volume"): + props["volume"] = _safe_float(obj.Shape.Volume) + except Exception: + pass + + result["objects"][obj.Label] = props + + return result + + +def set_silo_properties(obj, props: Dict[str, Any]): + """Set Silo properties on FreeCAD object.""" + for name, value in props.items(): + if not hasattr(obj, name): + if isinstance(value, str): + obj.addProperty("App::PropertyString", name, "Silo", "") + elif isinstance(value, int): + obj.addProperty("App::PropertyInteger", name, "Silo", "") + setattr(obj, name, value) + + +def get_tracked_object(doc): + """Find the primary tracked object in a document.""" + for obj in doc.Objects: + if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: + return obj + return None + + +class SiloSync: + """Handles synchronization between FreeCAD and Silo.""" + + def __init__(self, client: SiloClient = None): + self.client = client or _client + + def save_to_canonical_path(self, doc, force_rename: bool = False) -> Optional[Path]: + """Save document to canonical path.""" + obj = get_tracked_object(doc) + if not obj: + return None + + part_number = obj.SiloPartNumber + + try: + item = self.client.get_item(part_number) + description = item.get("description", "") + new_path = get_cad_file_path(part_number, description) + new_path.parent.mkdir(parents=True, exist_ok=True) + + existing_path = find_file_by_part_number(part_number) + current_path = Path(doc.FileName) if doc.FileName else None + + # Use save() if already at the correct path, saveAs() only if path changes + if current_path and current_path == new_path: + doc.save() + elif ( + existing_path + and existing_path != new_path + and (force_rename or current_path == existing_path) + ): + doc.saveAs(str(new_path)) + try: + existing_path.unlink() + except OSError: + pass + else: + doc.saveAs(str(new_path)) + + return new_path + except Exception as e: + FreeCAD.Console.PrintError(f"Save failed: {e}\n") + return None + + def create_document_for_item(self, item: Dict[str, Any], save: bool = True): + """Create a new FreeCAD document for a database item.""" + part_number = item.get("part_number", "") + description = item.get("description", "") + item_type = item.get("item_type", "part") + + if not part_number: + return None + + doc = FreeCAD.newDocument(part_number) + safe_name = "_" + part_number + + if item_type == "assembly": + # Create an Assembly object for assembly items (FreeCAD 1.0+) + try: + assembly_obj = doc.addObject("Assembly::AssemblyObject", safe_name) + assembly_obj.Label = part_number + set_silo_properties( + assembly_obj, + { + "SiloPartNumber": part_number, + "SiloRevision": item.get("current_revision", 1), + "SiloItemType": item_type, + }, + ) + except Exception as e: + # Fallback to App::Part if Assembly workbench not available + FreeCAD.Console.PrintWarning( + f"Assembly workbench not available, using App::Part: {e}\n" + ) + part_obj = doc.addObject("App::Part", safe_name) + part_obj.Label = part_number + set_silo_properties( + part_obj, + { + "SiloPartNumber": part_number, + "SiloRevision": item.get("current_revision", 1), + "SiloItemType": item_type, + }, + ) + else: + # Create a Part container for non-assembly items + part_obj = doc.addObject("App::Part", safe_name) + part_obj.Label = part_number + + set_silo_properties( + part_obj, + { + "SiloPartNumber": part_number, + "SiloRevision": item.get("current_revision", 1), + "SiloItemType": item_type, + }, + ) + + # Add a Body for parts (not assemblies) + body_label = sanitize_filename(description) if description else "Body" + body = doc.addObject("PartDesign::Body", "_" + body_label) + body.Label = body_label + part_obj.addObject(body) + + doc.recompute() + + if save: + file_path = get_cad_file_path(part_number, description) + file_path.parent.mkdir(parents=True, exist_ok=True) + doc.saveAs(str(file_path)) + + return doc + + def open_item(self, part_number: str): + """Open or create item document.""" + existing_path = find_file_by_part_number(part_number) + + if existing_path and existing_path.exists(): + return FreeCAD.openDocument(str(existing_path)) + + try: + item = self.client.get_item(part_number) + return self.create_document_for_item(item, save=True) + except Exception as e: + FreeCAD.Console.PrintError(f"Failed to open: {e}\n") + return None + + def upload_file( + self, part_number: str, file_path: str, comment: str = "Auto-save" + ) -> Optional[Dict]: + """Upload file to MinIO.""" + try: + doc = FreeCAD.openDocument(file_path) + if not doc: + return None + properties = collect_document_properties(doc) + FreeCAD.closeDocument(doc.Name) + + return self.client._upload_file(part_number, file_path, properties, comment) + except Exception as e: + FreeCAD.Console.PrintError(f"Upload failed: {e}\n") + return None + + def download_file(self, part_number: str) -> Optional[Path]: + """Download latest file from MinIO.""" + try: + item = self.client.get_item(part_number) + file_path = get_cad_file_path(part_number, item.get("description", "")) + file_path.parent.mkdir(parents=True, exist_ok=True) + + revisions = self.client.get_revisions(part_number) + for rev in revisions: + if rev.get("file_key"): + if self.client._download_file( + part_number, rev["revision_number"], str(file_path) + ): + return file_path + return None + except Exception as e: + FreeCAD.Console.PrintError(f"Download failed: {e}\n") + return None + + +_sync = SiloSync() + + +# ============================================================================ +# COMMANDS +# ============================================================================ + + +class Silo_Open: + """Open item - combined search and open dialog.""" + + def GetResources(self): + return { + "MenuText": "Open", + "ToolTip": "Search and open items (Ctrl+O)", + "Pixmap": _icon("open"), + } + + def Activated(self): + from PySide import QtCore, QtGui + + dialog = QtGui.QDialog() + dialog.setWindowTitle("Silo - Open Item") + dialog.setMinimumWidth(700) + dialog.setMinimumHeight(500) + + layout = QtGui.QVBoxLayout(dialog) + + # Search row + search_layout = QtGui.QHBoxLayout() + search_input = QtGui.QLineEdit() + search_input.setPlaceholderText("Search by part number or description...") + search_layout.addWidget(search_input) + layout.addLayout(search_layout) + + # Filters + filter_layout = QtGui.QHBoxLayout() + db_checkbox = QtGui.QCheckBox("Database") + db_checkbox.setChecked(True) + local_checkbox = QtGui.QCheckBox("Local Files") + local_checkbox.setChecked(True) + filter_layout.addWidget(db_checkbox) + filter_layout.addWidget(local_checkbox) + filter_layout.addStretch() + layout.addLayout(filter_layout) + + # Results table + results_table = QtGui.QTableWidget() + results_table.setColumnCount(5) + results_table.setHorizontalHeaderLabels( + ["Part Number", "Description", "Type", "Source", "Modified"] + ) + results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) + results_table.horizontalHeader().setStretchLastSection(True) + results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + layout.addWidget(results_table) + + results_data = [] + + def do_search(): + nonlocal results_data + search_term = search_input.text().strip() + results_data = [] + results_table.setRowCount(0) + + if db_checkbox.isChecked(): + try: + for item in _client.list_items(search=search_term): + results_data.append( + { + "part_number": item.get("part_number", ""), + "description": item.get("description", ""), + "item_type": item.get("item_type", ""), + "source": "database", + "modified": item.get("updated_at", "")[:10] + if item.get("updated_at") + else "", + "path": None, + } + ) + except Exception as e: + FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n") + + if local_checkbox.isChecked(): + try: + for item in search_local_files(search_term): + existing = next( + ( + r + for r in results_data + if r["part_number"] == item["part_number"] + ), + None, + ) + if existing: + existing["source"] = "both" + existing["path"] = item.get("path") + else: + results_data.append( + { + "part_number": item.get("part_number", ""), + "description": item.get("description", ""), + "item_type": "", + "source": "local", + "modified": item.get("modified", "")[:10] + if item.get("modified") + else "", + "path": item.get("path"), + } + ) + except Exception as e: + FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n") + + results_table.setRowCount(len(results_data)) + for row, data in enumerate(results_data): + results_table.setItem( + row, 0, QtGui.QTableWidgetItem(data["part_number"]) + ) + results_table.setItem( + row, 1, QtGui.QTableWidgetItem(data["description"]) + ) + results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"])) + results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"])) + results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"])) + results_table.resizeColumnsToContents() + + def open_selected(): + selected = results_table.selectedItems() + if not selected: + return + row = selected[0].row() + data = results_data[row] + + if data["path"]: + FreeCAD.openDocument(data["path"]) + else: + _sync.open_item(data["part_number"]) + dialog.accept() + + search_input.textChanged.connect(lambda: do_search()) + results_table.doubleClicked.connect(open_selected) + + # Buttons + btn_layout = QtGui.QHBoxLayout() + open_btn = QtGui.QPushButton("Open") + open_btn.clicked.connect(open_selected) + cancel_btn = QtGui.QPushButton("Cancel") + cancel_btn.clicked.connect(dialog.reject) + btn_layout.addStretch() + btn_layout.addWidget(open_btn) + btn_layout.addWidget(cancel_btn) + layout.addLayout(btn_layout) + + do_search() + dialog.exec_() + + def IsActive(self): + return True + + +class Silo_New: + """Create new item with part number.""" + + def GetResources(self): + return { + "MenuText": "New", + "ToolTip": "Create new item (Ctrl+N)", + "Pixmap": _icon("new"), + } + + def Activated(self): + from PySide import QtGui + + sel = FreeCADGui.Selection.getSelection() + + # Project code + try: + projects = _client.get_projects() + if projects: + project, ok = QtGui.QInputDialog.getItem( + None, "New Item", "Project:", projects, 0, True + ) + else: + project, ok = QtGui.QInputDialog.getText( + None, "New Item", "Project code (5 chars):" + ) + except Exception: + project, ok = QtGui.QInputDialog.getText( + None, "New Item", "Project code (5 chars):" + ) + + if not ok or not project: + return + project = project.upper().strip()[:5] + + # Category + try: + schema = _client.get_schema() + categories = schema.get("segments", []) + cat_segment = next( + (s for s in categories if s.get("name") == "category"), None + ) + if cat_segment and cat_segment.get("values"): + cat_list = [ + f"{k} - {v}" for k, v in sorted(cat_segment["values"].items()) + ] + category_str, ok = QtGui.QInputDialog.getItem( + None, "New Item", "Category:", cat_list, 0, False + ) + if not ok: + return + category = category_str.split(" - ")[0] + else: + category, ok = QtGui.QInputDialog.getText( + None, "New Item", "Category code:" + ) + if not ok: + return + except Exception: + category, ok = QtGui.QInputDialog.getText( + None, "New Item", "Category code:" + ) + if not ok: + return + + # Description + default_desc = sel[0].Label if sel else "" + description, ok = QtGui.QInputDialog.getText( + None, "New Item", "Description:", text=default_desc + ) + if not ok: + return + + try: + result = _client.create_item("kindred-rd", project, category, description) + part_number = result["part_number"] + + if sel: + # Tag selected object + obj = sel[0] + set_silo_properties( + obj, {"SiloPartNumber": part_number, "SiloRevision": 1} + ) + obj.Label = part_number + _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) + else: + # Create new document + _sync.create_document_for_item(result, save=True) + + FreeCAD.Console.PrintMessage(f"Created: {part_number}\n") + QtGui.QMessageBox.information( + None, "Item Created", f"Part number: {part_number}" + ) + + except Exception as e: + QtGui.QMessageBox.critical(None, "Error", str(e)) + + def IsActive(self): + return True + + +class Silo_Save: + """Save locally and upload to MinIO.""" + + def GetResources(self): + return { + "MenuText": "Save", + "ToolTip": "Save locally and upload to MinIO (Ctrl+S)", + "Pixmap": _icon("save"), + } + + def Activated(self): + doc = FreeCAD.ActiveDocument + if not doc: + FreeCAD.Console.PrintError("No active document\n") + return + + obj = get_tracked_object(doc) + + # If not tracked, just do a regular FreeCAD save + if not obj: + if doc.FileName: + doc.save() + FreeCAD.Console.PrintMessage(f"Saved: {doc.FileName}\n") + else: + FreeCADGui.runCommand("Std_SaveAs", 0) + return + + part_number = obj.SiloPartNumber + + # Check if document has unsaved changes + gui_doc = FreeCADGui.getDocument(doc.Name) + is_modified = gui_doc.Modified if gui_doc else True + FreeCAD.Console.PrintMessage( + f"[DEBUG] Modified={is_modified}, FileName={doc.FileName}\n" + ) + + if gui_doc and not is_modified and doc.FileName: + FreeCAD.Console.PrintMessage("No changes to save.\n") + return + + # Collect properties BEFORE saving to avoid dirtying the document + # (accessing Shape properties can trigger recompute) + FreeCAD.Console.PrintMessage("[DEBUG] Collecting properties...\n") + properties = collect_document_properties(doc) + + # Check modified state after collecting properties + is_modified_after_props = gui_doc.Modified if gui_doc else True + FreeCAD.Console.PrintMessage( + f"[DEBUG] After collect_properties: Modified={is_modified_after_props}\n" + ) + + # Save locally + FreeCAD.Console.PrintMessage("[DEBUG] Saving to canonical path...\n") + file_path = _sync.save_to_canonical_path(doc, force_rename=True) + if not file_path: + # Fallback to regular save if canonical path fails + if doc.FileName: + doc.save() + file_path = Path(doc.FileName) + else: + FreeCAD.Console.PrintError("Could not determine save path\n") + return + + # Check modified state after save + is_modified_after_save = gui_doc.Modified if gui_doc else True + FreeCAD.Console.PrintMessage( + f"[DEBUG] After save: Modified={is_modified_after_save}\n" + ) + + # Force clear modified flag if save succeeded (needed for assemblies) + if is_modified_after_save and gui_doc: + FreeCAD.Console.PrintMessage( + "[DEBUG] Attempting to clear Modified flag...\n" + ) + try: + gui_doc.Modified = False + FreeCAD.Console.PrintMessage( + f"[DEBUG] After force clear: Modified={gui_doc.Modified}\n" + ) + except Exception as e: + FreeCAD.Console.PrintMessage(f"[DEBUG] Could not clear Modified: {e}\n") + + FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n") + + # Try to upload to MinIO + try: + result = _client._upload_file( + part_number, str(file_path), properties, "Auto-save" + ) + + new_rev = result["revision_number"] + FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n") + + # Check modified state after upload + is_modified_after_upload = gui_doc.Modified if gui_doc else True + FreeCAD.Console.PrintMessage( + f"[DEBUG] After upload: Modified={is_modified_after_upload}\n" + ) + + except Exception as e: + FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n") + FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n") + + def IsActive(self): + return FreeCAD.ActiveDocument is not None + + +class Silo_Commit: + """Save as new revision with comment.""" + + def GetResources(self): + return { + "MenuText": "Commit", + "ToolTip": "Save as new revision with comment (Ctrl+Shift+S)", + "Pixmap": _icon("commit"), + } + + def Activated(self): + from PySide import QtGui + + doc = FreeCAD.ActiveDocument + if not doc: + FreeCAD.Console.PrintError("No active document\n") + return + + obj = get_tracked_object(doc) + if not obj: + FreeCAD.Console.PrintError( + "No tracked object. Use 'New' to register first.\n" + ) + return + + part_number = obj.SiloPartNumber + + comment, ok = QtGui.QInputDialog.getText(None, "Commit", "Revision comment:") + if not ok: + return + + # Collect properties BEFORE saving to avoid dirtying the document + properties = collect_document_properties(doc) + + try: + file_path = _sync.save_to_canonical_path(doc, force_rename=True) + if not file_path: + return + + result = _client._upload_file( + part_number, str(file_path), properties, comment + ) + + new_rev = result["revision_number"] + FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n") + + except Exception as e: + FreeCAD.Console.PrintError(f"Commit failed: {e}\n") + + def IsActive(self): + return FreeCAD.ActiveDocument is not None + + +class Silo_Pull: + """Download from MinIO / sync from database.""" + + def GetResources(self): + return { + "MenuText": "Pull", + "ToolTip": "Download latest from MinIO or create from database", + "Pixmap": _icon("pull"), + } + + def Activated(self): + from PySide import QtGui + + doc = FreeCAD.ActiveDocument + part_number = None + + if doc: + obj = get_tracked_object(doc) + if obj: + part_number = obj.SiloPartNumber + + if not part_number: + part_number, ok = QtGui.QInputDialog.getText(None, "Pull", "Part number:") + if not ok or not part_number: + return + part_number = part_number.strip().upper() + + # Check if local file exists + existing_local = find_file_by_part_number(part_number) + + # Check if file exists in MinIO + has_file, rev_num = _client.has_file(part_number) + + if has_file: + # File exists in MinIO + if existing_local: + # Local file exists - ask before overwriting + reply = QtGui.QMessageBox.question( + None, + "Pull", + f"Download revision {rev_num} and overwrite local file?", + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + ) + if reply != QtGui.QMessageBox.Yes: + return + + # Download from MinIO (creates local file automatically) + downloaded = _sync.download_file(part_number) + if downloaded: + FreeCAD.Console.PrintMessage(f"Downloaded: {downloaded}\n") + # Automatically open the downloaded file + FreeCAD.openDocument(str(downloaded)) + else: + QtGui.QMessageBox.warning(None, "Pull", "Download failed") + else: + # No file in MinIO - create from database + if existing_local: + # Local file already exists, just open it + FreeCAD.Console.PrintMessage( + f"Opening existing local file: {existing_local}\n" + ) + FreeCAD.openDocument(str(existing_local)) + else: + # No local file and no MinIO file - create new from DB + try: + item = _client.get_item(part_number) + new_doc = _sync.create_document_for_item(item, save=True) + if new_doc: + FreeCAD.Console.PrintMessage( + f"Created local file for {part_number}\n" + ) + else: + QtGui.QMessageBox.warning( + None, "Pull", f"Failed to create document for {part_number}" + ) + except Exception as e: + QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}") + + def IsActive(self): + return True + + +class Silo_Push: + """Upload local files to MinIO.""" + + def GetResources(self): + return { + "MenuText": "Push", + "ToolTip": "Upload local files that aren't in MinIO", + "Pixmap": _icon("push"), + } + + def Activated(self): + from PySide import QtGui + + # Find unuploaded files + local_files = search_local_files() + unuploaded = [] + + for lf in local_files: + pn = lf["part_number"] + try: + _client.get_item(pn) # Check if in DB + has_file, _ = _client.has_file(pn) + if not has_file: + unuploaded.append(lf) + except Exception: + pass # Not in DB, skip + + if not unuploaded: + QtGui.QMessageBox.information( + None, "Push", "All local files are already uploaded." + ) + return + + msg = f"Found {len(unuploaded)} files to upload:\n\n" + for item in unuploaded[:10]: + msg += f" {item['part_number']}\n" + if len(unuploaded) > 10: + msg += f" ... and {len(unuploaded) - 10} more\n" + msg += "\nUpload?" + + reply = QtGui.QMessageBox.question( + None, "Push", msg, QtGui.QMessageBox.Yes | QtGui.QMessageBox.No + ) + if reply != QtGui.QMessageBox.Yes: + return + + uploaded = 0 + for item in unuploaded: + result = _sync.upload_file( + item["part_number"], item["path"], "Synced from local" + ) + if result: + uploaded += 1 + + QtGui.QMessageBox.information(None, "Push", f"Uploaded {uploaded} files.") + + def IsActive(self): + return True + + +class Silo_Info: + """Show item status and revision history.""" + + def GetResources(self): + return { + "MenuText": "Info", + "ToolTip": "Show item status and revision history", + "Pixmap": _icon("info"), + } + + def Activated(self): + from PySide import QtGui + + doc = FreeCAD.ActiveDocument + if not doc: + FreeCAD.Console.PrintError("No active document\n") + return + + obj = get_tracked_object(doc) + if not obj: + FreeCAD.Console.PrintError("No tracked object\n") + return + + part_number = obj.SiloPartNumber + + try: + item = _client.get_item(part_number) + revisions = _client.get_revisions(part_number) + + msg = f"

{part_number}

" + msg += f"

Type: {item.get('item_type', '-')}

" + msg += f"

Description: {item.get('description', '-')}

" + msg += f"

Current Revision: {item.get('current_revision', 1)}

" + msg += f"

Local Revision: {getattr(obj, 'SiloRevision', '-')}

" + + has_file, _ = _client.has_file(part_number) + msg += f"

File in MinIO: {'Yes' if has_file else 'No'}

" + + msg += "

Revision History

" + msg += "" + for rev in revisions: + file_icon = "✓" if rev.get("file_key") else "-" + comment = rev.get("comment", "") or "-" + date = rev.get("created_at", "")[:10] + msg += f"" + msg += "
RevDateFileComment
{rev['revision_number']}{date}{file_icon}{comment}
" + + dialog = QtGui.QMessageBox() + dialog.setWindowTitle("Item Info") + dialog.setTextFormat(QtGui.Qt.RichText) + dialog.setText(msg) + dialog.exec_() + + except Exception as e: + QtGui.QMessageBox.warning(None, "Info", f"Failed to get info: {e}") + + def IsActive(self): + return FreeCAD.ActiveDocument is not None + + +# Register commands +FreeCADGui.addCommand("Silo_Open", Silo_Open()) +FreeCADGui.addCommand("Silo_New", Silo_New()) +FreeCADGui.addCommand("Silo_Save", Silo_Save()) +FreeCADGui.addCommand("Silo_Commit", Silo_Commit()) +FreeCADGui.addCommand("Silo_Pull", Silo_Pull()) +FreeCADGui.addCommand("Silo_Push", Silo_Push()) +FreeCADGui.addCommand("Silo_Info", Silo_Info()) diff --git a/schemas/kindred-locations.yaml b/schemas/kindred-locations.yaml new file mode 100644 index 0000000..fecfd35 --- /dev/null +++ b/schemas/kindred-locations.yaml @@ -0,0 +1,58 @@ +# Kindred Systems Location Hierarchy Schema +# +# Format: facility/area/shelf/bin +# +# Examples: +# lab/main/shelf-a/bin-1 +# lab/storage/shelf-b/bin-42 + +location_schema: + name: kindred-lab + version: 1 + description: "Kindred Systems lab and storage locations" + + path_separator: "/" + + hierarchy: + - level: 0 + type: facility + description: "Building or site" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["lab", "warehouse", "office"] + + - level: 1 + type: area + description: "Room or zone within facility" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["main", "storage", "assembly", "electronics"] + + - level: 2 + type: shelf + description: "Shelving unit" + name_pattern: "^shelf-[a-z]$" + examples: ["shelf-a", "shelf-b", "shelf-c"] + + - level: 3 + type: bin + description: "Individual container or bin" + name_pattern: "^bin-[0-9]{1,3}$" + examples: ["bin-1", "bin-42", "bin-100"] + + properties: + facility: + - name: address + type: text + required: false + area: + - name: climate_controlled + type: boolean + default: false + shelf: + - name: max_weight_kg + type: number + required: false + bin: + - name: bin_size + type: enum + values: [small, medium, large] + default: medium diff --git a/schemas/kindred-rd.yaml b/schemas/kindred-rd.yaml new file mode 100644 index 0000000..add620d --- /dev/null +++ b/schemas/kindred-rd.yaml @@ -0,0 +1,857 @@ +# Kindred Systems R&D Part Numbering Schema +# +# Format: XXXXX-CCC-NNNN +# XXXXX = Project code (5 alphanumeric chars) +# CCC = Category/subcategory code (e.g., F01, R27) +# NNNN = Sequence (4 alphanumeric, scoped per category) +# +# Examples: +# CS100-F01-0001 (Current Sensor, Screws/Bolts, seq 1) +# 3DX15-R27-0001 (3D Printer Extruder, Servo Motor, seq 1) +# 3DX10-S05-0002 (Extrusion Screw Unit, T-Slot Extrusion, seq 2) +# +# Note: Documents and drawings share part numbers with the items they describe +# and are managed as attachments/revisions rather than separate items. + +schema: + name: kindred-rd + version: 2 + description: "Kindred Systems R&D hierarchical part numbering" + + separator: "-" + + uniqueness: + scope: global + case_sensitive: false + + segments: + # Project identifier (5 alphanumeric characters) + - name: project + type: string + length: 5 + case: upper + description: "5-character project identifier" + validation: + pattern: "^[A-Z0-9]{5}$" + message: "Project code must be exactly 5 alphanumeric characters" + required: true + + # Category/subcategory code + - name: category + type: enum + description: "Category and subcategory code" + required: true + values: + # F - Fasteners + F01: "Screws and Bolts" + F02: "Threaded Rods" + F03: "Eyebolts" + F04: "U-Bolts" + F05: "Nuts" + F06: "Washers" + F07: "Shims" + F08: "Inserts" + F09: "Spacers" + F10: "Pins" + F11: "Anchors" + F12: "Nails" + F13: "Rivets" + F14: "Staples" + F15: "Key Stock" + F16: "Retaining Rings" + F17: "Cable Ties" + F18: "Hook and Loop" + + # C - Fluid Fittings + C01: "Full Couplings" + C02: "Half Couplings" + C03: "Reducers" + C04: "Elbows" + C05: "Tees" + C06: "Crosses" + C07: "Unions" + C08: "Adapters" + C09: "Plugs and Caps" + C10: "Nipples" + C11: "Flanges" + C12: "Valves" + C13: "Quick Disconnects" + C14: "Hose Barbs" + C15: "Compression Fittings" + C16: "Tubing" + C17: "Hoses" + + # R - Motion Components + R01: "Ball Bearings" + R02: "Roller Bearings" + R03: "Sleeve Bearings" + R04: "Thrust Bearings" + R05: "Linear Bearings" + R06: "Spur Gears" + R07: "Helical Gears" + R08: "Bevel Gears" + R09: "Worm Gears" + R10: "Rack and Pinion" + R11: "Sprockets" + R12: "Timing Pulleys" + R13: "V-Belt Pulleys" + R14: "Idler Pulleys" + R15: "Wheels" + R16: "Casters" + R17: "Shaft Couplings" + R18: "Clutches" + R19: "Brakes" + R20: "Lead Screws" + R21: "Ball Screws" + R22: "Linear Rails" + R23: "Linear Actuators" + R24: "Brushed DC Motor" + R25: "Brushless DC Motor" + R26: "Stepper Motor" + R27: "Servo Motor" + R28: "AC Induction Motor" + R29: "Gear Motor" + R30: "Motor Driver" + R31: "Motor Controller" + R32: "Encoder" + R33: "Pneumatic Cylinder" + R34: "Pneumatic Actuator" + R35: "Pneumatic Valve" + R36: "Pneumatic Regulator" + R37: "Pneumatic FRL Unit" + R38: "Air Compressor" + R39: "Vacuum Pump" + R40: "Hydraulic Cylinder" + R41: "Hydraulic Pump" + R42: "Hydraulic Motor" + R43: "Hydraulic Valve" + R44: "Hydraulic Accumulator" + + # S - Structural Materials + S01: "Square Tube" + S02: "Round Tube" + S03: "Rectangular Tube" + S04: "I-Beam" + S05: "T-Slotted Extrusion" + S06: "Angle" + S07: "Channel" + S08: "Flat Bar" + S09: "Round Bar" + S10: "Square Bar" + S11: "Hex Bar" + S12: "Sheet Metal" + S13: "Plate" + S14: "Expanded Metal" + S15: "Perforated Sheet" + S16: "Wire Mesh" + S17: "Grating" + + # E - Electrical Components + E01: "Wire" + E02: "Cable" + E03: "Connectors" + E04: "Terminals" + E05: "Circuit Breakers" + E06: "Fuses" + E07: "Relays" + E08: "Contactors" + E09: "Switches" + E10: "Buttons" + E11: "Indicators" + E12: "Resistors" + E13: "Capacitors" + E14: "Inductors" + E15: "Transformers" + E16: "Diodes" + E17: "Transistors" + E18: "ICs" + E19: "Microcontrollers" + E20: "Sensors" + E21: "Displays" + E22: "Power Supplies" + E23: "Batteries" + E24: "PCB" + E25: "Enclosures" + E26: "Heat Sinks" + E27: "Fans" + + # M - Mechanical Components + M01: "Compression Springs" + M02: "Extension Springs" + M03: "Torsion Springs" + M04: "Gas Springs" + M05: "Dampers" + M06: "Shock Absorbers" + M07: "Vibration Mounts" + M08: "Hinges" + M09: "Latches" + M10: "Handles" + M11: "Knobs" + M12: "Levers" + M13: "Linkages" + M14: "Cams" + M15: "Bellows" + M16: "Seals" + M17: "O-Rings" + M18: "Gaskets" + + # T - Tooling and Fixtures + T01: "Jigs" + T02: "Fixtures" + T03: "Molds" + T04: "Dies" + T05: "Gauges" + T06: "Templates" + T07: "Work Holding" + T08: "Test Fixtures" + + # A - Assemblies + A01: "Mechanical Assembly" + A02: "Electrical Assembly" + A03: "Electromechanical Assembly" + A04: "Subassembly" + A05: "Cable Assembly" + A06: "Pneumatic Assembly" + A07: "Hydraulic Assembly" + + # P - Purchased/Off-the-Shelf + P01: "Purchased Mechanical" + P02: "Purchased Electrical" + P03: "Purchased Assembly" + P04: "Raw Material" + P05: "Consumables" + + # X - Custom Fabricated Parts + X01: "Machined Part" + X02: "Sheet Metal Part" + X03: "3D Printed Part" + X04: "Cast Part" + X05: "Molded Part" + X06: "Welded Fabrication" + X07: "Laser Cut Part" + X08: "Waterjet Cut Part" + + # Sequence number (alphanumeric, scoped per category) + - name: sequence + type: serial + length: 4 + padding: "0" + start: 1 + description: "Sequential identifier (alphanumeric, per category)" + scope: "{category}" + + format: "{project}-{category}-{sequence}" + + examples: + - "CS100-F01-0001" + - "3DX15-R27-0001" + - "3DX10-S05-0002" + - "CS100-E20-0001" + - "3DX15-A01-0001" + + # Property schemas per category + property_schemas: + version: 2 + + # Global defaults (apply to all categories) + defaults: + manufacturer: + type: string + default: "" + description: "Manufacturer name" + manufacturer_pn: + type: string + default: "" + description: "Manufacturer part number" + supplier: + type: string + default: "" + description: "Primary supplier/distributor" + supplier_pn: + type: string + default: "" + description: "Supplier part number or SKU" + sourcing_link: + type: string + default: "" + description: "URL to purchase or source the item" + standard_cost: + type: number + default: null + unit: "USD" + description: "Standard cost per unit in USD" + lead_time_days: + type: number + default: null + description: "Typical lead time in days" + minimum_order_qty: + type: number + default: 1 + description: "Minimum order quantity" + lifecycle_status: + type: string + default: "active" + description: "Status: active, deprecated, obsolete, prototype" + rohs_compliant: + type: boolean + default: null + description: "RoHS compliance status" + country_of_origin: + type: string + default: "" + description: "Country of origin" + notes: + type: string + default: "" + description: "Additional notes" + + # Category-specific properties + categories: + # Fasteners (F01-F18) + F: + material: + type: string + default: "" + description: "Material (e.g., Steel, 316SS, Brass, Nylon)" + finish: + type: string + default: "" + description: "Surface finish (e.g., Zinc, Black Oxide, Plain)" + thread_size: + type: string + default: "" + description: "Thread size (e.g., M3x0.5, 1/4-20, M4)" + thread_pitch: + type: string + default: "" + description: "Thread pitch (coarse, fine, or specific pitch)" + length: + type: number + default: null + unit: "mm" + description: "Length in mm" + head_type: + type: string + default: "" + description: "Head type (e.g., Socket, Hex, Pan, Flat, Button)" + drive_type: + type: string + default: "" + description: "Drive type (e.g., Hex, Phillips, Torx, Slotted)" + strength_grade: + type: string + default: "" + description: "Strength grade (e.g., 8.8, 10.9, 12.9, A2-70)" + torque_spec: + type: number + default: null + unit: "Nm" + description: "Recommended torque in Newton-meters" + + # Fluid Fittings (C01-C17) + C: + material: + type: string + default: "" + description: "Material (e.g., Brass, 316SS, PVC, PTFE)" + connection_type: + type: string + default: "" + description: "Connection type (NPT, BSP, Push-to-Connect, Compression)" + size_1: + type: string + default: "" + description: "Primary port size" + size_2: + type: string + default: "" + description: "Secondary port size (for reducers/adapters)" + pressure_rating: + type: number + default: null + unit: "psi" + description: "Maximum pressure rating in PSI" + temperature_min: + type: number + default: null + unit: "C" + description: "Minimum temperature rating in Celsius" + temperature_max: + type: number + default: null + unit: "C" + description: "Maximum temperature rating in Celsius" + media_compatibility: + type: string + default: "" + description: "Compatible media (air, water, oil, chemicals)" + seal_material: + type: string + default: "" + description: "Seal/O-ring material" + + # Motion Components (R01-R44) + R: + # Common motion properties + load_capacity: + type: number + default: null + unit: "N" + description: "Load capacity in Newtons" + speed_rating: + type: number + default: null + unit: "rpm" + description: "Maximum speed in RPM" + # Motor/actuator properties + power_rating: + type: number + default: null + unit: "W" + description: "Power rating in watts" + voltage_nominal: + type: number + default: null + unit: "V" + description: "Nominal operating voltage" + voltage_min: + type: number + default: null + unit: "V" + description: "Minimum operating voltage" + voltage_max: + type: number + default: null + unit: "V" + description: "Maximum operating voltage" + current_nominal: + type: number + default: null + unit: "A" + description: "Nominal operating current" + current_stall: + type: number + default: null + unit: "A" + description: "Stall current" + torque_continuous: + type: number + default: null + unit: "Nm" + description: "Continuous torque in Newton-meters" + torque_peak: + type: number + default: null + unit: "Nm" + description: "Peak torque in Newton-meters" + steps_per_rev: + type: number + default: null + description: "Steps per revolution (for steppers)" + encoder_resolution: + type: number + default: null + unit: "ppr" + description: "Encoder resolution in pulses per revolution" + gear_ratio: + type: string + default: "" + description: "Gear ratio (e.g., 10:1, 50:1)" + # Bearing/linear properties + bore_diameter: + type: number + default: null + unit: "mm" + description: "Bore/shaft diameter in mm" + outer_diameter: + type: number + default: null + unit: "mm" + description: "Outer diameter in mm" + width: + type: number + default: null + unit: "mm" + description: "Width/length in mm" + travel: + type: number + default: null + unit: "mm" + description: "Travel distance in mm" + accuracy: + type: string + default: "" + description: "Positioning accuracy/repeatability" + # Pneumatic/hydraulic properties + port_size: + type: string + default: "" + description: "Port size" + stroke: + type: number + default: null + unit: "mm" + description: "Stroke length in mm" + bore: + type: number + default: null + unit: "mm" + description: "Cylinder bore diameter" + operating_pressure: + type: number + default: null + unit: "psi" + description: "Operating pressure in PSI" + + # Structural Materials (S01-S17) + S: + material: + type: string + default: "" + description: "Material (e.g., 6061-T6 Aluminum, 1018 Steel, 304SS)" + material_spec: + type: string + default: "" + description: "Material specification (ASTM, AISI, etc.)" + profile: + type: string + default: "" + description: "Profile description" + dimension_a: + type: number + default: null + unit: "mm" + description: "Primary dimension (width/OD) in mm" + dimension_b: + type: number + default: null + unit: "mm" + description: "Secondary dimension (height/ID) in mm" + wall_thickness: + type: number + default: null + unit: "mm" + description: "Wall/material thickness in mm" + length: + type: number + default: null + unit: "mm" + description: "Length in mm" + weight_per_length: + type: number + default: null + unit: "kg/m" + description: "Weight per meter in kg/m" + finish: + type: string + default: "" + description: "Surface finish (mill, anodized, powder coat)" + temper: + type: string + default: "" + description: "Temper/heat treatment condition" + + # Electrical Components (E01-E27) + E: + # Common electrical properties + voltage_rating: + type: number + default: null + unit: "V" + description: "Voltage rating" + current_rating: + type: number + default: null + unit: "A" + description: "Current rating" + power_rating: + type: number + default: null + unit: "W" + description: "Power rating/dissipation" + # Passive component properties + value: + type: string + default: "" + description: "Component value (10k, 100uF, 10mH)" + tolerance: + type: string + default: "" + description: "Tolerance (e.g., 1%, 5%, 10%)" + temperature_coefficient: + type: string + default: "" + description: "Temperature coefficient (e.g., X7R, C0G, NPO)" + # Package/form factor + package: + type: string + default: "" + description: "Package type (e.g., 0603, 0805, DIP-8, TO-220)" + mounting: + type: string + default: "" + description: "Mounting type (SMD, Through-Hole, Panel)" + # Connector properties + pin_count: + type: number + default: null + description: "Number of pins/contacts" + pitch: + type: number + default: null + unit: "mm" + description: "Pin pitch in mm" + wire_gauge: + type: string + default: "" + description: "Wire gauge (AWG or mm2)" + connector_type: + type: string + default: "" + description: "Connector family (JST, Molex, TE, etc.)" + # Semiconductor properties + frequency: + type: number + default: null + unit: "MHz" + description: "Operating frequency in MHz" + memory: + type: string + default: "" + description: "Memory size (for MCUs)" + interface: + type: string + default: "" + description: "Communication interfaces (SPI, I2C, UART, etc.)" + # Sensor properties + measurement_range: + type: string + default: "" + description: "Measurement range" + sensitivity: + type: string + default: "" + description: "Sensitivity/resolution" + output_type: + type: string + default: "" + description: "Output type (analog, digital, PWM, etc.)" + # Power supply properties + input_voltage: + type: string + default: "" + description: "Input voltage range" + output_voltage: + type: string + default: "" + description: "Output voltage" + efficiency: + type: number + default: null + unit: "%" + description: "Efficiency percentage" + + # Mechanical Components (M01-M18) + M: + material: + type: string + default: "" + description: "Material" + # Spring properties + spring_rate: + type: number + default: null + unit: "N/mm" + description: "Spring rate in N/mm" + free_length: + type: number + default: null + unit: "mm" + description: "Free length in mm" + solid_length: + type: number + default: null + unit: "mm" + description: "Solid/compressed length in mm" + max_load: + type: number + default: null + unit: "N" + description: "Maximum load in Newtons" + # Damper/shock properties + travel: + type: number + default: null + unit: "mm" + description: "Travel distance in mm" + force_extended: + type: number + default: null + unit: "N" + description: "Force when extended (gas springs)" + damping_rate: + type: string + default: "" + description: "Damping rate/coefficient" + # Seal properties + inner_diameter: + type: number + default: null + unit: "mm" + description: "Inner diameter in mm" + outer_diameter: + type: number + default: null + unit: "mm" + description: "Outer diameter in mm" + cross_section: + type: number + default: null + unit: "mm" + description: "Cross-section diameter/thickness in mm" + hardness: + type: string + default: "" + description: "Durometer hardness (e.g., 70A)" + temperature_range: + type: string + default: "" + description: "Operating temperature range" + + # Tooling and Fixtures (T01-T08) + T: + material: + type: string + default: "" + description: "Primary material" + tolerance: + type: string + default: "" + description: "Tolerance specification" + surface_finish: + type: string + default: "" + description: "Required surface finish" + hardness: + type: string + default: "" + description: "Hardness requirement" + associated_part: + type: string + default: "" + description: "Part number this tooling is used with" + machine: + type: string + default: "" + description: "Machine/equipment this tooling is for" + cycle_life: + type: number + default: null + description: "Expected cycle life" + + # Assemblies (A01-A07) + A: + weight: + type: number + default: null + unit: "kg" + description: "Total weight in kg" + dimensions: + type: string + default: "" + description: "Overall dimensions (LxWxH)" + component_count: + type: number + default: null + description: "Number of unique components" + assembly_time: + type: number + default: null + unit: "min" + description: "Estimated assembly time in minutes" + test_procedure: + type: string + default: "" + description: "Reference to test procedure" + voltage_rating: + type: number + default: null + unit: "V" + description: "System voltage rating (for electrical assemblies)" + current_rating: + type: number + default: null + unit: "A" + description: "System current rating (for electrical assemblies)" + ip_rating: + type: string + default: "" + description: "Ingress protection rating (e.g., IP65)" + + # Purchased/Off-the-Shelf (P01-P05) + P: + material: + type: string + default: "" + description: "Material (for raw materials)" + form: + type: string + default: "" + description: "Form/shape (sheet, rod, pellets, etc.)" + grade: + type: string + default: "" + description: "Material grade or specification" + quantity_per_unit: + type: number + default: 1 + description: "Quantity per purchase unit" + unit_of_measure: + type: string + default: "ea" + description: "Unit of measure (ea, kg, m, L, etc.)" + shelf_life: + type: number + default: null + unit: "days" + description: "Shelf life in days (for consumables)" + + # Custom Fabricated Parts (X01-X08) + X: + material: + type: string + default: "" + description: "Primary material" + material_spec: + type: string + default: "" + description: "Material specification" + finish: + type: string + default: "" + description: "Surface finish requirement" + critical_dimensions: + type: string + default: "" + description: "Critical dimensions and tolerances" + weight: + type: number + default: null + unit: "kg" + description: "Weight in kg" + process: + type: string + default: "" + description: "Primary manufacturing process" + secondary_operations: + type: string + default: "" + description: "Secondary operations (heat treat, plating, etc.)" + drawing_rev: + type: string + default: "" + description: "Current drawing revision" + inspection_requirements: + type: string + default: "" + description: "Inspection/QC requirements" diff --git a/scripts/init-db.sh b/scripts/init-db.sh new file mode 100755 index 0000000..d31027f --- /dev/null +++ b/scripts/init-db.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Initialize the Silo database +# This script waits for PostgreSQL to be ready and runs migrations + +set -e + +# Configuration +DB_HOST="${SILO_DB_HOST:-localhost}" +DB_PORT="${SILO_DB_PORT:-5432}" +DB_NAME="${SILO_DB_NAME:-silo}" +DB_USER="${SILO_DB_USER:-silo}" +DB_PASSWORD="${SILO_DB_PASSWORD:-silodev}" + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL at $DB_HOST:$DB_PORT..." +until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do + echo "PostgreSQL is unavailable - sleeping" + sleep 2 +done + +echo "PostgreSQL is ready!" + +# Run migrations +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATIONS_DIR="${SCRIPT_DIR}/../migrations" + +echo "Running migrations from $MIGRATIONS_DIR..." + +for migration in "$MIGRATIONS_DIR"/*.sql; do + if [ -f "$migration" ]; then + echo "Applying $(basename "$migration")..." + PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$migration" + fi +done + +echo "Database initialization complete!" diff --git a/silo-export-2026-01-24.csv b/silo-export-2026-01-24.csv new file mode 100644 index 0000000..dc95dfe --- /dev/null +++ b/silo-export-2026-01-24.csv @@ -0,0 +1,17 @@ +part_number,item_type,description,current_revision,created_at,updated_at,project,category,objects,test +3DX15-A01-0002,assembly,FILAMENT WINDING UNIT ASSY,2,2026-01-21T19:16:47Z,2026-01-24T07:13:35Z,3DX15,A01,,true +3DX15-A02-0001,assembly,A-AXIS SASSY,2,2026-01-21T19:15:02Z,2026-01-24T20:20:51Z,3DX15,A02,"{""3DX15-A02-0001"":{""_label"":""3DX15-A02-0001"",""_object_type"":""Assembly::AssemblyObject"",""bounding_box"":{""x_length"":350.74028853269783,""y_length"":303.7500000000002,""z_length"":55.99999999999999},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":475349.7867190071},""Joints"":{""_label"":""Joints"",""_object_type"":""Assembly::JointGroup""}}", +3DX15-A03-0001,assembly,3DX15 CONTROLS SASSY,2,2026-01-24T19:59:57Z,2026-01-24T20:20:50Z,3DX15,A03,"{""3DX15-A03-0001"":{""_label"":""3DX15-A03-0001"",""_object_type"":""Assembly::AssemblyObject"",""bounding_box"":{""x_length"":115,""y_length"":92.70000000000009,""z_length"":117.10000000000052},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":519223.97447591537},""Joints"":{""_label"":""Joints"",""_object_type"":""Assembly::JointGroup""}}", +3DX15-F10-0001,part,PE CLEVIS,2,2026-01-23T05:37:35Z,2026-01-24T20:20:52Z,3DX15,F10,"{""3DX15-F10-0001"":{""_label"":""3DX15-F10-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":31,""y_length"":12,""z_length"":16},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":2493.8169885872107},""PE_CLEVIS"":{""_label"":""PE_CLEVIS"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""W0950120020"":{""_label"":""W0950120020"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":31,""y_length"":12,""z_length"":16},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":2493.8169885872107}}", +3DX15-M09-0001,part,PE CRANKSHAFT,2,2026-01-21T23:50:03Z,2026-01-24T20:20:51Z,3DX15,M09,"{""3DX15-M09-0001"":{""_label"":""3DX15-M09-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":53.00000000000006,""y_length"":8.000000000003187,""z_length"":30.4416709927845},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3924.070570240609},""DatumPlane"":{""_label"":""DatumPlane"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":0,""y_length"":2e+100,""z_length"":2e+100},""placement"":{""position"":{""x"":2,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":5.333333333333334e+100},""DatumPlane001"":{""_label"":""DatumPlane001"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":0,""y_length"":2e+100,""z_length"":2e+100},""placement"":{""position"":{""x"":5.5,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":1.4666666666666667e+101},""DatumPlane002"":{""_label"":""DatumPlane002"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":0,""y_length"":2e+100,""z_length"":2e+100},""placement"":{""position"":{""x"":29.499999999999996,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":7.866666666666666e+101},""DatumPlane003"":{""_label"":""DatumPlane003"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":0,""y_length"":2e+100,""z_length"":2e+100},""placement"":{""position"":{""x"":32.999999999999986,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":8.799999999999995e+101},""PE_CRANKSHAFT"":{""_label"":""PE_CRANKSHAFT"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":53.00000000000006,""y_length"":8.000000000003187,""z_length"":30.4416709927845},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3924.070570240609},""Pad"":{""_label"":""Pad"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":2,""y_length"":7.985589954170106,""z_length"":7.992791729637451},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931953,""axis"":{""x"":0.5773502691896258,""y"":0.5773502691896258,""z"":0.5773502691896258}}},""volume"":100.53096491487338},""Pad001"":{""_label"":""Pad001"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":5.5000000000000036,""y_length"":8.000000000000007,""z_length"":30.441670992784434},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931953,""axis"":{""x"":0.5773502691896258,""y"":0.5773502691896258,""z"":0.5773502691896258}}},""volume"":906.460153515902},""Pad002"":{""_label"":""Pad002"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":29.5,""y_length"":8.000000000000014,""z_length"":30.441670992784445},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931953,""axis"":{""x"":0.5773502691896258,""y"":0.5773502691896258,""z"":0.5773502691896258}}},""volume"":2112.831732494383},""Pad003"":{""_label"":""Pad003"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":33,""y_length"":8.00000000000318,""z_length"":30.44167099278448},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931953,""axis"":{""x"":0.5773502691896258,""y"":0.5773502691896258,""z"":0.5773502691896258}}},""volume"":2918.7609210948385},""Pad004"":{""_label"":""Pad004"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":53.00000000000004,""y_length"":8.000000000003187,""z_length"":30.44167099278449},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931953,""axis"":{""x"":0.5773502691896258,""y"":0.5773502691896258,""z"":0.5773502691896258}}},""volume"":3924.0705702406062},""Scale"":{""_label"":""Scale"",""_object_type"":""Part::Scale"",""bounding_box"":{""x_length"":2e+100,""y_length"":2e+100,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":8,""z_length"":8},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":0},""Sketch001"":{""_label"":""Sketch001"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":8,""z_length"":30.44167099278443},""placement"":{""position"":{""x"":2,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":0},""Sketch002"":{""_label"":""Sketch002"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":8,""z_length"":8},""placement"":{""position"":{""x"":5.5,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":0},""Sketch003"":{""_label"":""Sketch003"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":8.000000000003142,""z_length"":30.44167099277861},""placement"":{""position"":{""x"":29.499999999999996,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":0},""Sketch004"":{""_label"":""Sketch004"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":7.999999999988165,""z_length"":7.999999999988165},""placement"":{""position"":{""x"":32.999999999999986,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":0},""ZPlane_Offset_001"":{""_label"":""ZPlane_Offset_001"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":0,""y_length"":2e+100,""z_length"":2e+100},""placement"":{""position"":{""x"":-10,""y"":0,""z"":0},""rotation"":{""angle"":1.5707963267948966,""axis"":{""x"":0,""y"":-1,""z"":0}}},""volume"":2.6666666666666665e+101},""ZPoint_Vtx_001"":{""_label"":""ZPoint_Vtx_001"",""_object_type"":""PartDesign::Point"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":5.5,""y"":-4.000000000000001,""z"":-1e-15},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0}}", +3DX15-R01-0001,part,PRESS-FIT MOUNTED BEARING 8MM SHAFT DIA,2,2026-01-21T23:32:22Z,2026-01-24T20:20:53Z,3DX15,R01,"{""3DX15-R01-0001"":{""_label"":""3DX15-R01-0001"",""_object_type"":""App::Part"",""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""PRESS-FIT_MOUNTED_BEARING_8MM_SHAFT_DIA"":{""_label"":""PRESS-FIT_MOUNTED_BEARING_8MM_SHAFT_DIA"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}}}", +3DX15-R08-0001,part,PE CLEVIS RECIEVER,12,2026-01-23T05:42:39Z,2026-01-24T07:52:02Z,3DX15,R08,"{""3DX15-R08-0001"":{""_label"":""3DX15-R08-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":6},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4256.708187143625},""DatumPlane"":{""_label"":""DatumPlane"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":2e+100,""y_length"":2e+100,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":6},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":1.6e+101},""PE_CLEVIS_RECIEVER"":{""_label"":""PE_CLEVIS_RECIEVER"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":6},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4256.708187143625},""Pad"":{""_label"":""Pad"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":6},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":5062.526702789407},""Pocket"":{""_label"":""Pocket"",""_object_type"":""PartDesign::Pocket"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":6},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4383.942689614011},""Pocket001"":{""_label"":""Pocket001"",""_object_type"":""PartDesign::Pocket"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":6},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4256.708187143625},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":38.12890720570694,""y_length"":34.341191788709715,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""Sketch001"":{""_label"":""Sketch001"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":12,""y_length"":12,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":6},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""Sketch002"":{""_label"":""Sketch002"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":31.145825622994252,""y_length"":27.353126622294162,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":6},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0}}", +3DX15-R08-0002,part,PE CLEVIS RECIEVER CLAMP,2,2026-01-23T05:43:16Z,2026-01-24T20:20:53Z,3DX15,R08,"{""3DX15-R08-0002"":{""_label"":""3DX15-R08-0002"",""_object_type"":""App::Part"",""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""PE_CLEVIS_RECIEVER_CLAMP"":{""_label"":""PE_CLEVIS_RECIEVER_CLAMP"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}}}", +3DX15-R31-0001,part,PE CYLINDER BODY,22,2026-01-21T19:18:03Z,2026-01-24T07:42:48Z,3DX15,R31,"{""1120120050XP_BODY"":{""_label"":""1120120050XP_BODY"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":33,""y_length"":17,""z_length"":17},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":5647.1443393030995},""1120120050XP_BODY001"":{""_label"":""1120120050XP_BODY001"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":33,""y_length"":17,""z_length"":17},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":5481.57192484029},""1120120050XP_BODY002"":{""_label"":""1120120050XP_BODY002"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":8.000000000000007,""y_length"":27.71281292110201,""z_length"":24.000000000000004},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":2638.7713841667046},""1120120050XP_BODY003"":{""_label"":""1120120050XP_BODY003"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":76.4,""y_length"":13.173246759281179,""z_length"":13.270000000000001},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":1925.7158404626887},""3DX15-R31-0001"":{""_label"":""3DX15-R31-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":133,""y_length"":27.71281292110201,""z_length"":24.000000000000004},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":15693.204696135563},""PE_CYLINDER_BODY"":{""_label"":""PE_CYLINDER_BODY"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":133,""y_length"":27.71281292110201,""z_length"":24.000000000000004},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":15693.204696135563}}", +3DX15-R31-0002,part,PE CYLINDER ROD,2,2026-01-21T19:58:40Z,2026-01-24T20:20:53Z,3DX15,R31,"{""1120120050XP_ROD"":{""_label"":""1120120050XP_ROD"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":4,""y_length"":11.547005383792518,""z_length"":10.000000000000002},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":264.5588732505719},""1120120050XP_ROD001"":{""_label"":""1120120050XP_ROD001"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":121,""y_length"":11.91250648917665,""z_length"":12},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4842.339704273057},""3DX15-R31-0002"":{""_label"":""3DX15-R31-0002"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":121,""y_length"":11.91250648917665,""z_length"":12},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":5106.898577524213},""PE_CYLINDER_ROD"":{""_label"":""PE_CYLINDER_ROD"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":121,""y_length"":11.91250648917665,""z_length"":12},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":5106.898577524213}}", +3DX15-R34-0001,part,ACTUATOR CONTROL MANIFOLD,2,2026-01-24T04:47:56Z,2026-01-24T07:17:18Z,3DX15,R34,"{""3DX15-R34-0001"":{""_label"":""3DX15-R34-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":115,""y_length"":26.000000000000007,""z_length"":61.00000000000008},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":124424.66095063699},""ACTUATOR_CONTROL_MANIFOLD"":{""_label"":""ACTUATOR_CONTROL_MANIFOLD"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""AM-524"":{""_label"":""AM-524"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":115,""y_length"":26.000000000000007,""z_length"":61.00000000000008},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":124424.66095063699}}", +3DX15-R34-0002,part,5-PORT 24VDC CONTROL SOL VALVE,2,2026-01-24T04:57:10Z,2026-01-24T20:22:50Z,3DX15,R34,"{""3DX15-R34-0002"":{""_label"":""3DX15-R34-0002"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":117.1,""y_length"":66.70000000000003,""z_length"":22.000000000000004},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":98699.82944213122},""5-PORT_24VDC_CONTROL_SOL_VALVE"":{""_label"":""5-PORT_24VDC_CONTROL_SOL_VALVE"",""_object_type"":""Part::Feature"",""bounding_box"":{""x_length"":117.1,""y_length"":66.70000000000003,""z_length"":22.000000000000004},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":98699.82944213122}}", +3DX15-S05-0001,part,200MM 2020 EXTR BLK,2,2026-01-24T05:06:21Z,2026-01-24T20:20:52Z,3DX15,S05,"{""200MM_2020_EXTR_BLK"":{""_label"":""200MM_2020_EXTR_BLK"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""2020-4"":{""_label"":""2020-4"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":20.000000009999816,""y_length"":20.00000006000003,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""3DX15-S05-0001"":{""_label"":""3DX15-S05-0001"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":200,""y_length"":20.00000000999981,""z_length"":20.000000060000026},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":36190.7715352345},""Frame_Sketch_Edge1"":{""_label"":""Frame_Sketch_Edge1"",""_object_type"":""PartDesign::FeatureAdditivePython"",""bounding_box"":{""x_length"":200,""y_length"":20.00000000999981,""z_length"":20.000000060000026},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":36190.7715352345},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":200,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":1.5707963267948966,""axis"":{""x"":1,""y"":0,""z"":0}}},""volume"":0},""frame_Sketch_Edge1"":{""_label"":""frame_Sketch_Edge1"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":20.000000009999816,""y_length"":20.00000006000003,""z_length"":200},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":36190.7715352345}}", +3DX15-S05-0002,part,400mm 2020 EXTR BLK,2,2026-01-24T05:06:57Z,2026-01-24T20:20:52Z,3DX15,S05,"{""2020-4"":{""_label"":""2020-4"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":20.000000009999816,""y_length"":20.00000006000003,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""3DX15-S05-0002"":{""_label"":""3DX15-S05-0002"",""_object_type"":""App::Part"",""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""400mm_2020_EXTR_BLK"":{""_label"":""400mm_2020_EXTR_BLK"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""Frame_Sketch_Edge1"":{""_label"":""Frame_Sketch_Edge1"",""_object_type"":""PartDesign::FeatureAdditivePython"",""bounding_box"":{""x_length"":400,""y_length"":20.00000000999981,""z_length"":20.000000060000026},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":2.0943951023931957,""axis"":{""x"":0.5773502691896257,""y"":0.5773502691896257,""z"":0.5773502691896257}}},""volume"":72381.54307046902},""Part"":{""_label"":""Part"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":400,""y_length"":20.00000000999981,""z_length"":20.000000060000026},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":72381.54307046902},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":400,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":1.5707963267948966,""axis"":{""x"":1,""y"":0,""z"":0}}},""volume"":0},""frame_Sketch_Edge1"":{""_label"":""frame_Sketch_Edge1"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":20.000000009999816,""y_length"":20.00000006000003,""z_length"":400},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":72381.54307046902}}", +3DX15-X01-0001,part,A-AXIS CARRIER BED,7,2026-01-21T19:27:05Z,2026-01-24T08:10:02Z,3DX15,X01,"{""3DX15-A02-0001"":{""_label"":""3DX15-A02-0001"",""_object_type"":""Assembly::AssemblyObject"",""bounding_box"":{""x_length"":412.5090381459164,""y_length"":357.2433063250476,""z_length"":20.00000000000005},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":478558.76189795684},""Joints"":{""_label"":""Joints"",""_object_type"":""Assembly::JointGroup""}}", +3DX15-X01-0002,part,CYLINDER MOUNTING BRACKET,4,2026-01-24T07:29:17Z,2026-01-24T08:07:32Z,3DX15,X01,"{""3DX15-X01-0002"":{""_label"":""3DX15-X01-0002"",""_object_type"":""App::Part"",""bounding_box"":{""x_length"":20,""y_length"":13.781875135722904,""z_length"":20},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3427.910652753453},""CYLINDER_MOUNTING_BRACKET"":{""_label"":""CYLINDER_MOUNTING_BRACKET"",""_object_type"":""PartDesign::Body"",""bounding_box"":{""x_length"":20,""y_length"":13.781875135722904,""z_length"":20},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3427.910652753453},""DatumPlane"":{""_label"":""DatumPlane"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":2e+100,""y_length"":2e+100,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":10},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":2.6666666666666665e+101},""Fillet"":{""_label"":""Fillet"",""_object_type"":""PartDesign::Fillet"",""bounding_box"":{""x_length"":20,""y_length"":13.781875135722904,""z_length"":20},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3553.5743588970454},""Pad"":{""_label"":""Pad"",""_object_type"":""PartDesign::Pad"",""bounding_box"":{""x_length"":20,""y_length"":20,""z_length"":20},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":4000},""Pocket"":{""_label"":""Pocket"",""_object_type"":""PartDesign::Pocket"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}}},""Pocket001"":{""_label"":""Pocket001"",""_object_type"":""PartDesign::Pocket"",""bounding_box"":{""x_length"":20,""y_length"":13.781875135722904,""z_length"":20},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":3302.246946609862},""Sketch"":{""_label"":""Sketch"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":20,""y_length"":20,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""Sketch001"":{""_label"":""Sketch001"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":4,""y_length"":4,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":10},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""Sketch002"":{""_label"":""Sketch002"",""_object_type"":""Sketcher::SketchObject"",""bounding_box"":{""x_length"":18,""y_length"":18,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""ZPlane_Mid_001"":{""_label"":""ZPlane_Mid_001"",""_object_type"":""PartDesign::Plane"",""bounding_box"":{""x_length"":2e+100,""y_length"":2e+100,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""ZPoint_Vtx_001"":{""_label"":""ZPoint_Vtx_001"",""_object_type"":""PartDesign::Point"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""ZPoint_Vtx_002"":{""_label"":""ZPoint_Vtx_002"",""_object_type"":""PartDesign::Point"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0},""ZPoint_Vtx_003"":{""_label"":""ZPoint_Vtx_003"",""_object_type"":""PartDesign::Point"",""bounding_box"":{""x_length"":0,""y_length"":0,""z_length"":0},""placement"":{""position"":{""x"":0,""y"":0,""z"":0},""rotation"":{""angle"":0,""axis"":{""x"":0,""y"":0,""z"":1}}},""volume"":0}}", diff --git a/silo-scaffold.tar.gz b/silo-scaffold.tar.gz new file mode 100644 index 0000000..3e8fe19 Binary files /dev/null and b/silo-scaffold.tar.gz differ diff --git a/silo-spec.md b/silo-spec.md new file mode 100644 index 0000000..b81bf67 --- /dev/null +++ b/silo-spec.md @@ -0,0 +1,833 @@ +# Silo: Item Database and Part Management System for FreeCAD + +**Version:** 0.1 Draft +**Date:** January 2026 +**Author:** Kindred Systems LLC + +--- + +## 1. Overview + +Silo is an item database with configurable part number generation, designed for R&D-oriented workflows. It integrates with FreeCAD 1.0+ to provide git-like object management, revision tracking, and physical inventory location management. + +### 1.1 Core Philosophy + +Silo treats **part numbering schemas as configuration, not code**. Multiple numbering schemes can coexist, each defined in YAML. The system is schema-agnostic—it doesn't impose a particular part numbering philosophy (intelligent vs. non-intelligent numbers) but instead provides the machinery to implement whatever scheme the organization requires. + +### 1.2 Key Principles + +- **Items are the atomic unit**: Everything is an item (parts, assemblies, drawings, documents) +- **Schemas are mutable**: Part numbering schemas can evolve, though migration tooling is out of scope for MVP +- **Append-only history**: All parameter changes are recorded; item state is reconstructable at any point in time +- **Configuration over convention**: Hierarchies, relationships, and behaviors are YAML-defined + +--- + +## 2. Architecture + +### 2.1 Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FreeCAD 1.0+ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Silo Workbench (Python) │ │ +│ │ - silo checkout / commit / status / log │ │ +│ │ - Part number generation │ │ +│ │ - Property sync with FreeCAD objects │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Silo Core (CLI/Library) │ +│ - Schema parsing and validation │ +│ - Part number generation engine │ +│ - Revision management │ +│ - Relationship graph │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────┐ +│ PostgreSQL │ │ MinIO │ +│ (psql.kindred.internal)│ │ - .FCStd file storage │ +│ - Item metadata │ │ - Versioned objects │ +│ - Relationships │ │ - Thumbnails │ +│ - Revision history │ │ │ +│ - Location hierarchy │ │ │ +└─────────────────────────┘ └─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Web UI (Browse/Search) │ +│ - Item browser with hierarchy navigation │ +│ - Search and filtering │ +│ - "Open in FreeCAD" links (freecad:// URI handler) │ +│ - BOM viewer │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Technology Stack + +| Component | Technology | Notes | +|-----------|------------|-------| +| Database | PostgreSQL | Existing instance at psql.kindred.internal | +| File Storage | MinIO | S3-compatible, versioning enabled | +| FreeCAD Integration | Python workbench | Macro-style commands | +| CLI | Go or Python | TBD based on complexity | +| Web UI | Go + htmx | Lightweight, minimal JS | + +--- + +## 3. Data Model + +### 3.1 Items + +An **item** is the fundamental entity. Items have: + +- A **part number** (generated according to a schema) +- A **type** (part, assembly, drawing, document, etc.) +- **Properties** (key-value pairs, schema-defined and custom) +- **Relationships** to other items +- **Revisions** (append-only history) +- **Files** (optional, stored in MinIO) +- **Location** (optional physical inventory location) + +### 3.2 Database Schema (Conceptual) + +```sql +-- Part numbering schemas (YAML stored as text, parsed at runtime) +CREATE TABLE schemas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + definition JSONB NOT NULL, -- parsed YAML + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Items (core entity) +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + part_number TEXT UNIQUE NOT NULL, + schema_id UUID REFERENCES schemas(id), + item_type TEXT NOT NULL, -- 'part', 'assembly', 'drawing', etc. + created_at TIMESTAMPTZ DEFAULT now(), + current_revision_id UUID -- points to latest revision +); + +-- Append-only revision history +CREATE TABLE revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID REFERENCES items(id) NOT NULL, + revision_number INTEGER NOT NULL, + properties JSONB NOT NULL, -- all properties at this revision + file_version TEXT, -- MinIO version ID if applicable + created_at TIMESTAMPTZ DEFAULT now(), + created_by TEXT, -- user identifier (future: LDAP DN) + comment TEXT, + UNIQUE(item_id, revision_number) +); + +-- Item relationships (BOM structure) +CREATE TABLE relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_item_id UUID REFERENCES items(id) NOT NULL, + child_item_id UUID REFERENCES items(id) NOT NULL, + relationship_type TEXT NOT NULL, -- 'component', 'alternate', 'reference' + quantity DECIMAL, + reference_designator TEXT, -- e.g., "R1", "C3" for electronics + metadata JSONB, -- assembly-specific relationship config + revision_id UUID REFERENCES revisions(id), -- which revision this applies to + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Location hierarchy (configurable via YAML) +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT UNIQUE NOT NULL, -- e.g., "lab/shelf-a/bin-3" + name TEXT NOT NULL, + parent_id UUID REFERENCES locations(id), + location_type TEXT NOT NULL, -- defined in location schema + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Item inventory (quantity at location) +CREATE TABLE inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID REFERENCES items(id) NOT NULL, + location_id UUID REFERENCES locations(id) NOT NULL, + quantity DECIMAL NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(item_id, location_id) +); + +-- Sequence counters for part number generation +CREATE TABLE sequences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + schema_id UUID REFERENCES schemas(id), + scope TEXT NOT NULL, -- scope key (e.g., project code, type code) + current_value INTEGER NOT NULL DEFAULT 0, + UNIQUE(schema_id, scope) +); +``` + +--- + +## 4. YAML Configuration System + +### 4.1 Part Numbering Schema + +Schemas define how part numbers are generated. Each schema consists of **segments** that are concatenated with a **separator**. + +```yaml +# /etc/silo/schemas/kindred-rd.yaml +schema: + name: kindred-rd + version: 1 + description: "Kindred Systems R&D part numbering" + + # Separator between segments (default: "-") + separator: "-" + + # Uniqueness enforcement + uniqueness: + scope: global # or "per-project", "per-type", "per-schema" + + segments: + - name: project + type: string + length: 5 + description: "Project identifier" + validation: + pattern: "^[A-Z0-9]{5}$" + required: true + + - name: part_type + type: enum + description: "Type of item" + values: + AS: "Assembly" + PT: "Part" + DW: "Drawing" + DC: "Document" + TB: "Tooling/Fixture" + PC: "Purchased Component" + required: true + + - name: sequence + type: serial + length: 4 + padding: "0" # left-pad with zeros + description: "Sequential number" + scope: "{project}-{part_type}" # counter scope (template) + + # Format template (optional, defaults to joining segments with separator) + format: "{project}-{part_type}-{sequence}" + + # Example outputs: + # PROTO-AS-0001 (first assembly in PROTO project) + # PROTO-PT-0001 (first part in PROTO project) + # ALPHA-AS-0001 (first assembly in ALPHA project) +``` + +### 4.2 Segment Types + +| Type | Description | Options | +|------|-------------|---------| +| `string` | Fixed or variable length string | `length`, `min_length`, `max_length`, `pattern`, `case` | +| `enum` | Predefined set of values | `values` (map of code → description) | +| `serial` | Auto-incrementing integer | `length`, `padding`, `start`, `scope` | +| `date` | Date-based segment | `format` (strftime-style) | +| `constant` | Fixed value | `value` | + +### 4.3 Serial Scope Templates + +The `scope` field in serial segments supports template variables referencing other segments: + +```yaml +# Sequence per project +scope: "{project}" + +# Sequence per project AND type (recommended for R&D) +scope: "{project}-{part_type}" + +# Global sequence (no scope) +scope: null +``` + +### 4.4 Alternative Schema Example (Simple Sequential) + +```yaml +# /etc/silo/schemas/simple.yaml +schema: + name: simple + version: 1 + description: "Simple non-intelligent numbering" + + segments: + - name: prefix + type: constant + value: "P" + + - name: sequence + type: serial + length: 6 + padding: "0" + scope: null # global counter + + format: "{prefix}{sequence}" + separator: "" + + # Output: P000001, P000002, ... +``` + +### 4.5 Location Hierarchy Schema + +```yaml +# /etc/silo/schemas/locations.yaml +location_schema: + name: kindred-lab + version: 1 + + hierarchy: + - level: 0 + type: facility + name_pattern: "^[a-z-]+$" + + - level: 1 + type: area + name_pattern: "^[a-z-]+$" + + - level: 2 + type: shelf + name_pattern: "^shelf-[a-z]$" + + - level: 3 + type: bin + name_pattern: "^bin-[0-9]+$" + + # Path format + path_separator: "/" + + # Example paths: + # lab/main-area/shelf-a/bin-1 + # lab/storage/shelf-b/bin-12 +``` + +### 4.6 Assembly Metadata Schema + +Each assembly can define its own relationship tracking behavior: + +```yaml +# Stored in item properties or as a linked document +assembly_config: + # What relationship types this assembly uses + relationship_types: + - component # standard BOM entry + - alternate # interchangeable substitute + - reference # related but not part of BOM + + # Whether to track reference designators + use_reference_designators: true + designator_format: "^[A-Z]+[0-9]+$" # e.g., R1, C3, U12 + + # Revision linking behavior + child_revision_tracking: specific # or "latest" + + # Custom properties for relationships + relationship_properties: + - name: mounting_orientation + type: enum + values: [top, bottom, left, right, front, back] + - name: notes + type: text +``` + +--- + +## 5. FreeCAD Integration + +### 5.1 Workbench Commands + +The Silo workbench provides git-like commands accessible via toolbar, menu, and Python console: + +| Command | Description | +|---------|-------------| +| `silo init` | Initialize Silo tracking for current document | +| `silo status` | Show tracked/untracked objects, modifications | +| `silo checkout ` | Load item from Silo into current document | +| `silo commit` | Save current state as new revision | +| `silo log` | Show revision history | +| `silo diff` | Compare current state to last committed revision | +| `silo register` | Generate part number for selected object(s) | +| `silo link` | Create relationship between objects | +| `silo bom` | Generate BOM from current assembly | + +### 5.2 Property Synchronization + +Silo properties map to FreeCAD custom properties: + +```python +# FreeCAD object properties (synced from Silo) +obj.addProperty("App::PropertyString", "SiloPartNumber", "Silo", "Part number") +obj.addProperty("App::PropertyString", "SiloRevision", "Silo", "Current revision") +obj.addProperty("App::PropertyString", "SiloDescription", "Silo", "Item description") +# ... additional properties as defined in schema +``` + +### 5.3 File Storage Strategy + +FreeCAD `.FCStd` files are ZIP archives. Storage options: + +1. **Whole file storage** (MVP): Store complete .FCStd in MinIO with versioning +2. **Exploded storage** (future): Unpack and store components separately for better diffing + +For MVP, whole file storage is simpler and MinIO versioning handles history. + +### 5.4 Checkout Locking (Future) + +MVP operates as single-user. Future multi-user support will need locking strategy: + +- **Pessimistic locking**: Checkout acquires exclusive lock +- **Optimistic locking**: Allow concurrent edits, handle conflicts on commit + +Recommendation for future: Pessimistic locking for CAD files (merge is impractical). + +--- + +## 6. Web Interface + +### 6.1 Features + +- **Browse**: Navigate item hierarchy (project → assembly → subassembly → part) +- **Search**: Full-text search across part numbers, descriptions, properties +- **View**: Item details, revision history, relationships, location +- **BOM Viewer**: Expandable tree view of assembly structure +- **"Open in FreeCAD"**: Launch FreeCAD with specific item via URI handler + +### 6.2 URI Handler + +Register `silo://` protocol handler: + +``` +silo://open/PROTO-AS-0001 # Open latest revision +silo://open/PROTO-AS-0001?rev=3 # Open specific revision +``` + +### 6.3 Technology + +- **Backend**: Go with standard library HTTP +- **Frontend**: htmx for interactivity, minimal JavaScript +- **Templates**: Go html/template +- **Search**: PostgreSQL full-text search (pg_trgm for fuzzy matching) + +--- + +## 7. Revision Tracking + +### 7.1 Append-Only Model + +Every property change creates a new revision record. The current state is always the latest revision, but any historical state can be reconstructed. + +``` +Item: PROTO-AS-0001 + +Revision 1 (2026-01-15): Initial creation + - description: "Main chassis assembly" + - material: null + - weight: null + +Revision 2 (2026-01-20): Updated properties + - description: "Main chassis assembly" + - material: "6061-T6 Aluminum" + - weight: 2.5 + +Revision 3 (2026-02-01): Design change + - description: "Main chassis assembly v2" + - material: "6061-T6 Aluminum" + - weight: 2.3 +``` + +### 7.2 Revision Creation + +Revisions are created explicitly by user action (not automatic): + +- `silo commit` from FreeCAD +- "Save Revision" button in web UI +- API call with explicit revision flag + +### 7.3 Revision vs. File Version + +- **Revision**: Silo metadata revision (tracked in PostgreSQL) +- **File Version**: MinIO object version (automatic on upload) + +A single Silo revision may span multiple file uploads during editing. Only committed revisions create formal revision records. + +--- + +## 8. Relationships and BOM + +### 8.1 Relationship Types + +| Type | Description | Use Case | +|------|-------------|----------| +| `component` | Part is used in assembly | Standard BOM entry | +| `alternate` | Interchangeable substitute | Alternative sourcing | +| `reference` | Related item, not in BOM | Drawings, specs, tools | + +### 8.2 Reference Designators + +For assemblies that require them (electronics, complex mechanisms): + +```yaml +# Relationship record +parent: PROTO-AS-0001 +child: PROTO-PT-0042 +type: component +quantity: 4 +reference_designators: ["R1", "R2", "R3", "R4"] +``` + +### 8.3 Revision-Specific Relationships + +Relationships can link to specific child revisions or track latest: + +```yaml +# Locked to specific revision +child: PROTO-PT-0042 +child_revision: 3 + +# Always use latest (default for R&D) +child: PROTO-PT-0042 +child_revision: null # means "latest" +``` + +Assembly metadata YAML controls default behavior per assembly. + +--- + +## 9. Physical Inventory + +### 9.1 Location Management + +Locations are hierarchical, defined by YAML schema. Each item can exist at multiple locations with quantities. + +``` +Location: lab/main-area/shelf-a/bin-3 + - PROTO-PT-0001: 15 units + - PROTO-PT-0002: 8 units + +Location: lab/storage/shelf-b/bin-1 + - PROTO-PT-0001: 50 units (spare stock) +``` + +### 9.2 Inventory Operations + +- **Add**: Increase quantity at location +- **Remove**: Decrease quantity at location +- **Move**: Transfer between locations +- **Adjust**: Set absolute quantity (for cycle counts) + +All operations logged for audit trail (future consideration). + +--- + +## 10. Authentication (Future) + +### 10.1 Current State (MVP) + +Single-user, no authentication required. + +### 10.2 Future: LDAPS Integration + +Plan for FreeIPA integration: + +```yaml +# /etc/silo/auth.yaml +auth: + provider: ldap + server: ldaps://ipa.kindred.internal + base_dn: "dc=kindred,dc=internal" + user_dn_template: "uid={username},cn=users,cn=accounts,dc=kindred,dc=internal" + group_base: "cn=groups,cn=accounts,dc=kindred,dc=internal" + + # Role mapping + roles: + admin: + groups: ["silo-admins"] + editor: + groups: ["silo-users", "engineers"] + viewer: + groups: ["silo-viewers"] +``` + +--- + +## 11. API Design (Sketch) + +### 11.1 REST Endpoints + +``` +# Items +GET /api/items # List/search items +POST /api/items # Create item +GET /api/items/{part_number} # Get item details +PUT /api/items/{part_number} # Update item (creates revision) +DELETE /api/items/{part_number} # Archive item + +# Revisions +GET /api/items/{part_number}/revisions +GET /api/items/{part_number}/revisions/{rev} + +# Relationships +GET /api/items/{part_number}/bom +POST /api/items/{part_number}/relationships +DELETE /api/items/{part_number}/relationships/{id} + +# Files +GET /api/items/{part_number}/file +PUT /api/items/{part_number}/file +GET /api/items/{part_number}/file?rev={rev} + +# Schemas +GET /api/schemas +POST /api/schemas +GET /api/schemas/{name} + +# Locations +GET /api/locations +POST /api/locations +GET /api/locations/{path} + +# Inventory +GET /api/inventory/{part_number} +POST /api/inventory/{part_number}/adjust + +# Part number generation +POST /api/generate-part-number + Body: { "schema": "kindred-rd", "project": "PROTO", "part_type": "AS" } + Response: { "part_number": "PROTO-AS-0001" } +``` + +--- + +## 12. MVP Scope + +### 12.1 Included + +- [ ] PostgreSQL database schema +- [ ] YAML schema parser for part numbering +- [ ] Part number generation engine +- [ ] Basic CLI for item CRUD +- [ ] FreeCAD workbench with core commands (checkout, commit, status, register) +- [ ] MinIO integration for file storage +- [ ] Single-level and multi-level BOM support +- [ ] Reference designator tracking +- [ ] Alternate part tracking +- [ ] Revision history (append-only) +- [ ] Location hierarchy (YAML-defined) +- [ ] Basic inventory tracking (quantity at location) +- [ ] Web UI for browsing and search +- [ ] "Open in FreeCAD" URI handler + +### 12.2 Excluded (Future) + +- [ ] Schema migration tooling +- [ ] Multi-user with authentication +- [ ] Checkout locking +- [ ] Approval workflows +- [ ] External system integrations (ERP, purchasing) +- [ ] Exploded file storage with diffing +- [ ] Audit logging +- [ ] Notifications +- [ ] Reporting/analytics + +--- + +## 13. Open Questions + +1. **CLI language**: Go for consistency with web UI, or Python for FreeCAD ecosystem alignment? + +2. **Property schema**: Should item properties be schema-defined (like part numbers) or freeform? Recommendation: Support both—schema defines expected properties, but allow ad-hoc additions. + +3. **Thumbnail generation**: Generate thumbnails from .FCStd on commit? Useful for web UI browsing. + +4. **Search indexing**: PostgreSQL full-text search sufficient, or add dedicated search (Meilisearch, etc.)? + +5. **Offline operation**: Should FreeCAD workbench support offline mode with sync? Adds significant complexity. + +--- + +## 14. References + +### 14.1 Design Influences + +- **CycloneDX BOM specification**: JSON/YAML schema patterns for component identification, relationships, and metadata (https://cyclonedx.org) +- **OpenBOM data model**: Reference-instance separation, flexible property schemas +- **FreeCAD DynamicData workbench**: Custom property patterns in FreeCAD +- **Ansible inventory YAML**: Hierarchical configuration patterns with variable inheritance + +### 14.2 Related Standards + +- **ISO 10303 (STEP)**: Product data representation +- **IPC-2581**: Electronics assembly BOM format +- **Package URL (PURL)**: Standardized component identification + +--- + +## Appendix A: Example YAML Files + +### A.1 Complete Part Numbering Schema + +```yaml +# kindred-rd-schema.yaml +schema: + name: kindred-rd + version: 1 + description: "Kindred Systems R&D part numbering for prototype development" + + separator: "-" + + uniqueness: + scope: global + case_sensitive: false + + segments: + - name: project + type: string + length: 5 + case: upper + description: "5-character project identifier" + validation: + pattern: "^[A-Z0-9]{5}$" + message: "Project code must be exactly 5 alphanumeric characters" + required: true + + - name: part_type + type: enum + description: "Two-character type code" + required: true + values: + AS: "Assembly - multi-part unit" + PT: "Part - single manufactured item" + DW: "Drawing - technical drawing" + DC: "Document - specification, procedure, etc." + TB: "Tooling - jigs, fixtures, molds" + PC: "Purchased - externally sourced component" + EL: "Electrical - wiring, PCB, electronics" + SW: "Software - firmware, configuration" + + - name: sequence + type: serial + length: 4 + padding: "0" + start: 1 + description: "Sequential number within project/type" + scope: "{project}-{part_type}" + + format: "{project}-{part_type}-{sequence}" + + # Validation rules applied to complete part number + validation: + min_length: 14 + max_length: 14 + + # Metadata for UI/documentation + examples: + - "PROTO-AS-0001" + - "ALPHA-PT-0042" + - "BETA1-EL-0003" +``` + +### A.2 Complete Location Schema + +```yaml +# kindred-locations.yaml +location_schema: + name: kindred-lab + version: 1 + description: "Kindred Systems lab and storage locations" + + path_separator: "/" + + hierarchy: + - level: 0 + type: facility + description: "Building or site" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["lab", "warehouse", "office"] + + - level: 1 + type: area + description: "Room or zone within facility" + name_pattern: "^[a-z][a-z0-9-]*$" + examples: ["main-lab", "storage", "assembly"] + + - level: 2 + type: shelf + description: "Shelving unit" + name_pattern: "^shelf-[a-z]$" + examples: ["shelf-a", "shelf-b"] + + - level: 3 + type: bin + description: "Individual container or bin" + name_pattern: "^bin-[0-9]{1,3}$" + examples: ["bin-1", "bin-42", "bin-100"] + + # Properties tracked per location type + properties: + facility: + - name: address + type: text + required: false + area: + - name: climate_controlled + type: boolean + default: false + shelf: + - name: max_weight_kg + type: number + required: false + bin: + - name: bin_size + type: enum + values: [small, medium, large] + default: medium +``` + +### A.3 Assembly Configuration + +```yaml +# Stored as item property or linked document +# Example: assembly PROTO-AS-0001 +assembly_config: + name: "Main Chassis Assembly" + + relationship_types: + - component + - alternate + - reference + + use_reference_designators: false + + child_revision_tracking: latest + + # Assembly-specific BOM properties + relationship_properties: + - name: installation_notes + type: text + - name: torque_spec + type: text + - name: adhesive_required + type: boolean + default: false + + # Validation rules + validation: + require_quantity: true + min_components: 1 +```