Add revision control and project tagging migration
This commit is contained in:
487
docs/GAP_ANALYSIS.md
Normal file
487
docs/GAP_ANALYSIS.md
Normal 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
|
||||
```
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')">×</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();
|
||||
|
||||
@@ -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
299
internal/db/projects.go
Normal 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
|
||||
}
|
||||
53
migrations/006_project_tags.sql
Normal file
53
migrations/006_project_tags.sql
Normal 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';
|
||||
47
migrations/007_revision_status.sql
Normal file
47
migrations/007_revision_status.sql
Normal 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';
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user