update databasing system with minimum API, schema parsing and FreeCAD
integration
12
.env.example
Normal file
@@ -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
|
||||
45
.gitignore
vendored
Normal file
@@ -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
|
||||
68
HolePattern.FCMacro
Normal file
@@ -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()
|
||||
226
Makefile
Normal file
@@ -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"
|
||||
76
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
|
||||
|
||||
313
cmd/silo/main.go
Normal file
@@ -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 <command> [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
|
||||
}
|
||||
126
cmd/silod/main.go
Normal file
@@ -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")
|
||||
}
|
||||
42
config.example.yaml
Normal file
@@ -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"
|
||||
82
deployments/docker-compose.yaml
Normal file
@@ -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
|
||||
833
docs/SPECIFICATION.md
Normal file
@@ -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 <part_number>` | 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
|
||||
```
|
||||
137
docs/STATUS.md
Normal file
@@ -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
|
||||
39
go.mod
Normal file
@@ -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
|
||||
)
|
||||
89
go.sum
Normal file
@@ -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=
|
||||
516
internal/api/csv.go
Normal file
@@ -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]
|
||||
}
|
||||
1076
internal/api/handlers.go
Normal file
56
internal/api/middleware.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
95
internal/api/routes.go
Normal file
@@ -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
|
||||
}
|
||||
501
internal/api/templates/base.html
Normal file
@@ -0,0 +1,501 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .Title}}{{.Title}} - {{end}}Silo</title>
|
||||
<style>
|
||||
/* Catppuccin Mocha Theme */
|
||||
:root {
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ctp-sapphire);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--ctp-sky);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background-color: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-brand h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
color: var(--ctp-subtext1);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-nav a:hover {
|
||||
background-color: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-nav a.active {
|
||||
background-color: var(--ctp-surface1);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--ctp-surface0);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Search and Filters */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-mauve);
|
||||
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--ctp-mauve);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext1);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.part-number {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: var(--ctp-peach);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-type-part {
|
||||
background-color: rgba(137, 180, 250, 0.2);
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.item-type-assembly {
|
||||
background-color: rgba(166, 227, 161, 0.2);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.item-type-document {
|
||||
background-color: rgba(249, 226, 175, 0.2);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.item-type-tooling {
|
||||
background-color: rgba(243, 139, 168, 0.2);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--ctp-surface0);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--ctp-surface0);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ctp-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover {
|
||||
background-color: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background-color: var(--ctp-mauve);
|
||||
color: var(--ctp-crust);
|
||||
border-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--ctp-surface1);
|
||||
border-top-color: var(--ctp-mauve);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<h1>Silo</h1>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
|
||||
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
{{if eq .Page "items"}}
|
||||
{{template "items_content" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_content" .}}
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
{{if eq .Page "items"}}
|
||||
{{template "items_scripts" .}}
|
||||
{{else if eq .Page "schemas"}}
|
||||
{{template "schemas_scripts" .}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
1993
internal/api/templates/items.html
Normal file
399
internal/api/templates/schemas.html
Normal file
@@ -0,0 +1,399 @@
|
||||
{{define "schemas_content"}}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Part Numbering Schemas</h2>
|
||||
</div>
|
||||
|
||||
<div id="schemas-list">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Value Modal -->
|
||||
<div class="modal-overlay" id="add-value-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Add New Value</h3>
|
||||
<button class="modal-close" onclick="closeAddValueModal()">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form id="add-value-form" onsubmit="addValue(event)">
|
||||
<input type="hidden" id="add-schema-name" />
|
||||
<input type="hidden" id="add-segment-name" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
id="add-code"
|
||||
required
|
||||
placeholder="e.g., F19"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
id="add-description"
|
||||
required
|
||||
placeholder="e.g., Clamps"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="closeAddValueModal()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">Add Value</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Value Modal -->
|
||||
<div class="modal-overlay" id="edit-value-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Edit Value</h3>
|
||||
<button class="modal-close" onclick="closeEditValueModal()">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form id="edit-value-form" onsubmit="updateValue(event)">
|
||||
<input type="hidden" id="edit-schema-name" />
|
||||
<input type="hidden" id="edit-segment-name" />
|
||||
<input type="hidden" id="edit-code" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
id="edit-code-display"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
id="edit-description"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="closeEditValueModal()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal-overlay" id="delete-value-modal">
|
||||
<div class="modal" style="max-width: 400px">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Delete Value</h3>
|
||||
<button class="modal-close" onclick="closeDeleteValueModal()">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1.5rem">
|
||||
<p>
|
||||
Are you sure you want to delete
|
||||
<strong id="delete-value-code"></strong>?
|
||||
</p>
|
||||
<p style="color: var(--ctp-red); margin-top: 0.5rem">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="closeDeleteValueModal()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
style="background-color: var(--ctp-red)"
|
||||
onclick="confirmDeleteValue()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}} {{define "schemas_scripts"}}
|
||||
<style>
|
||||
.value-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.value-actions button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.add-value-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
let deleteValueInfo = null;
|
||||
|
||||
async function loadSchemas() {
|
||||
const container = document.getElementById("schemas-list");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/schemas");
|
||||
const schemas = await response.json();
|
||||
|
||||
// Filter out empty schemas
|
||||
const validSchemas = schemas.filter((s) => s.name);
|
||||
|
||||
if (validSchemas.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="empty-state"><h3>No schemas found</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = validSchemas
|
||||
.map(
|
||||
(schema) => `
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h3 style="color: var(--ctp-mauve); margin-bottom: 0.5rem;">${schema.name}</h3>
|
||||
<p style="color: var(--ctp-subtext0); margin-bottom: 1rem;">${schema.description || ""}</p>
|
||||
<p style="margin-bottom: 0.5rem;"><strong>Format:</strong> <code style="background: var(--ctp-surface1); padding: 0.25rem 0.5rem; border-radius: 0.25rem;">${schema.format}</code></p>
|
||||
<p style="margin-bottom: 1rem;"><strong>Version:</strong> ${schema.version}</p>
|
||||
|
||||
${
|
||||
schema.examples && schema.examples.length > 0
|
||||
? `
|
||||
<p style="margin-bottom: 0.5rem;"><strong>Examples:</strong></p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
${schema.examples.map((ex) => `<span class="part-number" style="background: var(--ctp-surface1); padding: 0.25rem 0.75rem; border-radius: 0.25rem;">${ex}</span>`).join("")}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary style="cursor: pointer; color: var(--ctp-sapphire);">View Segments (${schema.segments.length})</summary>
|
||||
${schema.segments
|
||||
.map(
|
||||
(seg) => `
|
||||
<div style="margin-top: 1rem; padding: 1rem; background: var(--ctp-surface0); border-radius: 0.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<h4 style="margin: 0; color: var(--ctp-blue);">${seg.name}</h4>
|
||||
<span class="item-type item-type-part">${seg.type}</span>
|
||||
</div>
|
||||
<p style="color: var(--ctp-subtext0); margin-bottom: 0.5rem;">${seg.description || ""}</p>
|
||||
${seg.type === "enum" && seg.values ? renderEnumValues(schema.name, seg.name, seg.values) : ""}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</details>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="empty-state"><h3>Error loading schemas</h3><p>${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnumValues(schemaName, segmentName, values) {
|
||||
const sorted = Object.entries(values).sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
);
|
||||
return `
|
||||
<div class="table-container" style="margin-top: 0.5rem;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Description</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted
|
||||
.map(
|
||||
([code, desc]) => `
|
||||
<tr>
|
||||
<td><code>${code}</code></td>
|
||||
<td>${desc}</td>
|
||||
<td>
|
||||
<div class="value-actions">
|
||||
<button class="btn btn-secondary" onclick="openEditValueModal('${schemaName}', '${segmentName}', '${code}', '${desc.replace(/'/g, "\\'")}')">Edit</button>
|
||||
<button class="btn btn-secondary" style="background-color: var(--ctp-surface2);" onclick="openDeleteValueModal('${schemaName}', '${segmentName}', '${code}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="btn btn-primary add-value-btn" onclick="openAddValueModal('${schemaName}', '${segmentName}')">+ Add Value</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add Value Modal
|
||||
function openAddValueModal(schemaName, segmentName) {
|
||||
document.getElementById("add-schema-name").value = schemaName;
|
||||
document.getElementById("add-segment-name").value = segmentName;
|
||||
document.getElementById("add-value-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeAddValueModal() {
|
||||
document.getElementById("add-value-modal").classList.remove("active");
|
||||
document.getElementById("add-value-form").reset();
|
||||
}
|
||||
|
||||
async function addValue(event) {
|
||||
event.preventDefault();
|
||||
const schemaName = document.getElementById("add-schema-name").value;
|
||||
const segmentName = document.getElementById("add-segment-name").value;
|
||||
const code = document.getElementById("add-code").value;
|
||||
const description = document.getElementById("add-description").value;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/schemas/${schemaName}/segments/${segmentName}/values`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, description }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.message || error.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
closeAddValueModal();
|
||||
loadSchemas();
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Value Modal
|
||||
function openEditValueModal(schemaName, segmentName, code, description) {
|
||||
document.getElementById("edit-schema-name").value = schemaName;
|
||||
document.getElementById("edit-segment-name").value = segmentName;
|
||||
document.getElementById("edit-code").value = code;
|
||||
document.getElementById("edit-code-display").value = code;
|
||||
document.getElementById("edit-description").value = description;
|
||||
document.getElementById("edit-value-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeEditValueModal() {
|
||||
document.getElementById("edit-value-modal").classList.remove("active");
|
||||
document.getElementById("edit-value-form").reset();
|
||||
}
|
||||
|
||||
async function updateValue(event) {
|
||||
event.preventDefault();
|
||||
const schemaName = document.getElementById("edit-schema-name").value;
|
||||
const segmentName = document.getElementById("edit-segment-name").value;
|
||||
const code = document.getElementById("edit-code").value;
|
||||
const description = document.getElementById("edit-description").value;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/schemas/${schemaName}/segments/${segmentName}/values/${code}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.message || error.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
closeEditValueModal();
|
||||
loadSchemas();
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Value Modal
|
||||
function openDeleteValueModal(schemaName, segmentName, code) {
|
||||
deleteValueInfo = { schemaName, segmentName, code };
|
||||
document.getElementById("delete-value-code").textContent = code;
|
||||
document.getElementById("delete-value-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeDeleteValueModal() {
|
||||
document
|
||||
.getElementById("delete-value-modal")
|
||||
.classList.remove("active");
|
||||
deleteValueInfo = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteValue() {
|
||||
if (!deleteValueInfo) return;
|
||||
|
||||
const { schemaName, segmentName, code } = deleteValueInfo;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/schemas/${schemaName}/segments/${segmentName}/values/${code}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.message || error.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
closeDeleteValueModal();
|
||||
loadSchemas();
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) {
|
||||
overlay.classList.remove("active");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadSchemas();
|
||||
</script>
|
||||
{{end}}
|
||||
92
internal/api/web.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
122
internal/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
90
internal/db/db.go
Normal file
@@ -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
|
||||
}
|
||||
329
internal/db/items.go
Normal file
@@ -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
|
||||
}
|
||||
211
internal/migration/properties.go
Normal file
@@ -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()
|
||||
}
|
||||
180
internal/partnum/generator.go
Normal file
@@ -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
|
||||
}
|
||||
235
internal/schema/schema.go
Normal file
@@ -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
|
||||
}
|
||||
121
internal/storage/storage.go
Normal file
@@ -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)
|
||||
}
|
||||
228
migrations/001_initial.sql
Normal file
@@ -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;
|
||||
35
migrations/002_sequence_by_name.sql
Normal file
@@ -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;
|
||||
23
migrations/003_remove_material.sql
Normal file
@@ -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;
|
||||
18
migrations/004_cad_sync_state.sql
Normal file
@@ -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;
|
||||
30
migrations/005_property_schema_version.sql
Normal file
@@ -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;
|
||||
8
pkg/freecad/Init.py
Normal file
@@ -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
|
||||
109
pkg/freecad/InitGui.py
Normal file
@@ -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 = """<h3>Welcome to Silo Workbench!</h3>
|
||||
<p>For the best experience, set up these keyboard shortcuts:</p>
|
||||
<table style="margin: 10px 0;">
|
||||
<tr><td><b>Ctrl+O</b></td><td> - </td><td>Silo_Open (Search & Open)</td></tr>
|
||||
<tr><td><b>Ctrl+N</b></td><td> - </td><td>Silo_New (Register new item)</td></tr>
|
||||
<tr><td><b>Ctrl+S</b></td><td> - </td><td>Silo_Save (Save & upload)</td></tr>
|
||||
<tr><td><b>Ctrl+Shift+S</b></td><td> - </td><td>Silo_Commit (Save with comment)</td></tr>
|
||||
</table>
|
||||
<p><b>To set shortcuts:</b> Tools > Customize > Keyboard</p>
|
||||
<p style="color: #888;">This message appears once.</p>"""
|
||||
|
||||
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")
|
||||
15
pkg/freecad/package.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
<name>Silo</name>
|
||||
<description>Item database and part management workbench</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
|
||||
<license file="LICENSE">MIT</license>
|
||||
<url type="repository">https://github.com/kindredsystems/silo</url>
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>SiloWorkbench</classname>
|
||||
<subdirectory>./</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
</package>
|
||||
8
pkg/freecad/resources/icons/silo-commit.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Git commit style -->
|
||||
<circle cx="12" cy="12" r="4" fill="#313244" stroke="#a6e3a1"/>
|
||||
<line x1="12" y1="2" x2="12" y2="8" stroke="#cba6f7"/>
|
||||
<line x1="12" y1="16" x2="12" y2="22" stroke="#cba6f7"/>
|
||||
<!-- Checkmark inside -->
|
||||
<polyline points="9.5 12 11 13.5 14.5 10" stroke="#a6e3a1" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
6
pkg/freecad/resources/icons/silo-info.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Info circle -->
|
||||
<circle cx="12" cy="12" r="10" fill="#313244"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
|
||||
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
8
pkg/freecad/resources/icons/silo-new.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Document with plus -->
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="#313244"/>
|
||||
<polyline points="14 2 14 8 20 8" fill="#45475a" stroke="#cba6f7"/>
|
||||
<!-- Plus sign -->
|
||||
<line x1="12" y1="11" x2="12" y2="17" stroke="#a6e3a1" stroke-width="2"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14" stroke="#a6e3a1" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
8
pkg/freecad/resources/icons/silo-open.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Folder open icon -->
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#313244"/>
|
||||
<path d="M2 10h20" stroke="#6c7086"/>
|
||||
<!-- Search magnifier -->
|
||||
<circle cx="17" cy="15" r="3" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="19.5" y1="17.5" x2="22" y2="20" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
7
pkg/freecad/resources/icons/silo-pull.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Cloud -->
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
|
||||
<!-- Download arrow -->
|
||||
<path d="M12 13v5m0 0l-2-2m2 2l2-2" stroke="#89b4fa" stroke-width="2"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13" stroke="#89b4fa" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
7
pkg/freecad/resources/icons/silo-push.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Cloud -->
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
|
||||
<!-- Upload arrow -->
|
||||
<path d="M12 18v-5m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="2"/>
|
||||
<line x1="12" y1="13" x2="12" y2="9" stroke="#a6e3a1" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
8
pkg/freecad/resources/icons/silo-save.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Floppy disk -->
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" fill="#313244"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21" fill="#45475a" stroke="#cba6f7"/>
|
||||
<polyline points="7 3 7 8 15 8" fill="#45475a" stroke="#6c7086"/>
|
||||
<!-- Upload arrow -->
|
||||
<path d="M12 17v-4m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 523 B |
28
pkg/freecad/resources/icons/silo.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- Silo icon - grain silo with database/sync symbolism -->
|
||||
<!-- Uses Catppuccin Mocha colors -->
|
||||
|
||||
<!-- Silo body (cylindrical tower) -->
|
||||
<path d="M16 20 L16 52 Q16 56 32 56 Q48 56 48 52 L48 20"
|
||||
fill="#313244" stroke="#cba6f7" stroke-width="2"/>
|
||||
|
||||
<!-- Silo dome/roof -->
|
||||
<ellipse cx="32" cy="20" rx="16" ry="6" fill="#45475a" stroke="#cba6f7" stroke-width="2"/>
|
||||
<path d="M24 14 Q32 4 40 14" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="32" y1="6" x2="32" y2="14" stroke="#cba6f7" stroke-width="2" stroke-linecap="round"/>
|
||||
|
||||
<!-- Horizontal bands (like database rows / silo rings) -->
|
||||
<ellipse cx="32" cy="28" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
|
||||
<ellipse cx="32" cy="36" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
|
||||
<ellipse cx="32" cy="44" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
|
||||
|
||||
<!-- Base ellipse -->
|
||||
<ellipse cx="32" cy="52" rx="16" ry="4" fill="none" stroke="#cba6f7" stroke-width="2"/>
|
||||
|
||||
<!-- Sync arrows (circular) - represents upload/download -->
|
||||
<g transform="translate(44, 8)">
|
||||
<circle cx="8" cy="8" r="7" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<path d="M5 6 L8 3 L11 6 M8 3 L8 10" stroke="#a6e3a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 10 L8 13 L5 10 M8 13 L8 10" stroke="#a6e3a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1184
pkg/freecad/silo_commands.py
Normal file
58
schemas/kindred-locations.yaml
Normal file
@@ -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
|
||||
857
schemas/kindred-rd.yaml
Normal file
@@ -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"
|
||||
36
scripts/init-db.sh
Executable file
@@ -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!"
|
||||
17
silo-export-2026-01-24.csv
Normal file
BIN
silo-scaffold.tar.gz
Normal file
833
silo-spec.md
Normal file
@@ -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 <part_number>` | 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
|
||||
```
|
||||