update databasing system with minimum API, schema parsing and FreeCAD

integration
This commit is contained in:
Forbes
2026-01-24 15:03:17 -06:00
parent eafdc30f32
commit c327baf36f
51 changed files with 11635 additions and 0 deletions

12
.env.example Normal file
View 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
View 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
View 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
View 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"

View File

@@ -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
View 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
View 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
View 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"

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

File diff suppressed because it is too large Load Diff

View 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
View 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
}

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

File diff suppressed because it is too large Load Diff

View 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()">
&times;
</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()">
&times;
</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()">
&times;
</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
View 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
View 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
View 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
View 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
}

View 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()
}

View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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;

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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!"

File diff suppressed because one or more lines are too long

BIN
silo-scaffold.tar.gz Normal file

Binary file not shown.

833
silo-spec.md Normal file
View 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
```