Add revision control and project tagging migration

This commit is contained in:
Zoe Forbes
2026-01-24 16:27:18 -06:00
parent ed9cef67f9
commit 7f375fec7c
11 changed files with 2941 additions and 229 deletions

487
docs/GAP_ANALYSIS.md Normal file
View File

@@ -0,0 +1,487 @@
# Silo Gap Analysis and Revision Control Roadmap
**Date:** 2026-01-24
**Status:** Analysis Complete
---
## Executive Summary
This document analyzes the current state of the Silo project against its specification, identifies documentation and feature gaps, and outlines a roadmap for enhanced revision control capabilities.
---
## 1. Documentation Gap Analysis
### 1.1 Current Documentation
| Document | Coverage | Status |
|----------|----------|--------|
| `README.md` | Quick start, overview | Partial (50%) |
| `docs/SPECIFICATION.md` | Design specification | Comprehensive (90%) |
| `docs/STATUS.md` | Development progress | Current but incomplete |
| `silo-spec.md` | Duplicate of SPECIFICATION.md | Redundant |
### 1.2 Documentation Gaps (Priority Order)
#### High Priority
| Gap | Impact | Effort |
|-----|--------|--------|
| **API Reference** | Users cannot integrate programmatically | Medium |
| **Deployment Guide** | Cannot deploy to production | Medium |
| **Database Schema Guide** | Migration troubleshooting difficult | Low |
| **Configuration Reference** | config.yaml options undocumented | Low |
#### Medium Priority
| Gap | Impact | Effort |
|-----|--------|--------|
| **User Workflows** | Users lack step-by-step guidance | Medium |
| **FreeCAD Command Reference** | Addon features undiscoverable | Low |
| **Troubleshooting Guide** | Support burden increases | Medium |
| **Developer Setup Guide** | Onboarding friction | Low |
#### Lower Priority
| Gap | Impact | Effort |
|-----|--------|--------|
| **CHANGELOG.md** | Version history unclear | Low |
| **Architecture Decision Records** | Design rationale lost | Medium |
| **Integration Guide** | Third-party integration unclear | High |
### 1.3 Recommended Actions
1. **Consolidate specs**: Remove `silo-spec.md` duplicate
2. **Create `docs/API.md`**: Full REST endpoint documentation with examples
3. **Create `docs/DEPLOYMENT.md`**: Production deployment guide
4. **Expand README.md**: Add configuration reference section
---
## 2. Current Revision Control Implementation
### 2.1 What's Implemented
| Feature | Status | Location |
|---------|--------|----------|
| Append-only revision history | Complete | `internal/db/items.go` |
| Sequential revision numbering | Complete | Database trigger |
| Property snapshots (JSONB) | Complete | `revisions.properties` |
| File versioning (MinIO) | Complete | `internal/storage/` |
| SHA256 checksums | Complete | Captured on upload |
| Revision comments | Complete | `revisions.comment` |
| User attribution | Complete | `revisions.created_by` |
| Property schema versioning | Complete | Migration 005 |
| BOM revision pinning | Complete | `relationships.child_revision` |
### 2.2 Database Schema
```sql
-- Current revision schema (migrations/001_initial.sql)
CREATE TABLE revisions (
id UUID PRIMARY KEY,
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL,
properties JSONB NOT NULL DEFAULT '{}',
file_key TEXT,
file_version TEXT, -- MinIO version ID
file_checksum TEXT, -- SHA256
file_size BIGINT,
thumbnail_key TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
created_by TEXT,
comment TEXT,
property_schema_version INTEGER DEFAULT 1,
UNIQUE(item_id, revision_number)
);
```
### 2.3 API Endpoints
| Endpoint | Method | Status |
|----------|--------|--------|
| `/api/items/{pn}/revisions` | GET | Implemented |
| `/api/items/{pn}/revisions` | POST | Implemented |
| `/api/items/{pn}/revisions/{rev}` | GET | Implemented |
| `/api/items/{pn}/file` | POST | Implemented |
| `/api/items/{pn}/file` | GET | Implemented (latest) |
| `/api/items/{pn}/file/{rev}` | GET | Implemented |
### 2.4 FreeCAD Integration
| Command | Function | Status |
|---------|----------|--------|
| `Silo_Save` | Auto-save + upload | Implemented |
| `Silo_Commit` | Save with comment | Implemented |
| `Silo_Pull` | Download/create | Implemented |
| `Silo_Push` | Batch upload | Implemented |
| `Silo_Info` | View revision history | Implemented |
---
## 3. Revision Control Gaps
### 3.1 Critical Gaps
| Gap | Description | Impact |
|-----|-------------|--------|
| **No rollback** | Cannot revert to previous revision | Data recovery difficult |
| **No comparison** | Cannot diff between revisions | Change tracking manual |
| **No locking** | No concurrent edit protection | Multi-user unsafe |
| **No approval workflow** | No release/sign-off process | Quality control gap |
### 3.2 Important Gaps
| Gap | Description | Impact |
|-----|-------------|--------|
| **No branching** | Linear history only | No experimental variants |
| **No tagging** | No named milestones | Release tracking manual |
| **No audit log** | Actions not logged separately | Compliance gap |
| **Thumbnail missing** | Schema exists, not populated | No visual preview |
### 3.3 Nice-to-Have Gaps
| Gap | Description | Impact |
|-----|-------------|--------|
| **No search** | Cannot search revision comments | Discovery limited |
| **No retention policy** | Revisions never expire | Storage grows unbounded |
| **No delta storage** | Full file per revision | Storage inefficient |
| **No notifications** | No change alerts | Manual monitoring required |
---
## 4. Revision Control Roadmap
### Phase 1: Foundation (Recommended First)
**Goal:** Enable safe single-user revision management
#### 1.1 Rollback Support
```
Effort: Medium | Priority: High | Risk: Low
```
**Changes Required:**
- Add `POST /api/items/{pn}/rollback/{rev}` endpoint
- Create new revision copying properties/file from target revision
- FreeCAD: Add `Silo_Rollback` command
**Database:** No schema changes needed (creates new revision from old)
#### 1.2 Revision Comparison API
```
Effort: Medium | Priority: High | Risk: Low
```
**Changes Required:**
- Add `GET /api/items/{pn}/revisions/compare?from={rev1}&to={rev2}` endpoint
- Return property diff (added/removed/changed keys)
- Return file metadata diff (size, checksum changes)
**Implementation:**
```go
type RevisionDiff struct {
FromRevision int `json:"from_revision"`
ToRevision int `json:"to_revision"`
Properties PropertyDiff `json:"properties"`
FileChanged bool `json:"file_changed"`
FileSizeDiff int64 `json:"file_size_diff,omitempty"`
}
type PropertyDiff struct {
Added map[string]any `json:"added,omitempty"`
Removed map[string]any `json:"removed,omitempty"`
Changed map[string]PropertyChange `json:"changed,omitempty"`
}
```
#### 1.3 Revision Labels/Status
```
Effort: Low | Priority: Medium | Risk: Low
```
**Database Migration:**
```sql
ALTER TABLE revisions ADD COLUMN status TEXT DEFAULT 'draft';
-- Values: 'draft', 'review', 'released', 'obsolete'
ALTER TABLE revisions ADD COLUMN labels TEXT[] DEFAULT '{}';
-- Arbitrary tags: ['prototype', 'v1.0', 'customer-approved']
```
**API Changes:**
- Add `PATCH /api/items/{pn}/revisions/{rev}` for status/label updates
- Add filtering by status in list endpoint
---
### Phase 2: Multi-User Support
**Goal:** Enable safe concurrent editing
#### 2.1 Pessimistic Locking
```
Effort: High | Priority: High | Risk: Medium
```
**Database Migration:**
```sql
CREATE TABLE item_locks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
locked_by TEXT NOT NULL,
locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
lock_type TEXT NOT NULL DEFAULT 'exclusive',
comment TEXT,
UNIQUE(item_id)
);
CREATE INDEX idx_locks_expires ON item_locks(expires_at);
```
**API Endpoints:**
```
POST /api/items/{pn}/lock # Acquire lock
DELETE /api/items/{pn}/lock # Release lock
GET /api/items/{pn}/lock # Check lock status
```
**FreeCAD Integration:**
- Auto-lock on `Silo_Pull` (configurable)
- Auto-unlock on `Silo_Save`/`Silo_Commit`
- Show lock status in `Silo_Info`
#### 2.2 Authentication (LDAP/FreeIPA)
```
Effort: High | Priority: High | Risk: Medium
```
**Changes Required:**
- Add `internal/auth/` package
- LDAP bind configuration in config.yaml
- Middleware for API authentication
- `created_by` populated from authenticated user
**Configuration:**
```yaml
auth:
enabled: true
provider: ldap
ldap:
server: ldap://freeipa.example.com
base_dn: cn=users,cn=accounts,dc=example,dc=com
bind_dn: uid=silo-service,cn=users,...
bind_password_env: LDAP_BIND_PASSWORD
```
#### 2.3 Audit Logging
```
Effort: Medium | Priority: Medium | Risk: Low
```
**Database Migration:**
```sql
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id TEXT NOT NULL,
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'lock', 'unlock'
resource_type TEXT NOT NULL, -- 'item', 'revision', 'project', 'relationship'
resource_id TEXT NOT NULL,
details JSONB,
ip_address TEXT
);
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_user ON audit_log(user_id);
CREATE INDEX idx_audit_resource ON audit_log(resource_type, resource_id);
```
---
### Phase 3: Advanced Features
**Goal:** Enhanced revision management capabilities
#### 3.1 Branching Support
```
Effort: High | Priority: Low | Risk: High
```
**Concept:** Named revision streams per item
**Database Migration:**
```sql
CREATE TABLE revision_branches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- 'main', 'experimental', 'customer-variant'
base_revision INTEGER, -- Branch point
created_at TIMESTAMPTZ DEFAULT now(),
created_by TEXT,
UNIQUE(item_id, name)
);
ALTER TABLE revisions ADD COLUMN branch_id UUID REFERENCES revision_branches(id);
```
**Complexity:** Requires merge logic, conflict resolution UI
#### 3.2 Release Management
```
Effort: Medium | Priority: Medium | Risk: Low
```
**Database Migration:**
```sql
CREATE TABLE releases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE, -- 'v1.0', '2026-Q1'
description TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
created_by TEXT,
status TEXT DEFAULT 'draft' -- 'draft', 'released', 'archived'
);
CREATE TABLE release_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
release_id UUID REFERENCES releases(id) ON DELETE CASCADE,
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL,
UNIQUE(release_id, item_id)
);
```
**Use Case:** Snapshot a set of items at specific revisions for a product release
#### 3.3 Thumbnail Generation
```
Effort: Medium | Priority: Low | Risk: Low
```
**Implementation Options:**
1. FreeCAD headless rendering (python script)
2. External service (e.g., CAD thumbnail microservice)
3. User-uploaded thumbnails
**Changes:**
- Add thumbnail generation on file upload
- Store in MinIO at `thumbnails/{part_number}/rev{n}.png`
- Expose via `GET /api/items/{pn}/thumbnail/{rev}`
---
## 5. Recommended Implementation Order
### Immediate (Next Sprint)
1. **Revision Comparison API** - High value, low risk
2. **Rollback Support** - Critical for data safety
3. **Revision Labels** - Quick win for workflow
### Short-term (1-2 Months)
4. **Pessimistic Locking** - Required before multi-user
5. **Authentication** - Required before production deployment
6. **Audit Logging** - Compliance and debugging
### Medium-term (3-6 Months)
7. **Release Management** - Product milestone tracking
8. **Thumbnail Generation** - Visual preview capability
9. **Documentation Overhaul** - API reference, deployment guide
### Long-term (Future)
10. **Branching** - Complex, defer until needed
11. **Delta Storage** - Optimization, not critical
12. **Notifications** - Nice-to-have workflow enhancement
---
## 6. Migration Considerations
### Part Number Format Migration (Completed)
The recent migration from `XXXXX-CCC-NNNN` to `CCC-NNNN` format has been completed:
- Database migration: `migrations/006_project_tags.sql`
- Schema update: `schemas/kindred-rd.yaml` v3
- Projects: Now many-to-many tags instead of embedded in part number
- File paths: `~/projects/cad/{category}_{name}/{part_number}_{description}.FCStd`
### Future Schema Migrations
The property schema versioning framework (`property_schema_version`, `property_migrations` table) is in place but lacks:
- Automated migration runners
- Rollback capability for failed migrations
- Dry-run validation mode
**Recommendation:** Build migration tooling before adding complex property schemas.
---
## 7. Open Questions (from Specification)
These design decisions remain unresolved:
1. **Property change triggers** - Should editing properties auto-create revision?
2. **Revision metadata editing** - Allow comment updates post-creation?
3. **Soft delete behavior** - Archive or hard delete revisions?
4. **File diff strategy** - Exploded FCStd storage for better diffing?
5. **Retention policy** - How long to keep old revisions?
---
## Appendix A: File Structure for New Features
```
internal/
api/
handlers_revision.go # New revision endpoints
handlers_lock.go # Locking endpoints
handlers_audit.go # Audit log endpoints
auth/
ldap.go # LDAP authentication
middleware.go # Auth middleware
db/
locks.go # Lock repository
audit.go # Audit repository
releases.go # Release repository
migrations/
007_revision_status.sql # Labels and status
008_item_locks.sql # Locking table
009_audit_log.sql # Audit logging
010_releases.sql # Release management
```
---
## Appendix B: API Additions Summary
### Phase 1 Endpoints
```
GET /api/items/{pn}/revisions/compare # Diff two revisions
POST /api/items/{pn}/rollback/{rev} # Create revision from old
PATCH /api/items/{pn}/revisions/{rev} # Update status/labels
```
### Phase 2 Endpoints
```
POST /api/items/{pn}/lock # Acquire lock
DELETE /api/items/{pn}/lock # Release lock
GET /api/items/{pn}/lock # Check lock status
GET /api/audit # Query audit log
```
### Phase 3 Endpoints
```
GET /api/releases # List releases
POST /api/releases # Create release
GET /api/releases/{name} # Get release details
POST /api/releases/{name}/items # Add items to release
GET /api/items/{pn}/thumbnail/{rev} # Get thumbnail
```

View File

@@ -47,8 +47,8 @@ var csvColumns = []string{
"current_revision",
"created_at",
"updated_at",
"project",
"category",
"projects", // comma-separated project codes
}
// HandleExportCSV exports items to CSV format.
@@ -129,8 +129,19 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
for _, item := range items {
row := make([]string, len(headers))
// Parse part number to extract project and category
project, category := parsePartNumber(item.PartNumber)
// Extract category from part number (format: CCC-NNNN)
category := parseCategory(item.PartNumber)
// Get projects for this item
projects, err := s.projects.GetProjectsForItem(ctx, item.ID)
projectCodes := ""
if err == nil && len(projects) > 0 {
codes := make([]string, len(projects))
for i, p := range projects {
codes[i] = p.Code
}
projectCodes = strings.Join(codes, ",")
}
// Standard columns
row[0] = item.PartNumber
@@ -139,8 +150,8 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
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
row[6] = category
row[7] = projectCodes
// Property columns
if includeProps {
@@ -206,8 +217,8 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
colIndex[strings.ToLower(strings.TrimSpace(h))] = i
}
// Validate required columns
requiredCols := []string{"project", "category"}
// Validate required columns - only category is required now (projects are optional tags)
requiredCols := []string{"category"}
for _, col := range requiredCols {
if _, ok := colIndex[col]; !ok {
writeError(w, http.StatusBadRequest, "missing_column", fmt.Sprintf("Required column '%s' not found", col))
@@ -241,20 +252,20 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
rowNum++
// Extract values
project := getCSVValue(record, colIndex, "project")
category := getCSVValue(record, colIndex, "category")
description := getCSVValue(record, colIndex, "description")
partNumber := getCSVValue(record, colIndex, "part_number")
projectsStr := getCSVValue(record, colIndex, "projects")
// Validate project
if project == "" {
result.Errors = append(result.Errors, CSVImportErr{
Row: rowNum,
Field: "project",
Message: "Project code is required",
})
result.ErrorCount++
continue
// Parse project codes (comma-separated)
var projectCodes []string
if projectsStr != "" {
for _, code := range strings.Split(projectsStr, ",") {
code = strings.TrimSpace(strings.ToUpper(code))
if code != "" {
projectCodes = append(projectCodes, code)
}
}
}
// Validate category
@@ -270,7 +281,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
// 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 {
@@ -308,7 +318,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
input := partnum.Input{
SchemaName: schemaName,
Values: map[string]string{
"project": strings.ToUpper(project),
"category": strings.ToUpper(category),
},
}
@@ -351,6 +360,18 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
continue
}
// Tag item with projects
if len(projectCodes) > 0 {
if err := s.projects.SetItemProjects(ctx, item.ID, projectCodes); err != nil {
// Item was created but tagging failed - log warning but don't fail the row
s.logger.Warn().
Err(err).
Str("part_number", partNumber).
Strs("projects", projectCodes).
Msg("failed to tag item with projects")
}
}
result.SuccessCount++
result.CreatedItems = append(result.CreatedItems, partNumber)
}
@@ -380,9 +401,9 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) {
// Build headers: standard columns + default property columns from schema
headers := []string{
"project",
"category",
"description",
"projects", // comma-separated project codes (optional)
}
// Add default property columns from schema
@@ -411,9 +432,9 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) {
// Write example row
exampleRow := make([]string, len(headers))
exampleRow[0] = "PROJ1" // project
exampleRow[1] = "F01" // category
exampleRow[2] = "Example Item Description"
exampleRow[0] = "F01" // category
exampleRow[1] = "Example Item Description" // description
exampleRow[2] = "PROJ1,PROJ2" // projects (comma-separated)
// Leave property columns empty
if err := writer.Write(exampleRow); err != nil {
@@ -424,13 +445,13 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) {
// Helper functions
func parsePartNumber(pn string) (project, category string) {
// parseCategory extracts category code from part number (format: CCC-NNNN)
func parseCategory(pn string) string {
parts := strings.Split(pn, "-")
if len(parts) >= 2 {
project = parts[0]
category = parts[1]
if len(parts) >= 1 {
return parts[0]
}
return
return ""
}
func formatPropertyValue(v any) string {
@@ -509,8 +530,8 @@ func isStandardColumn(col string) bool {
"current_revision": true,
"created_at": true,
"updated_at": true,
"project": true,
"category": true,
"projects": true,
}
return standardCols[col]
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -22,6 +23,7 @@ type Server struct {
logger zerolog.Logger
db *db.DB
items *db.ItemRepository
projects *db.ProjectRepository
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
@@ -37,6 +39,7 @@ func NewServer(
store *storage.Storage,
) *Server {
items := db.NewItemRepository(database)
projects := db.NewProjectRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore)
@@ -44,6 +47,7 @@ func NewServer(
logger: logger,
db: database,
items: items,
projects: projects,
schemas: schemas,
schemasDir: schemasDir,
partgen: partgen,
@@ -214,9 +218,9 @@ type ItemResponse struct {
// CreateItemRequest represents a request to create an item.
type CreateItemRequest struct {
Schema string `json:"schema"`
Project string `json:"project"`
Category string `json:"category"`
Description string `json:"description"`
Projects []string `json:"projects,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
}
@@ -256,20 +260,6 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// HandleListProjects returns distinct project codes from existing items.
func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
projects, err := s.items.ListProjects(ctx)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list projects")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects")
return
}
writeJSON(w, http.StatusOK, projects)
}
// HandleCreateItem creates a new item with generated part number.
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -286,11 +276,10 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
schemaName = "kindred-rd"
}
// Generate part number
// Generate part number (no longer includes project)
input := partnum.Input{
SchemaName: schemaName,
Values: map[string]string{
"project": req.Project,
"category": req.Category,
},
}
@@ -308,8 +297,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
switch req.Category[0] {
case 'A':
itemType = "assembly"
case 'D':
itemType = "document"
case 'T':
itemType = "tooling"
}
@@ -326,7 +313,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
if properties == nil {
properties = make(map[string]any)
}
properties["project"] = req.Project
properties["category"] = req.Category
if err := s.items.Create(ctx, item, properties); err != nil {
@@ -335,6 +321,15 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
return
}
// Tag item with projects if provided
if len(req.Projects) > 0 {
for _, projectCode := range req.Projects {
if err := s.projects.AddItemToProjectByCode(ctx, item.ID, projectCode); err != nil {
s.logger.Warn().Err(err).Str("project", projectCode).Msg("failed to tag item with project")
}
}
}
writeJSON(w, http.StatusCreated, itemToResponse(item))
}
@@ -494,6 +489,32 @@ type RevisionResponse struct {
CreatedAt string `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
Comment *string `json:"comment,omitempty"`
Status string `json:"status"`
Labels []string `json:"labels"`
}
// RevisionDiffResponse represents the API response for revision comparison.
type RevisionDiffResponse struct {
FromRevision int `json:"from_revision"`
ToRevision int `json:"to_revision"`
FromStatus string `json:"from_status"`
ToStatus string `json:"to_status"`
FileChanged bool `json:"file_changed"`
FileSizeDiff *int64 `json:"file_size_diff,omitempty"`
Added map[string]any `json:"added,omitempty"`
Removed map[string]any `json:"removed,omitempty"`
Changed map[string]db.PropertyChange `json:"changed,omitempty"`
}
// UpdateRevisionRequest represents a request to update revision status/labels.
type UpdateRevisionRequest struct {
Status *string `json:"status,omitempty"`
Labels []string `json:"labels,omitempty"`
}
// RollbackRequest represents a request to rollback to a previous revision.
type RollbackRequest struct {
Comment string `json:"comment,omitempty"`
}
// HandleListRevisions lists revisions for an item.
@@ -561,6 +582,172 @@ func (s *Server) HandleGetRevision(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Revision not found")
}
// HandleUpdateRevision updates the status and/or labels of a revision.
func (s *Server) HandleUpdateRevision(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
revStr := chi.URLParam(r, "revision")
revNum, err := strconv.Atoi(revStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number")
return
}
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
var req UpdateRevisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
// Validate that at least one field is being updated
if req.Status == nil && req.Labels == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Must provide status or labels to update")
return
}
err = s.items.UpdateRevisionStatus(ctx, item.ID, revNum, req.Status, req.Labels)
if err != nil {
if err.Error() == "revision not found" {
writeError(w, http.StatusNotFound, "not_found", "Revision not found")
return
}
s.logger.Error().Err(err).Msg("failed to update revision")
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Return updated revision
rev, err := s.items.GetRevision(ctx, item.ID, revNum)
if err != nil || rev == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get updated revision")
return
}
writeJSON(w, http.StatusOK, revisionToResponse(rev))
}
// HandleCompareRevisions compares two revisions and returns their differences.
func (s *Server) HandleCompareRevisions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
// Get query parameters for from and to revisions
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
if fromStr == "" || toStr == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Must provide 'from' and 'to' query parameters")
return
}
fromRev, err := strconv.Atoi(fromStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision", "'from' must be a number")
return
}
toRev, err := strconv.Atoi(toStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision", "'to' must be a number")
return
}
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
diff, err := s.items.CompareRevisions(ctx, item.ID, fromRev, toRev)
if err != nil {
s.logger.Error().Err(err).Msg("failed to compare revisions")
writeError(w, http.StatusBadRequest, "comparison_failed", err.Error())
return
}
response := RevisionDiffResponse{
FromRevision: diff.FromRevision,
ToRevision: diff.ToRevision,
FromStatus: diff.FromStatus,
ToStatus: diff.ToStatus,
FileChanged: diff.FileChanged,
FileSizeDiff: diff.FileSizeDiff,
Added: diff.Added,
Removed: diff.Removed,
Changed: diff.Changed,
}
writeJSON(w, http.StatusOK, response)
}
// HandleRollbackRevision creates a new revision by copying from an existing one.
func (s *Server) HandleRollbackRevision(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
revStr := chi.URLParam(r, "revision")
revNum, err := strconv.Atoi(revStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number")
return
}
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
var req RollbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
// Generate comment if not provided
comment := req.Comment
if comment == "" {
comment = fmt.Sprintf("Rollback to revision %d", revNum)
}
newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, nil)
if err != nil {
s.logger.Error().Err(err).Msg("failed to create rollback revision")
writeError(w, http.StatusBadRequest, "rollback_failed", err.Error())
return
}
s.logger.Info().
Str("part_number", partNumber).
Int("source_revision", revNum).
Int("new_revision", newRev.RevisionNumber).
Msg("rollback revision created")
writeJSON(w, http.StatusCreated, revisionToResponse(newRev))
}
// Part number generation
// GeneratePartNumberRequest represents a request to generate a part number.
@@ -811,6 +998,10 @@ func itemToResponse(item *db.Item) ItemResponse {
}
func revisionToResponse(rev *db.Revision) RevisionResponse {
labels := rev.Labels
if labels == nil {
labels = []string{}
}
return RevisionResponse{
ID: rev.ID,
RevisionNumber: rev.RevisionNumber,
@@ -821,6 +1012,8 @@ func revisionToResponse(rev *db.Revision) RevisionResponse {
CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedBy: rev.CreatedBy,
Comment: rev.Comment,
Status: rev.Status,
Labels: labels,
}
}
@@ -1074,3 +1267,257 @@ func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request
rctx.URLParams.Add("revision", "latest")
s.HandleDownloadFile(w, r)
}
// Project handlers
// ProjectResponse represents a project in API responses.
type ProjectResponse struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreateProjectRequest represents a request to create a project.
type CreateProjectRequest struct {
Code string `json:"code"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
// UpdateProjectRequest represents a request to update a project.
type UpdateProjectRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
// HandleListProjects lists all projects.
func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
projects, err := s.projects.List(ctx)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list projects")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects")
return
}
response := make([]ProjectResponse, len(projects))
for i, p := range projects {
response[i] = projectToResponse(p)
}
writeJSON(w, http.StatusOK, response)
}
// HandleCreateProject creates a new project.
func (s *Server) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Code == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Project code is required")
return
}
// Validate project code format (2-10 alphanumeric characters)
if len(req.Code) < 2 || len(req.Code) > 10 {
writeError(w, http.StatusBadRequest, "invalid_code", "Project code must be 2-10 characters")
return
}
project := &db.Project{
Code: req.Code,
Name: req.Name,
Description: req.Description,
}
if err := s.projects.Create(ctx, project); err != nil {
s.logger.Error().Err(err).Msg("failed to create project")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, projectToResponse(project))
}
// HandleGetProject retrieves a project by code.
func (s *Server) HandleGetProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := chi.URLParam(r, "code")
project, err := s.projects.GetByCode(ctx, code)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get project")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get project")
return
}
if project == nil {
writeError(w, http.StatusNotFound, "not_found", "Project not found")
return
}
writeJSON(w, http.StatusOK, projectToResponse(project))
}
// HandleUpdateProject updates a project.
func (s *Server) HandleUpdateProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := chi.URLParam(r, "code")
var req UpdateProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if err := s.projects.Update(ctx, code, req.Name, req.Description); err != nil {
s.logger.Error().Err(err).Msg("failed to update project")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
}
project, _ := s.projects.GetByCode(ctx, code)
writeJSON(w, http.StatusOK, projectToResponse(project))
}
// HandleDeleteProject deletes a project.
func (s *Server) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := chi.URLParam(r, "code")
if err := s.projects.Delete(ctx, code); err != nil {
s.logger.Error().Err(err).Msg("failed to delete project")
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetProjectItems lists items in a project.
func (s *Server) HandleGetProjectItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := chi.URLParam(r, "code")
project, err := s.projects.GetByCode(ctx, code)
if err != nil || project == nil {
writeError(w, http.StatusNotFound, "not_found", "Project not found")
return
}
items, err := s.projects.GetItemsForProject(ctx, project.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get project items")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get items")
return
}
response := make([]ItemResponse, len(items))
for i, item := range items {
response[i] = itemToResponse(item)
}
writeJSON(w, http.StatusOK, response)
}
// HandleGetItemProjects lists projects for an item.
func (s *Server) HandleGetItemProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
projects, err := s.projects.GetProjectsForItem(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item projects")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get projects")
return
}
response := make([]ProjectResponse, len(projects))
for i, p := range projects {
response[i] = projectToResponse(p)
}
writeJSON(w, http.StatusOK, response)
}
// AddItemProjectRequest represents a request to add projects to an item.
type AddItemProjectRequest struct {
Projects []string `json:"projects"`
}
// HandleAddItemProjects adds project tags to an item.
func (s *Server) HandleAddItemProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
var req AddItemProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
for _, code := range req.Projects {
if err := s.projects.AddItemToProjectByCode(ctx, item.ID, code); err != nil {
s.logger.Warn().Err(err).Str("project", code).Msg("failed to add project")
}
}
// Return updated project list
projects, _ := s.projects.GetProjectsForItem(ctx, item.ID)
response := make([]ProjectResponse, len(projects))
for i, p := range projects {
response[i] = projectToResponse(p)
}
writeJSON(w, http.StatusOK, response)
}
// HandleRemoveItemProject removes a project tag from an item.
func (s *Server) HandleRemoveItemProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
projectCode := chi.URLParam(r, "code")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if err := s.projects.RemoveItemFromProjectByCode(ctx, item.ID, projectCode); err != nil {
s.logger.Error().Err(err).Msg("failed to remove project")
writeError(w, http.StatusInternalServerError, "remove_failed", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func projectToResponse(p *db.Project) ProjectResponse {
return ProjectResponse{
ID: p.ID,
Code: p.Code,
Name: p.Name,
Description: p.Description,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@@ -57,8 +57,15 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
})
})
// Projects (distinct project codes from items)
r.Get("/projects", server.HandleListProjects)
// Projects
r.Route("/projects", func(r chi.Router) {
r.Get("/", server.HandleListProjects)
r.Post("/", server.HandleCreateProject)
r.Get("/{code}", server.HandleGetProject)
r.Put("/{code}", server.HandleUpdateProject)
r.Delete("/{code}", server.HandleDeleteProject)
r.Get("/{code}/items", server.HandleGetProjectItems)
})
// Items
r.Route("/items", func(r chi.Router) {
@@ -75,10 +82,18 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Put("/", server.HandleUpdateItem)
r.Delete("/", server.HandleDeleteItem)
// Item project tags
r.Get("/projects", server.HandleGetItemProjects)
r.Post("/projects", server.HandleAddItemProjects)
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
// Revisions
r.Get("/revisions", server.HandleListRevisions)
r.Post("/revisions", server.HandleCreateRevision)
r.Get("/revisions/compare", server.HandleCompareRevisions)
r.Get("/revisions/{revision}", server.HandleGetRevision)
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
// File upload/download
r.Post("/file", server.HandleUploadFile)

View File

@@ -126,13 +126,10 @@
<div class="search-help" id="search-help">
<div class="search-help-title">Search Tips</div>
<div class="search-help-item">
<code>3DX15</code> - Search by project code
<code>F01</code> - Search by category code
</div>
<div class="search-help-item">
<code>A01</code> - Search by category
</div>
<div class="search-help-item">
<code>3DX15-A01</code> - Project + category
<code>F01-0001</code> - Full part number
</div>
<div class="search-help-item">
<code>0001</code> - Search by sequence
@@ -202,36 +199,21 @@
>
<option value="">Start from scratch...</option>
<option value="machined-part">
Machined Part (M-category)
Machined Part (X-category)
</option>
<option value="printed-part">
3D Printed Part (F-category)
3D Printed Part (X-category)
</option>
<option value="fastener">Fastener (R-category)</option>
<option value="fastener">Fastener (F-category)</option>
<option value="electronics">
Electronics (E-category)
</option>
<option value="assembly">Assembly (A-category)</option>
<option value="purchased">
Purchased/COTS (S-category)
Purchased/COTS (P-category)
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Project Code</label>
<select class="form-input" id="project" required>
<option value="">Select project...</option>
</select>
<input
type="text"
class="form-input"
id="project-new"
maxlength="5"
pattern="[A-Za-z0-9]{5}"
placeholder="Or enter new project code (5 chars)"
style="margin-top: 0.5rem"
/>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-input" id="category" required>
@@ -247,6 +229,19 @@
placeholder="Item description"
/>
</div>
<div class="form-group">
<label class="form-label">Project Tags (optional)</label>
<div class="project-tags-container" id="project-tags-container">
<select
class="form-input"
id="project-select"
onchange="addProjectTag()"
>
<option value="">Add project tag...</option>
</select>
<div class="selected-tags" id="selected-tags"></div>
</div>
</div>
<div class="form-actions">
<button
type="button"
@@ -414,13 +409,13 @@
<div class="import-instructions">
<p>Upload a CSV file to bulk import items. Required columns:</p>
<ul>
<li><code>project</code> - 5-character project code</li>
<li>
<code>category</code> - Category code (e.g., F01, A01)
</li>
</ul>
<p>
Optional columns: <code>description</code>,
<code>projects</code> (comma-separated project codes),
<code>part_number</code>, and any property columns.
</p>
<a href="/api/items/template.csv" class="template-link"
@@ -991,6 +986,162 @@
color: var(--ctp-subtext0);
font-style: italic;
}
/* Project Tags Styles */
.project-tags-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 1.5rem;
}
.project-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 1rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.85rem;
color: var(--ctp-text);
}
.project-tag .tag-remove {
background: transparent;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
padding: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
transition: all 0.2s;
}
.project-tag .tag-remove:hover {
background: var(--ctp-red);
color: var(--ctp-crust);
}
.item-projects {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
}
.item-project-tag {
display: inline-block;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-mauve);
border-radius: 0.75rem;
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
color: var(--ctp-mauve);
font-weight: 500;
}
/* Revision Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-draft {
background: var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.status-review {
background: var(--ctp-yellow);
color: var(--ctp-crust);
}
.status-released {
background: var(--ctp-green);
color: var(--ctp-crust);
}
.status-obsolete {
background: var(--ctp-red);
color: var(--ctp-crust);
}
/* Revision Actions */
.revision-actions {
display: flex;
gap: 0.25rem;
}
.revision-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Revision Compare UI */
.compare-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-controls select {
padding: 0.4rem 0.75rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.85rem;
}
.compare-result {
margin-top: 1rem;
padding: 1rem;
background: var(--ctp-surface0);
border-radius: 0.5rem;
}
.compare-result h4 {
margin: 0 0 0.75rem 0;
color: var(--ctp-subtext1);
font-size: 0.9rem;
}
.diff-section {
margin-bottom: 1rem;
}
.diff-section h5 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
}
.diff-added {
color: var(--ctp-green);
}
.diff-removed {
color: var(--ctp-red);
}
.diff-changed {
color: var(--ctp-yellow);
}
.diff-item {
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border-radius: 0.25rem;
margin-bottom: 0.25rem;
}
/* Status Select Dropdown */
.status-select {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
background: var(--ctp-surface1);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
color: var(--ctp-text);
cursor: pointer;
}
</style>
<script>
let currentPage = 1;
@@ -1022,14 +1173,17 @@
// Item templates for quick creation
const itemTemplates = {
"machined-part": { category: "M09", descPrefix: "MACHINED " },
"printed-part": { category: "F10", descPrefix: "3DP " },
fastener: { category: "R01", descPrefix: "" },
"machined-part": { category: "X01", descPrefix: "MACHINED " },
"printed-part": { category: "X03", descPrefix: "3DP " },
fastener: { category: "F01", descPrefix: "" },
electronics: { category: "E01", descPrefix: "" },
assembly: { category: "A01", descPrefix: "" },
purchased: { category: "S05", descPrefix: "COTS " },
purchased: { category: "P01", descPrefix: "COTS " },
};
// Selected project tags for new item creation
let selectedProjectTags = [];
// Load schema for create form
async function loadSchema() {
try {
@@ -1061,7 +1215,9 @@
async function loadProjectCodes() {
try {
const response = await fetch("/api/projects");
projectCodes = await response.json();
const projects = await response.json();
// Extract codes from project objects
projectCodes = projects.map((p) => p.code || p);
// Populate project filter dropdown
const projectFilter = document.getElementById("project-filter");
@@ -1072,19 +1228,56 @@
projectFilter.appendChild(option);
});
// Populate create form project dropdown
const projectSelect = document.getElementById("project");
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
// Populate create form project tag dropdown
const projectSelect = document.getElementById("project-select");
if (projectSelect) {
projectCodes.forEach((code) => {
const option = document.createElement("option");
option.value = code;
option.textContent = code;
projectSelect.appendChild(option);
});
}
} catch (error) {
console.error("Failed to load project codes:", error);
}
}
// Project tag management for create form
function addProjectTag() {
const select = document.getElementById("project-select");
const code = select.value;
if (!code || selectedProjectTags.includes(code)) {
select.value = "";
return;
}
selectedProjectTags.push(code);
renderSelectedTags();
select.value = "";
}
function removeProjectTag(code) {
selectedProjectTags = selectedProjectTags.filter((c) => c !== code);
renderSelectedTags();
}
function renderSelectedTags() {
const container = document.getElementById("selected-tags");
if (!container) return;
container.innerHTML = selectedProjectTags
.map(
(code) => `
<span class="project-tag">
${code}
<button type="button" class="tag-remove" onclick="removeProjectTag('${code}')">&times;</button>
</span>
`,
)
.join("");
}
// Apply template to create form
function applyTemplate() {
const templateId = document.getElementById("template").value;
@@ -1230,36 +1423,31 @@
// Create Modal functions
function openCreateModal() {
document.getElementById("create-modal").classList.add("active");
selectedProjectTags = [];
renderSelectedTags();
}
function closeCreateModal() {
document.getElementById("create-modal").classList.remove("active");
document.getElementById("create-form").reset();
selectedProjectTags = [];
renderSelectedTags();
}
async function createItem(event) {
event.preventDefault();
// Use new project code if provided, otherwise use dropdown selection
const projectSelect = document.getElementById("project").value;
const projectNew = document
.getElementById("project-new")
.value.trim()
.toUpperCase();
const project = projectNew || projectSelect;
if (!project) {
alert("Please select or enter a project code");
return;
}
const data = {
schema: "kindred-rd",
project: project,
category: document.getElementById("category").value,
description: document.getElementById("description").value,
};
// Add project tags if any selected
if (selectedProjectTags.length > 0) {
data.projects = selectedProjectTags;
}
try {
const response = await fetch("/api/items", {
method: "POST",
@@ -1274,7 +1462,11 @@
}
const item = await response.json();
alert(`Created: ${item.part_number}`);
let msg = `Created: ${item.part_number}`;
if (selectedProjectTags.length > 0) {
msg += `\nTagged with: ${selectedProjectTags.join(", ")}`;
}
alert(msg);
closeCreateModal();
loadItems();
} catch (error) {
@@ -1424,9 +1616,27 @@
fetch(`/api/items/${partNumber}/revisions`),
]);
if (!itemRes.ok) {
const error = await itemRes.json();
throw new Error(
error.message || error.error || "Failed to load item",
);
}
if (!revsRes.ok) {
const error = await revsRes.json();
throw new Error(
error.message || error.error || "Failed to load revisions",
);
}
const item = await itemRes.json();
const revisions = await revsRes.json();
// Ensure revisions is an array
if (!Array.isArray(revisions)) {
throw new Error("Invalid revisions response");
}
currentDetailItem = item;
currentDetailRevisions = revisions;
@@ -1474,6 +1684,28 @@
`;
}
// Fetch project tags for this item
let projectTagsHtml =
'<span class="item-projects"><em style="color: var(--ctp-subtext0);">None</em></span>';
try {
const projectsRes = await fetch(
`/api/items/${partNumber}/projects`,
);
if (projectsRes.ok) {
const itemProjects = await projectsRes.json();
if (itemProjects && itemProjects.length > 0) {
projectTagsHtml = `<span class="item-projects">${itemProjects
.map(
(p) =>
`<span class="item-project-tag">${p.code || p}</span>`,
)
.join("")}</span>`;
}
}
} catch (e) {
console.warn("Failed to load project tags:", e);
}
// Info tab
document.getElementById("tab-info").innerHTML = `
<div style="margin-bottom: 1.5rem;">
@@ -1481,6 +1713,7 @@
<p><strong>Part Number:</strong> <span class="part-number-container"><span class="part-number">${item.part_number}</span><button class="copy-btn" onclick="copyPartNumber('${item.part_number}', this)" title="Copy part number">${icons.clipboard}</button></span></p>
<p><strong>Type:</strong> <span class="item-type item-type-${item.item_type}">${item.item_type}</span></p>
<p><strong>Description:</strong> ${item.description || "-"}</p>
<p><strong>Projects:</strong> ${projectTagsHtml}</p>
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
@@ -1491,27 +1724,55 @@
// Properties tab
renderPropertiesTab(item.properties || {}, item.current_revision);
// Revisions tab
// Revisions tab with compare and rollback
const revisionOptions = revisions
.map(
(r) =>
`<option value="${r.revision_number}">Rev ${r.revision_number}</option>`,
)
.join("");
document.getElementById("tab-revisions").innerHTML = `
<div class="compare-controls">
<span>Compare:</span>
<select id="compare-from">${revisionOptions}</select>
<span>to</span>
<select id="compare-to">${revisionOptions}</select>
<button class="btn btn-secondary" onclick="compareRevisions('${partNumber}')">Compare</button>
</div>
<div id="compare-result"></div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rev</th>
<th>Status</th>
<th>Date</th>
<th>File</th>
<th>Comment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${revisions
.map(
(rev) => `
(rev, idx) => `
<tr>
<td>${rev.revision_number}</td>
<td>
<select class="status-select" onchange="updateRevisionStatus('${partNumber}', ${rev.revision_number}, this.value)">
<option value="draft" ${rev.status === "draft" ? "selected" : ""}>Draft</option>
<option value="review" ${rev.status === "review" ? "selected" : ""}>Review</option>
<option value="released" ${rev.status === "released" ? "selected" : ""}>Released</option>
<option value="obsolete" ${rev.status === "obsolete" ? "selected" : ""}>Obsolete</option>
</select>
</td>
<td>${formatDate(rev.created_at)}</td>
<td>${rev.file_key ? `<button class="copy-btn" onclick="downloadFile('${partNumber}', ${rev.revision_number})" title="Download">${icons.download}</button>` : "-"}</td>
<td>${rev.comment || "-"}</td>
<td class="revision-actions">
${idx > 0 ? `<button class="btn btn-secondary" onclick="rollbackToRevision('${partNumber}', ${rev.revision_number})" title="Rollback to this revision">Rollback</button>` : '<span style="color: var(--ctp-subtext0); font-size: 0.75rem;">Current</span>'}
</td>
</tr>
`,
)
@@ -1520,6 +1781,14 @@
</table>
</div>
`;
// Set default compare selection (latest vs previous)
if (revisions.length >= 2) {
document.getElementById("compare-from").value =
revisions[1].revision_number;
document.getElementById("compare-to").value =
revisions[0].revision_number;
}
} catch (error) {
document.getElementById("tab-info").innerHTML =
`<p>Error loading item: ${error.message}</p>`;
@@ -1984,6 +2253,174 @@
}
}
// Revision Control Functions
// Compare two revisions and display the diff
async function compareRevisions(partNumber) {
const fromRev = document.getElementById("compare-from").value;
const toRev = document.getElementById("compare-to").value;
const resultDiv = document.getElementById("compare-result");
if (fromRev === toRev) {
resultDiv.innerHTML =
'<p style="color: var(--ctp-subtext0);">Select two different revisions to compare.</p>';
return;
}
resultDiv.innerHTML =
'<div class="loading"><div class="spinner"></div></div>';
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/compare?from=${fromRev}&to=${toRev}`,
);
if (!response.ok) {
const error = await response.json();
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message || error.error}</p>`;
return;
}
const diff = await response.json();
let html = `<div class="compare-result">
<h4>Changes from Rev ${diff.from_revision} to Rev ${diff.to_revision}</h4>`;
// Status change
if (diff.from_status !== diff.to_status) {
html += `<div class="diff-section">
<h5 class="diff-changed">Status Changed</h5>
<div class="diff-item">${diff.from_status}${diff.to_status}</div>
</div>`;
}
// File change
if (diff.file_changed) {
const sizeChange = diff.file_size_diff
? diff.file_size_diff > 0
? `+${formatFileSize(diff.file_size_diff)}`
: formatFileSize(diff.file_size_diff)
: "";
html += `<div class="diff-section">
<h5 class="diff-changed">File Changed</h5>
<div class="diff-item">File was modified ${sizeChange ? `(${sizeChange})` : ""}</div>
</div>`;
}
// Added properties
if (diff.added && Object.keys(diff.added).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-added">+ Added Properties</h5>`;
for (const [key, value] of Object.entries(diff.added)) {
html += `<div class="diff-item diff-added">+ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Removed properties
if (diff.removed && Object.keys(diff.removed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-removed">- Removed Properties</h5>`;
for (const [key, value] of Object.entries(diff.removed)) {
html += `<div class="diff-item diff-removed">- ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(value))}</div>`;
}
html += `</div>`;
}
// Changed properties
if (diff.changed && Object.keys(diff.changed).length > 0) {
html += `<div class="diff-section">
<h5 class="diff-changed">~ Changed Properties</h5>`;
for (const [key, change] of Object.entries(diff.changed)) {
html += `<div class="diff-item diff-changed">~ ${escapeHtml(key)}: ${escapeHtml(JSON.stringify(change.old_value))}${escapeHtml(JSON.stringify(change.new_value))}</div>`;
}
html += `</div>`;
}
// No changes
if (
(!diff.added || Object.keys(diff.added).length === 0) &&
(!diff.removed || Object.keys(diff.removed).length === 0) &&
(!diff.changed || Object.keys(diff.changed).length === 0) &&
!diff.file_changed &&
diff.from_status === diff.to_status
) {
html += `<p style="color: var(--ctp-subtext0);">No property changes between these revisions.</p>`;
}
html += `</div>`;
resultDiv.innerHTML = html;
} catch (error) {
resultDiv.innerHTML = `<p style="color: var(--ctp-red);">Error: ${error.message}</p>`;
}
}
// Update revision status
async function updateRevisionStatus(partNumber, revision, status) {
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
// Reload to restore the original status
showItemDetail(partNumber);
return;
}
// Brief visual feedback - the select already shows the new value
} catch (error) {
alert(`Error: ${error.message}`);
showItemDetail(partNumber);
}
}
// Rollback to a previous revision
async function rollbackToRevision(partNumber, revision) {
const confirmed = confirm(
`Rollback to Revision ${revision}?\n\nThis will create a NEW revision with the properties and file from Rev ${revision}. The revision history is preserved.`,
);
if (!confirmed) return;
const comment = prompt(
"Rollback comment (optional):",
`Rollback to revision ${revision}`,
);
if (comment === null) return; // User cancelled
try {
const response = await fetch(
`/api/items/${partNumber}/revisions/${revision}/rollback`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment: comment || `Rollback to revision ${revision}`,
}),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
const result = await response.json();
alert(`Created revision ${result.revision_number} from rollback`);
showItemDetail(partNumber);
loadItems();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Initialize
loadSchema();
loadProjectCodes();

View File

@@ -38,6 +38,35 @@ type Revision struct {
CreatedAt time.Time
CreatedBy *string
Comment *string
Status string // draft, review, released, obsolete
Labels []string // arbitrary tags
}
// RevisionStatus constants
const (
RevisionStatusDraft = "draft"
RevisionStatusReview = "review"
RevisionStatusReleased = "released"
RevisionStatusObsolete = "obsolete"
)
// PropertyChange represents a change in a property value between revisions.
type PropertyChange struct {
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
}
// RevisionDiff represents the differences between two revisions.
type RevisionDiff struct {
FromRevision int `json:"from_revision"`
ToRevision int `json:"to_revision"`
FromStatus string `json:"from_status"`
ToStatus string `json:"to_status"`
FileChanged bool `json:"file_changed"`
FileSizeDiff *int64 `json:"file_size_diff,omitempty"`
Added map[string]any `json:"added,omitempty"`
Removed map[string]any `json:"removed,omitempty"`
Changed map[string]PropertyChange `json:"changed,omitempty"`
}
// ItemRepository provides item database operations.
@@ -131,35 +160,53 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
// 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
`
// Build query - use JOIN if filtering by project
var query string
args := []any{}
argNum := 1
if opts.Project != "" {
// Filter by project via many-to-many relationship
query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
JOIN projects p ON p.id = ip.project_id
WHERE i.archived_at IS NULL AND p.code = $1
`
args = append(args, opts.Project)
argNum++
} else {
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
`
}
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)
if opts.Project != "" {
query += fmt.Sprintf(" AND (i.part_number ILIKE $%d OR i.description ILIKE $%d)", argNum, argNum)
} else {
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.Project != "" {
query += " ORDER BY i.part_number"
} else {
query += " ORDER BY part_number"
}
if opts.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argNum)
@@ -194,13 +241,11 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
return items, nil
}
// ListProjects returns distinct project codes from all items.
// ListProjects returns all project codes from the projects table.
// Deprecated: Use ProjectRepository.List() instead for full project details.
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
SELECT code FROM projects ORDER BY code
`)
if err != nil {
return nil, fmt.Errorf("querying projects: %w", err)
@@ -255,13 +300,37 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
// 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)
// Check if status column exists (migration 007 applied)
var hasStatusColumn bool
err := r.db.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'revisions' AND column_name = 'status'
)
`).Scan(&hasStatusColumn)
if err != nil {
return nil, fmt.Errorf("checking schema: %w", err)
}
var rows pgx.Rows
if hasStatusColumn {
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,
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
} else {
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)
}
@@ -271,10 +340,20 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
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 hasStatusColumn {
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,
&rev.Status, &rev.Labels,
)
} else {
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,
)
rev.Status = "draft"
rev.Labels = []string{}
}
if err != nil {
return nil, fmt.Errorf("scanning revision: %w", err)
}
@@ -287,6 +366,228 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
return revisions, nil
}
// GetRevision retrieves a specific revision by item ID and revision number.
func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisionNumber int) (*Revision, error) {
rev := &Revision{}
var propsJSON []byte
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, '{}') as labels
FROM revisions
WHERE item_id = $1 AND revision_number = $2
`, itemID, revisionNumber).Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("querying revision: %w", err)
}
if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil {
return nil, fmt.Errorf("unmarshaling properties: %w", err)
}
return rev, nil
}
// UpdateRevisionStatus updates the status and/or labels of a revision.
func (r *ItemRepository) UpdateRevisionStatus(ctx context.Context, itemID string, revisionNumber int, status *string, labels []string) error {
if status == nil && labels == nil {
return nil // Nothing to update
}
if status != nil {
// Validate status
switch *status {
case RevisionStatusDraft, RevisionStatusReview, RevisionStatusReleased, RevisionStatusObsolete:
// Valid
default:
return fmt.Errorf("invalid status: %s", *status)
}
}
// Build dynamic update query
query := "UPDATE revisions SET "
args := []any{}
argNum := 1
updates := []string{}
if status != nil {
updates = append(updates, fmt.Sprintf("status = $%d", argNum))
args = append(args, *status)
argNum++
}
if labels != nil {
updates = append(updates, fmt.Sprintf("labels = $%d", argNum))
args = append(args, labels)
argNum++
}
query += updates[0]
for i := 1; i < len(updates); i++ {
query += ", " + updates[i]
}
query += fmt.Sprintf(" WHERE item_id = $%d AND revision_number = $%d", argNum, argNum+1)
args = append(args, itemID, revisionNumber)
result, err := r.db.pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating revision: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("revision not found")
}
return nil
}
// CompareRevisions computes the differences between two revisions.
func (r *ItemRepository) CompareRevisions(ctx context.Context, itemID string, fromRev, toRev int) (*RevisionDiff, error) {
// Get both revisions
from, err := r.GetRevision(ctx, itemID, fromRev)
if err != nil {
return nil, fmt.Errorf("getting from revision: %w", err)
}
if from == nil {
return nil, fmt.Errorf("revision %d not found", fromRev)
}
to, err := r.GetRevision(ctx, itemID, toRev)
if err != nil {
return nil, fmt.Errorf("getting to revision: %w", err)
}
if to == nil {
return nil, fmt.Errorf("revision %d not found", toRev)
}
diff := &RevisionDiff{
FromRevision: fromRev,
ToRevision: toRev,
FromStatus: from.Status,
ToStatus: to.Status,
Added: make(map[string]any),
Removed: make(map[string]any),
Changed: make(map[string]PropertyChange),
}
// Check file changes
fromChecksum := ""
toChecksum := ""
if from.FileChecksum != nil {
fromChecksum = *from.FileChecksum
}
if to.FileChecksum != nil {
toChecksum = *to.FileChecksum
}
diff.FileChanged = fromChecksum != toChecksum
// Calculate file size difference
if from.FileSize != nil && to.FileSize != nil {
sizeDiff := *to.FileSize - *from.FileSize
diff.FileSizeDiff = &sizeDiff
}
// Compare properties
// Find added and changed properties
for key, toVal := range to.Properties {
fromVal, exists := from.Properties[key]
if !exists {
diff.Added[key] = toVal
} else if !equalValues(fromVal, toVal) {
diff.Changed[key] = PropertyChange{
OldValue: fromVal,
NewValue: toVal,
}
}
}
// Find removed properties
for key, fromVal := range from.Properties {
if _, exists := to.Properties[key]; !exists {
diff.Removed[key] = fromVal
}
}
// Clean up empty maps for cleaner JSON
if len(diff.Added) == 0 {
diff.Added = nil
}
if len(diff.Removed) == 0 {
diff.Removed = nil
}
if len(diff.Changed) == 0 {
diff.Changed = nil
}
return diff, nil
}
// equalValues compares two property values for equality.
func equalValues(a, b any) bool {
// Use JSON encoding for deep comparison
aJSON, err1 := json.Marshal(a)
bJSON, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(aJSON) == string(bJSON)
}
// CreateRevisionFromExisting creates a new revision by copying from an existing one (rollback).
func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID string, sourceRevNumber int, comment string, createdBy *string) (*Revision, error) {
// Get the source revision
source, err := r.GetRevision(ctx, itemID, sourceRevNumber)
if err != nil {
return nil, fmt.Errorf("getting source revision: %w", err)
}
if source == nil {
return nil, fmt.Errorf("source revision %d not found", sourceRevNumber)
}
// Create new revision with copied properties (and optionally file reference)
newRev := &Revision{
ItemID: itemID,
Properties: source.Properties,
FileKey: source.FileKey,
FileVersion: source.FileVersion,
FileChecksum: source.FileChecksum,
FileSize: source.FileSize,
ThumbnailKey: source.ThumbnailKey,
CreatedBy: createdBy,
Comment: &comment,
}
// Insert the new revision
propsJSON, err := json.Marshal(newRev.Properties)
if err != nil {
return nil, 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, status
)
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft'
FROM items WHERE id = $1
RETURNING id, revision_number, created_at
`, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion,
newRev.FileChecksum, newRev.FileSize, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment,
).Scan(&newRev.ID, &newRev.RevisionNumber, &newRev.CreatedAt)
if err != nil {
return nil, fmt.Errorf("inserting revision: %w", err)
}
newRev.Status = RevisionStatusDraft
return newRev, nil
}
// Archive soft-deletes an item.
func (r *ItemRepository) Archive(ctx context.Context, id string) error {
_, err := r.db.pool.Exec(ctx, `

299
internal/db/projects.go Normal file
View File

@@ -0,0 +1,299 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// Project represents a project in the database.
type Project struct {
ID string
Code string
Name string
Description string
CreatedAt time.Time
}
// ProjectRepository provides project database operations.
type ProjectRepository struct {
db *DB
}
// NewProjectRepository creates a new project repository.
func NewProjectRepository(db *DB) *ProjectRepository {
return &ProjectRepository{db: db}
}
// List returns all projects.
func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, code, name, description, created_at
FROM projects
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetByCode returns a project by its code.
func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE code = $1
`, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// GetByID returns a project by its ID.
func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE id = $1
`, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// Create inserts a new project.
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO projects (code, name, description)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description)).Scan(&p.ID, &p.CreatedAt)
}
// Update updates a project's name and description.
func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE projects
SET name = $2, description = $3
WHERE code = $1
`, code, nullIfEmpty(name), nullIfEmpty(description))
return err
}
// Delete removes a project (will cascade to item_projects).
func (r *ProjectRepository) Delete(ctx context.Context, code string) error {
_, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code)
return err
}
// AddItemToProject associates an item with a project.
func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
VALUES ($1, $2)
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectID)
return err
}
// AddItemToProjectByCode associates an item with a project by code.
func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectCode)
return err
}
// RemoveItemFromProject removes an item's association with a project.
func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = $2
`, itemID, projectID)
return err
}
// RemoveItemFromProjectByCode removes an item's association with a project by code.
func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2)
`, itemID, projectCode)
return err
}
// GetProjectsForItem returns all projects associated with an item.
func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.id, p.code, p.name, p.description, p.created_at
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectCodesForItem returns project codes for an item (convenience method).
func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.code
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var codes []string
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
codes = append(codes, code)
}
return codes, rows.Err()
}
// GetItemsForProject returns all items associated with a project.
func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.cad_synced_at, i.cad_file_path
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
WHERE ip.project_id = $1 AND i.archived_at IS NULL
ORDER BY i.part_number
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Item
for rows.Next() {
item := &Item{}
if err := rows.Scan(
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// SetItemProjects replaces all project associations for an item.
func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
// Remove existing associations
_, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID)
if err != nil {
return err
}
// Add new associations
for _, code := range projectCodes {
_, err := tx.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, code)
if err != nil {
return err
}
}
return nil
})
}
// helper function
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -0,0 +1,53 @@
-- Migration 006: Project Tags (Many-to-Many)
--
-- Changes part number format from XXXXX-CCC-NNNN to CCC-NNNN
-- Migrates project codes to explicit many-to-many relationship
-- Create projects table for explicit project management
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(10) UNIQUE NOT NULL,
name TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Many-to-many junction table for item-project associations
CREATE TABLE item_projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(item_id, project_id)
);
-- Indexes for efficient queries
CREATE INDEX idx_item_projects_item ON item_projects(item_id);
CREATE INDEX idx_item_projects_project ON item_projects(project_id);
CREATE INDEX idx_projects_code ON projects(code);
-- Migrate existing data: Extract projects from old part numbers (XXXXX-CCC-NNNN format)
-- Only for part numbers that match the old format (5 char project + category + sequence)
INSERT INTO projects (code)
SELECT DISTINCT SUBSTRING(part_number FROM 1 FOR 5)
FROM items
WHERE part_number ~ '^[A-Z0-9]{5}-[A-Z0-9]+-[0-9]+$'
ON CONFLICT (code) DO NOTHING;
-- Link existing items to their extracted projects
INSERT INTO item_projects (item_id, project_id)
SELECT i.id, p.id
FROM items i
JOIN projects p ON p.code = SUBSTRING(i.part_number FROM 1 FOR 5)
WHERE i.part_number ~ '^[A-Z0-9]{5}-[A-Z0-9]+-[0-9]+$';
-- Migrate part numbers: Strip project prefix (XXXXX-CCC-NNNN → CCC-NNNN)
UPDATE items
SET part_number = SUBSTRING(part_number FROM 7) -- Skip "XXXXX-" (6 chars)
WHERE part_number ~ '^[A-Z0-9]{5}-[A-Z0-9]+-[0-9]+$';
-- Update revision properties to remove project field (it's now a tag)
-- Keep the data in properties for historical reference but mark it as migrated
UPDATE revisions
SET properties = properties || '{"_project_migrated": true}'::jsonb
WHERE properties ? 'project';

View File

@@ -0,0 +1,47 @@
-- Migration: Add revision status, labels, and comparison support
-- Phase 1 of enhanced revision control
-- Add status field to revisions
-- Values: 'draft' (default), 'review', 'released', 'obsolete'
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'draft';
-- Add labels array for arbitrary tags (e.g., 'prototype', 'v1.0', 'customer-approved')
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
-- Add index for filtering by status
CREATE INDEX IF NOT EXISTS idx_revisions_status ON revisions(status);
-- Add index for label searches (GIN index for array containment queries)
CREATE INDEX IF NOT EXISTS idx_revisions_labels ON revisions USING GIN(labels);
-- Add composite index for common query pattern (item + status)
CREATE INDEX IF NOT EXISTS idx_revisions_item_status ON revisions(item_id, status);
-- Create a view for easy revision comparison data
CREATE OR REPLACE VIEW revision_summary AS
SELECT
r.id,
r.item_id,
i.part_number,
r.revision_number,
r.status,
r.labels,
r.file_key IS NOT NULL AS has_file,
r.file_size,
r.file_checksum,
r.created_at,
r.created_by,
r.comment,
jsonb_object_keys(r.properties) AS property_keys,
(SELECT COUNT(*) FROM jsonb_object_keys(r.properties)) AS property_count
FROM revisions r
JOIN items i ON i.id = r.item_id;
-- Add check constraint for valid status values
ALTER TABLE revisions DROP CONSTRAINT IF EXISTS revisions_status_check;
ALTER TABLE revisions ADD CONSTRAINT revisions_status_check
CHECK (status IN ('draft', 'review', 'released', 'obsolete'));
-- Comment on new columns
COMMENT ON COLUMN revisions.status IS 'Workflow status: draft, review, released, obsolete';
COMMENT ON COLUMN revisions.labels IS 'Arbitrary tags/labels for categorization';

View File

@@ -18,6 +18,190 @@ SILO_PROJECTS_DIR = os.environ.get(
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
# Category name mapping for folder structure
# Format: CCC -> "descriptive_name"
CATEGORY_NAMES = {
# Fasteners
"F01": "screws_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_loop",
# Fluid Fittings
"C01": "full_couplings",
"C02": "half_couplings",
"C03": "reducers",
"C04": "elbows",
"C05": "tees",
"C06": "crosses",
"C07": "unions",
"C08": "adapters",
"C09": "plugs_caps",
"C10": "nipples",
"C11": "flanges",
"C12": "valves",
"C13": "quick_disconnects",
"C14": "hose_barbs",
"C15": "compression_fittings",
"C16": "tubing",
"C17": "hoses",
# 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_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",
# Structural Materials
"S01": "square_tube",
"S02": "round_tube",
"S03": "rectangular_tube",
"S04": "i_beam",
"S05": "t_slot_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",
# 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",
# 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",
# Tooling and Fixtures
"T01": "jigs",
"T02": "fixtures",
"T03": "molds",
"T04": "dies",
"T05": "gauges",
"T06": "templates",
"T07": "work_holding",
"T08": "test_fixtures",
# Assemblies
"A01": "mechanical_assembly",
"A02": "electrical_assembly",
"A03": "electromechanical_assembly",
"A04": "subassembly",
"A05": "cable_assembly",
"A06": "pneumatic_assembly",
"A07": "hydraulic_assembly",
# Purchased/Off-the-Shelf
"P01": "purchased_mechanical",
"P02": "purchased_electrical",
"P03": "purchased_assembly",
"P04": "raw_material",
"P05": "consumables",
# 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",
}
# Icon directory
def _get_icon_dir():
@@ -169,18 +353,21 @@ class SiloClient:
return self._request("GET", "/items?" + "&".join(params))
def create_item(
self, schema: str, project: str, category: str, description: str = ""
self,
schema: str,
category: str,
description: str = "",
projects: List[str] = None,
) -> Dict[str, Any]:
return self._request(
"POST",
"/items",
{
"schema": schema,
"project": project,
"category": category,
"description": description,
},
)
"""Create a new item with optional project tags."""
data = {
"schema": schema,
"category": category,
"description": description,
}
if projects:
data["projects"] = projects
return self._request("POST", "/items", data)
def update_item(
self, part_number: str, description: str = None, item_type: str = None
@@ -199,8 +386,21 @@ class SiloClient:
return self._request("GET", f"/schemas/{name}")
def get_projects(self) -> list:
"""Get list of all projects."""
return self._request("GET", "/projects")
def get_item_projects(self, part_number: str) -> list:
"""Get projects associated with an item."""
return self._request("GET", f"/items/{part_number}/projects")
def add_item_projects(
self, part_number: str, project_codes: List[str]
) -> Dict[str, Any]:
"""Add project tags to an item."""
return self._request(
"POST", f"/items/{part_number}/projects", {"projects": project_codes}
)
def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
"""Check if item has files in MinIO."""
try:
@@ -212,6 +412,39 @@ class SiloClient:
except Exception:
return False, None
def compare_revisions(
self, part_number: str, from_rev: int, to_rev: int
) -> Dict[str, Any]:
"""Compare two revisions and return differences."""
return self._request(
"GET",
f"/items/{part_number}/revisions/compare?from={from_rev}&to={to_rev}",
)
def rollback_revision(
self, part_number: str, revision: int, comment: str = ""
) -> Dict[str, Any]:
"""Create a new revision by rolling back to a previous one."""
data = {}
if comment:
data["comment"] = comment
return self._request(
"POST", f"/items/{part_number}/revisions/{revision}/rollback", data
)
def update_revision(
self, part_number: str, revision: int, status: str = None, labels: list = None
) -> Dict[str, Any]:
"""Update revision status and/or labels."""
data = {}
if status:
data["status"] = status
if labels is not None:
data["labels"] = labels
return self._request(
"PATCH", f"/items/{part_number}/revisions/{revision}", data
)
_client = SiloClient()
@@ -227,54 +460,86 @@ def sanitize_filename(name: str) -> str:
return sanitized[:50]
def parse_part_number(part_number: str) -> Tuple[str, str, str]:
"""Parse part number into (project, category, sequence)."""
def parse_part_number(part_number: str) -> Tuple[str, str]:
"""Parse part number into (category, sequence).
New format: CCC-NNNN (e.g., F01-0001)
Returns: (category_code, sequence)
"""
parts = part_number.split("-")
if len(parts) >= 3:
return parts[0], parts[1], parts[2]
return part_number, "", ""
if len(parts) >= 2:
return parts[0], parts[1]
return part_number, ""
def get_category_folder_name(category_code: str) -> str:
"""Get the folder name for a category (e.g., 'F01_screws_bolts')."""
name = CATEGORY_NAMES.get(category_code.upper(), "misc")
return f"{category_code}_{name}"
def get_cad_file_path(part_number: str, description: str = "") -> Path:
"""Generate canonical file path for a CAD file."""
project_code, _, _ = parse_part_number(part_number)
"""Generate canonical file path for a CAD file.
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd
Example: ~/projects/cad/F01_screws_bolts/F01-0001_M3_Socket_Screw.FCStd
"""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
if description:
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
else:
filename = f"{part_number}.FCStd"
return get_projects_dir() / project_code.lower() / "cad" / filename
return get_projects_dir() / "cad" / folder_name / filename
def find_file_by_part_number(part_number: str) -> Optional[Path]:
"""Find existing CAD file for a part number."""
project_code, _, _ = parse_part_number(part_number)
cad_dir = get_projects_dir() / project_code.lower() / "cad"
if not cad_dir.exists():
return None
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
return matches[0] if matches else None
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
cad_dir = get_projects_dir() / "cad" / folder_name
if cad_dir.exists():
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
# Also search in base cad directory (for older files or different structures)
base_cad_dir = get_projects_dir() / "cad"
if base_cad_dir.exists():
# Search all subdirectories
for subdir in base_cad_dir.iterdir():
if subdir.is_dir():
matches = list(subdir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
return None
def search_local_files(search_term: str = "", project_filter: str = "") -> list:
"""Search for CAD files in local projects directory."""
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
"""Search for CAD files in local cad directory."""
results = []
base_dir = get_projects_dir()
if not base_dir.exists():
cad_dir = get_projects_dir() / "cad"
if not cad_dir.exists():
return results
search_lower = search_term.lower()
for project_dir in base_dir.iterdir():
if not project_dir.is_dir():
continue
if project_filter and project_dir.name.lower() != project_filter.lower():
for category_dir in cad_dir.iterdir():
if not category_dir.is_dir():
continue
cad_dir = project_dir / "cad"
if not cad_dir.exists():
# Extract category code from folder name (e.g., "F01_screws_bolts" -> "F01")
folder_name = category_dir.name
category_code = folder_name.split("_")[0] if "_" in folder_name else folder_name
if category_filter and category_code.upper() != category_filter.upper():
continue
for fcstd_file in cad_dir.glob("*.FCStd"):
for fcstd_file in category_dir.glob("*.FCStd"):
filename = fcstd_file.stem
parts = filename.split("_", 1)
part_number = parts[0]
@@ -298,7 +563,7 @@ def search_local_files(search_term: str = "", project_filter: str = "") -> list:
"path": str(fcstd_file),
"part_number": part_number,
"description": description,
"project": project_dir.name.upper(),
"category": category_code,
"modified": modified,
"source": "local",
}
@@ -726,27 +991,7 @@ class Silo_New:
sel = FreeCADGui.Selection.getSelection()
# Project code
try:
projects = _client.get_projects()
if projects:
project, ok = QtGui.QInputDialog.getItem(
None, "New Item", "Project:", projects, 0, True
)
else:
project, ok = QtGui.QInputDialog.getText(
None, "New Item", "Project code (5 chars):"
)
except Exception:
project, ok = QtGui.QInputDialog.getText(
None, "New Item", "Project code (5 chars):"
)
if not ok or not project:
return
project = project.upper().strip()[:5]
# Category
# Category selection
try:
schema = _client.get_schema()
categories = schema.get("segments", [])
@@ -784,8 +1029,52 @@ class Silo_New:
if not ok:
return
# Optional project tagging
selected_projects = []
try:
result = _client.create_item("kindred-rd", project, category, description)
projects = _client.get_projects()
if projects:
project_codes = [p.get("code", "") for p in projects if p.get("code")]
if project_codes:
# Multi-select dialog for projects
dialog = QtGui.QDialog()
dialog.setWindowTitle("Tag with Projects (Optional)")
dialog.setMinimumWidth(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel("Select projects to tag this item with:")
layout.addWidget(label)
list_widget = QtGui.QListWidget()
list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection)
for code in project_codes:
list_widget.addItem(code)
layout.addWidget(list_widget)
btn_layout = QtGui.QHBoxLayout()
skip_btn = QtGui.QPushButton("Skip")
ok_btn = QtGui.QPushButton("Tag Selected")
btn_layout.addWidget(skip_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
skip_btn.clicked.connect(dialog.reject)
ok_btn.clicked.connect(dialog.accept)
if dialog.exec_() == QtGui.QDialog.Accepted:
selected_projects = [
item.text() for item in list_widget.selectedItems()
]
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
try:
result = _client.create_item(
"kindred-rd",
category,
description,
projects=selected_projects if selected_projects else None,
)
part_number = result["part_number"]
if sel:
@@ -800,10 +1089,12 @@ class Silo_New:
# Create new document
_sync.create_document_for_item(result, save=True)
msg = f"Part number: {part_number}"
if selected_projects:
msg += f"\nTagged with projects: {', '.join(selected_projects)}"
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
QtGui.QMessageBox.information(
None, "Item Created", f"Part number: {part_number}"
)
QtGui.QMessageBox.information(None, "Item Created", msg)
except Exception as e:
QtGui.QMessageBox.critical(None, "Error", str(e))
@@ -1143,22 +1434,39 @@ class Silo_Info:
item = _client.get_item(part_number)
revisions = _client.get_revisions(part_number)
# Get projects for item
try:
projects = _client.get_item_projects(part_number)
project_codes = [p.get("code", "") for p in projects if p.get("code")]
except Exception:
project_codes = []
msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
has_file, _ = _client.has_file(part_number)
msg += f"<p><b>File in MinIO:</b> {'Yes' if has_file else 'No'}</p>"
# Show current revision status
if revisions:
current_status = revisions[0].get("status", "draft")
current_labels = revisions[0].get("labels", [])
msg += f"<p><b>Current Status:</b> {current_status}</p>"
if current_labels:
msg += f"<p><b>Labels:</b> {', '.join(current_labels)}</p>"
msg += "<h4>Revision History</h4><table border='1' cellpadding='4'>"
msg += "<tr><th>Rev</th><th>Date</th><th>File</th><th>Comment</th></tr>"
msg += "<tr><th>Rev</th><th>Status</th><th>Date</th><th>File</th><th>Comment</th></tr>"
for rev in revisions:
file_icon = "" if rev.get("file_key") else "-"
comment = rev.get("comment", "") or "-"
date = rev.get("created_at", "")[:10]
msg += f"<tr><td>{rev['revision_number']}</td><td>{date}</td><td>{file_icon}</td><td>{comment}</td></tr>"
status = rev.get("status", "draft")
msg += f"<tr><td>{rev['revision_number']}</td><td>{status}</td><td>{date}</td><td>{file_icon}</td><td>{comment}</td></tr>"
msg += "</table>"
dialog = QtGui.QMessageBox()
@@ -1174,6 +1482,309 @@ class Silo_Info:
return FreeCAD.ActiveDocument is not None
class Silo_TagProjects:
"""Manage project tags for an item."""
def GetResources(self):
return {
"MenuText": "Tag Projects",
"ToolTip": "Add or remove project tags for an item",
"Pixmap": _icon("tag"),
}
def Activated(self):
from PySide import QtGui
doc = FreeCAD.ActiveDocument
if not doc:
FreeCAD.Console.PrintError("No active document\n")
return
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object\n")
return
part_number = obj.SiloPartNumber
try:
# Get current projects for item
current_projects = _client.get_item_projects(part_number)
current_codes = {
p.get("code", "") for p in current_projects if p.get("code")
}
# Get all available projects
all_projects = _client.get_projects()
all_codes = [p.get("code", "") for p in all_projects if p.get("code")]
if not all_codes:
QtGui.QMessageBox.information(
None,
"Tag Projects",
"No projects available. Create projects first.",
)
return
# Multi-select dialog
dialog = QtGui.QDialog()
dialog.setWindowTitle(f"Tag Projects for {part_number}")
dialog.setMinimumWidth(350)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel("Select projects to associate with this item:")
layout.addWidget(label)
list_widget = QtGui.QListWidget()
list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection)
for code in all_codes:
item = QtGui.QListWidgetItem(code)
list_widget.addItem(item)
if code in current_codes:
item.setSelected(True)
layout.addWidget(list_widget)
btn_layout = QtGui.QHBoxLayout()
cancel_btn = QtGui.QPushButton("Cancel")
save_btn = QtGui.QPushButton("Save")
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
cancel_btn.clicked.connect(dialog.reject)
save_btn.clicked.connect(dialog.accept)
if dialog.exec_() == QtGui.QDialog.Accepted:
selected = [item.text() for item in list_widget.selectedItems()]
# Add new tags
to_add = [c for c in selected if c not in current_codes]
if to_add:
_client.add_item_projects(part_number, to_add)
# Note: removing tags would require a separate API call per project
# For simplicity, we just add new ones here
msg = f"Updated project tags for {part_number}"
if to_add:
msg += f"\nAdded: {', '.join(to_add)}"
QtGui.QMessageBox.information(None, "Tag Projects", msg)
FreeCAD.Console.PrintMessage(f"{msg}\n")
except Exception as e:
QtGui.QMessageBox.warning(None, "Tag Projects", f"Failed: {e}")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
class Silo_Rollback:
"""Rollback to a previous revision."""
def GetResources(self):
return {
"MenuText": "Rollback",
"ToolTip": "Rollback to a previous revision",
"Pixmap": _icon("rollback"),
}
def Activated(self):
from PySide import QtCore, QtGui
doc = FreeCAD.ActiveDocument
if not doc:
FreeCAD.Console.PrintError("No active document\n")
return
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object\n")
return
part_number = obj.SiloPartNumber
try:
revisions = _client.get_revisions(part_number)
if len(revisions) < 2:
QtGui.QMessageBox.information(
None, "Rollback", "No previous revisions to rollback to."
)
return
# Build revision list for selection (exclude current/latest)
current_rev = revisions[0]["revision_number"]
prev_revisions = revisions[1:] # All except latest
# Create selection dialog
dialog = QtGui.QDialog()
dialog.setWindowTitle(f"Rollback {part_number}")
dialog.setMinimumWidth(500)
dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel(
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
layout.addWidget(label)
# Revision table
table = QtGui.QTableWidget()
table.setColumnCount(4)
table.setHorizontalHeaderLabels(["Rev", "Status", "Date", "Comment"])
table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
table.setRowCount(len(prev_revisions))
table.horizontalHeader().setStretchLastSection(True)
for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem(
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.resizeColumnsToContents()
layout.addWidget(table)
# Comment field
comment_label = QtGui.QLabel("Rollback comment (optional):")
layout.addWidget(comment_label)
comment_input = QtGui.QLineEdit()
comment_input.setPlaceholderText("Reason for rollback...")
layout.addWidget(comment_input)
# Buttons
btn_layout = QtGui.QHBoxLayout()
cancel_btn = QtGui.QPushButton("Cancel")
rollback_btn = QtGui.QPushButton("Rollback")
btn_layout.addStretch()
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(rollback_btn)
layout.addLayout(btn_layout)
selected_rev = [None]
def on_rollback():
selected = table.selectedItems()
if not selected:
QtGui.QMessageBox.warning(
dialog, "Rollback", "Please select a revision"
)
return
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept()
cancel_btn.clicked.connect(dialog.reject)
rollback_btn.clicked.connect(on_rollback)
if dialog.exec_() == QtGui.QDialog.Accepted and selected_rev[0]:
target_rev = selected_rev[0]
comment = comment_input.text().strip()
# Confirm
reply = QtGui.QMessageBox.question(
None,
"Confirm Rollback",
f"Create new revision by rolling back to Rev {target_rev}?\n\n"
"This will copy properties and file reference from the selected revision.",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
# Perform rollback
result = _client.rollback_revision(part_number, target_rev, comment)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(
f"Created revision {new_rev} (rollback from {target_rev})\n"
)
QtGui.QMessageBox.information(
None,
"Rollback Complete",
f"Created revision {new_rev} from rollback to Rev {target_rev}.\n\n"
"Use 'Pull' to download the rolled-back file.",
)
except Exception as e:
QtGui.QMessageBox.warning(None, "Rollback", f"Failed: {e}")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
class Silo_SetStatus:
"""Set revision status (draft, review, released, obsolete)."""
def GetResources(self):
return {
"MenuText": "Set Status",
"ToolTip": "Set the status of the current revision",
"Pixmap": _icon("status"),
}
def Activated(self):
from PySide import QtGui
doc = FreeCAD.ActiveDocument
if not doc:
FreeCAD.Console.PrintError("No active document\n")
return
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object\n")
return
part_number = obj.SiloPartNumber
local_rev = getattr(obj, "SiloRevision", 1)
try:
# Get current revision info
revisions = _client.get_revisions(part_number)
current_rev = revisions[0] if revisions else None
if not current_rev:
QtGui.QMessageBox.warning(None, "Set Status", "No revisions found")
return
current_status = current_rev.get("status", "draft")
rev_num = current_rev["revision_number"]
# Status selection
statuses = ["draft", "review", "released", "obsolete"]
status, ok = QtGui.QInputDialog.getItem(
None,
"Set Revision Status",
f"Set status for Rev {rev_num} (current: {current_status}):",
statuses,
statuses.index(current_status),
False,
)
if not ok or status == current_status:
return
# Update status
_client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage(
f"Updated Rev {rev_num} status to '{status}'\n"
)
QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
)
except Exception as e:
QtGui.QMessageBox.warning(None, "Set Status", f"Failed: {e}")
def IsActive(self):
return FreeCAD.ActiveDocument is not None
# Register commands
FreeCADGui.addCommand("Silo_Open", Silo_Open())
FreeCADGui.addCommand("Silo_New", Silo_New())
@@ -1182,3 +1793,6 @@ FreeCADGui.addCommand("Silo_Commit", Silo_Commit())
FreeCADGui.addCommand("Silo_Pull", Silo_Pull())
FreeCADGui.addCommand("Silo_Push", Silo_Push())
FreeCADGui.addCommand("Silo_Info", Silo_Info())
FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())

View File

@@ -1,22 +1,24 @@
# Kindred Systems R&D Part Numbering Schema
#
# Format: XXXXX-CCC-NNNN
# XXXXX = Project code (5 alphanumeric chars)
# Format: CCC-NNNN
# CCC = Category/subcategory code (e.g., F01, R27)
# NNNN = Sequence (4 alphanumeric, scoped per category)
# NNNN = Sequence (4 digits, 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)
# F01-0001 (Screws/Bolts, seq 1)
# R27-0001 (Servo Motor, seq 1)
# S05-0002 (T-Slot Extrusion, seq 2)
#
# Note: Projects are managed separately as tags (many-to-many relationship).
# Parts can be associated with multiple projects.
#
# 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"
version: 3
description: "Kindred Systems R&D part numbering (category-sequence)"
separator: "-"
@@ -25,17 +27,6 @@ schema:
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
@@ -118,7 +109,7 @@ schema:
R34: "Pneumatic Actuator"
R35: "Pneumatic Valve"
R36: "Pneumatic Regulator"
R37: "Pneumatic FRL Unit"
R37: "Other Pneumatic Device"
R38: "Air Compressor"
R39: "Vacuum Pump"
R40: "Hydraulic Cylinder"
@@ -231,23 +222,23 @@ schema:
X07: "Laser Cut Part"
X08: "Waterjet Cut Part"
# Sequence number (alphanumeric, scoped per category)
# Sequence number (scoped per category)
- name: sequence
type: serial
length: 4
padding: "0"
start: 1
description: "Sequential identifier (alphanumeric, per category)"
description: "Sequential identifier (per category)"
scope: "{category}"
format: "{project}-{category}-{sequence}"
format: "{category}-{sequence}"
examples:
- "CS100-F01-0001"
- "3DX15-R27-0001"
- "3DX10-S05-0002"
- "CS100-E20-0001"
- "3DX15-A01-0001"
- "F01-0001"
- "R27-0001"
- "S05-0002"
- "E20-0001"
- "A01-0001"
# Property schemas per category
property_schemas: