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
|
||||||
|
```
|
||||||