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",
|
"current_revision",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"project",
|
|
||||||
"category",
|
"category",
|
||||||
|
"projects", // comma-separated project codes
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleExportCSV exports items to CSV format.
|
// HandleExportCSV exports items to CSV format.
|
||||||
@@ -129,8 +129,19 @@ func (s *Server) HandleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
row := make([]string, len(headers))
|
row := make([]string, len(headers))
|
||||||
|
|
||||||
// Parse part number to extract project and category
|
// Extract category from part number (format: CCC-NNNN)
|
||||||
project, category := parsePartNumber(item.PartNumber)
|
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
|
// Standard columns
|
||||||
row[0] = item.PartNumber
|
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[3] = strconv.Itoa(item.CurrentRevision)
|
||||||
row[4] = item.CreatedAt.Format(time.RFC3339)
|
row[4] = item.CreatedAt.Format(time.RFC3339)
|
||||||
row[5] = item.UpdatedAt.Format(time.RFC3339)
|
row[5] = item.UpdatedAt.Format(time.RFC3339)
|
||||||
row[6] = project
|
row[6] = category
|
||||||
row[7] = category
|
row[7] = projectCodes
|
||||||
|
|
||||||
// Property columns
|
// Property columns
|
||||||
if includeProps {
|
if includeProps {
|
||||||
@@ -206,8 +217,8 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
colIndex[strings.ToLower(strings.TrimSpace(h))] = i
|
colIndex[strings.ToLower(strings.TrimSpace(h))] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required columns
|
// Validate required columns - only category is required now (projects are optional tags)
|
||||||
requiredCols := []string{"project", "category"}
|
requiredCols := []string{"category"}
|
||||||
for _, col := range requiredCols {
|
for _, col := range requiredCols {
|
||||||
if _, ok := colIndex[col]; !ok {
|
if _, ok := colIndex[col]; !ok {
|
||||||
writeError(w, http.StatusBadRequest, "missing_column", fmt.Sprintf("Required column '%s' not found", col))
|
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++
|
rowNum++
|
||||||
|
|
||||||
// Extract values
|
// Extract values
|
||||||
project := getCSVValue(record, colIndex, "project")
|
|
||||||
category := getCSVValue(record, colIndex, "category")
|
category := getCSVValue(record, colIndex, "category")
|
||||||
description := getCSVValue(record, colIndex, "description")
|
description := getCSVValue(record, colIndex, "description")
|
||||||
partNumber := getCSVValue(record, colIndex, "part_number")
|
partNumber := getCSVValue(record, colIndex, "part_number")
|
||||||
|
projectsStr := getCSVValue(record, colIndex, "projects")
|
||||||
|
|
||||||
// Validate project
|
// Parse project codes (comma-separated)
|
||||||
if project == "" {
|
var projectCodes []string
|
||||||
result.Errors = append(result.Errors, CSVImportErr{
|
if projectsStr != "" {
|
||||||
Row: rowNum,
|
for _, code := range strings.Split(projectsStr, ",") {
|
||||||
Field: "project",
|
code = strings.TrimSpace(strings.ToUpper(code))
|
||||||
Message: "Project code is required",
|
if code != "" {
|
||||||
})
|
projectCodes = append(projectCodes, code)
|
||||||
result.ErrorCount++
|
}
|
||||||
continue
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate category
|
// Validate category
|
||||||
@@ -270,7 +281,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Build properties from extra columns
|
// Build properties from extra columns
|
||||||
properties := make(map[string]any)
|
properties := make(map[string]any)
|
||||||
properties["project"] = strings.ToUpper(project)
|
|
||||||
properties["category"] = strings.ToUpper(category)
|
properties["category"] = strings.ToUpper(category)
|
||||||
|
|
||||||
for col, idx := range colIndex {
|
for col, idx := range colIndex {
|
||||||
@@ -308,7 +318,6 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
input := partnum.Input{
|
input := partnum.Input{
|
||||||
SchemaName: schemaName,
|
SchemaName: schemaName,
|
||||||
Values: map[string]string{
|
Values: map[string]string{
|
||||||
"project": strings.ToUpper(project),
|
|
||||||
"category": strings.ToUpper(category),
|
"category": strings.ToUpper(category),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -351,6 +360,18 @@ func (s *Server) HandleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
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.SuccessCount++
|
||||||
result.CreatedItems = append(result.CreatedItems, partNumber)
|
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
|
// Build headers: standard columns + default property columns from schema
|
||||||
headers := []string{
|
headers := []string{
|
||||||
"project",
|
|
||||||
"category",
|
"category",
|
||||||
"description",
|
"description",
|
||||||
|
"projects", // comma-separated project codes (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default property columns from schema
|
// Add default property columns from schema
|
||||||
@@ -411,9 +432,9 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Write example row
|
// Write example row
|
||||||
exampleRow := make([]string, len(headers))
|
exampleRow := make([]string, len(headers))
|
||||||
exampleRow[0] = "PROJ1" // project
|
exampleRow[0] = "F01" // category
|
||||||
exampleRow[1] = "F01" // category
|
exampleRow[1] = "Example Item Description" // description
|
||||||
exampleRow[2] = "Example Item Description"
|
exampleRow[2] = "PROJ1,PROJ2" // projects (comma-separated)
|
||||||
// Leave property columns empty
|
// Leave property columns empty
|
||||||
|
|
||||||
if err := writer.Write(exampleRow); err != nil {
|
if err := writer.Write(exampleRow); err != nil {
|
||||||
@@ -424,13 +445,13 @@ func (s *Server) HandleCSVTemplate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Helper functions
|
// 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, "-")
|
parts := strings.Split(pn, "-")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 1 {
|
||||||
project = parts[0]
|
return parts[0]
|
||||||
category = parts[1]
|
|
||||||
}
|
}
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPropertyValue(v any) string {
|
func formatPropertyValue(v any) string {
|
||||||
@@ -509,8 +530,8 @@ func isStandardColumn(col string) bool {
|
|||||||
"current_revision": true,
|
"current_revision": true,
|
||||||
"created_at": true,
|
"created_at": true,
|
||||||
"updated_at": true,
|
"updated_at": true,
|
||||||
"project": true,
|
|
||||||
"category": true,
|
"category": true,
|
||||||
|
"projects": true,
|
||||||
}
|
}
|
||||||
return standardCols[col]
|
return standardCols[col]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -22,6 +23,7 @@ type Server struct {
|
|||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
db *db.DB
|
db *db.DB
|
||||||
items *db.ItemRepository
|
items *db.ItemRepository
|
||||||
|
projects *db.ProjectRepository
|
||||||
schemas map[string]*schema.Schema
|
schemas map[string]*schema.Schema
|
||||||
schemasDir string
|
schemasDir string
|
||||||
partgen *partnum.Generator
|
partgen *partnum.Generator
|
||||||
@@ -37,6 +39,7 @@ func NewServer(
|
|||||||
store *storage.Storage,
|
store *storage.Storage,
|
||||||
) *Server {
|
) *Server {
|
||||||
items := db.NewItemRepository(database)
|
items := db.NewItemRepository(database)
|
||||||
|
projects := db.NewProjectRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ func NewServer(
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
db: database,
|
db: database,
|
||||||
items: items,
|
items: items,
|
||||||
|
projects: projects,
|
||||||
schemas: schemas,
|
schemas: schemas,
|
||||||
schemasDir: schemasDir,
|
schemasDir: schemasDir,
|
||||||
partgen: partgen,
|
partgen: partgen,
|
||||||
@@ -214,9 +218,9 @@ type ItemResponse struct {
|
|||||||
// CreateItemRequest represents a request to create an item.
|
// CreateItemRequest represents a request to create an item.
|
||||||
type CreateItemRequest struct {
|
type CreateItemRequest struct {
|
||||||
Schema string `json:"schema"`
|
Schema string `json:"schema"`
|
||||||
Project string `json:"project"`
|
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Projects []string `json:"projects,omitempty"`
|
||||||
Properties map[string]any `json:"properties,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)
|
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.
|
// HandleCreateItem creates a new item with generated part number.
|
||||||
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -286,11 +276,10 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
schemaName = "kindred-rd"
|
schemaName = "kindred-rd"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate part number
|
// Generate part number (no longer includes project)
|
||||||
input := partnum.Input{
|
input := partnum.Input{
|
||||||
SchemaName: schemaName,
|
SchemaName: schemaName,
|
||||||
Values: map[string]string{
|
Values: map[string]string{
|
||||||
"project": req.Project,
|
|
||||||
"category": req.Category,
|
"category": req.Category,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -308,8 +297,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch req.Category[0] {
|
switch req.Category[0] {
|
||||||
case 'A':
|
case 'A':
|
||||||
itemType = "assembly"
|
itemType = "assembly"
|
||||||
case 'D':
|
|
||||||
itemType = "document"
|
|
||||||
case 'T':
|
case 'T':
|
||||||
itemType = "tooling"
|
itemType = "tooling"
|
||||||
}
|
}
|
||||||
@@ -326,7 +313,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
if properties == nil {
|
if properties == nil {
|
||||||
properties = make(map[string]any)
|
properties = make(map[string]any)
|
||||||
}
|
}
|
||||||
properties["project"] = req.Project
|
|
||||||
properties["category"] = req.Category
|
properties["category"] = req.Category
|
||||||
|
|
||||||
if err := s.items.Create(ctx, item, properties); err != nil {
|
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
|
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))
|
writeJSON(w, http.StatusCreated, itemToResponse(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +489,32 @@ type RevisionResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
CreatedBy *string `json:"created_by,omitempty"`
|
CreatedBy *string `json:"created_by,omitempty"`
|
||||||
Comment *string `json:"comment,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.
|
// 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")
|
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
|
// Part number generation
|
||||||
|
|
||||||
// GeneratePartNumberRequest represents a request to generate a part number.
|
// 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 {
|
func revisionToResponse(rev *db.Revision) RevisionResponse {
|
||||||
|
labels := rev.Labels
|
||||||
|
if labels == nil {
|
||||||
|
labels = []string{}
|
||||||
|
}
|
||||||
return RevisionResponse{
|
return RevisionResponse{
|
||||||
ID: rev.ID,
|
ID: rev.ID,
|
||||||
RevisionNumber: rev.RevisionNumber,
|
RevisionNumber: rev.RevisionNumber,
|
||||||
@@ -821,6 +1012,8 @@ func revisionToResponse(rev *db.Revision) RevisionResponse {
|
|||||||
CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
CreatedBy: rev.CreatedBy,
|
CreatedBy: rev.CreatedBy,
|
||||||
Comment: rev.Comment,
|
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")
|
rctx.URLParams.Add("revision", "latest")
|
||||||
s.HandleDownloadFile(w, r)
|
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)
|
// Projects
|
||||||
r.Get("/projects", server.HandleListProjects)
|
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
|
// Items
|
||||||
r.Route("/items", func(r chi.Router) {
|
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.Put("/", server.HandleUpdateItem)
|
||||||
r.Delete("/", server.HandleDeleteItem)
|
r.Delete("/", server.HandleDeleteItem)
|
||||||
|
|
||||||
|
// Item project tags
|
||||||
|
r.Get("/projects", server.HandleGetItemProjects)
|
||||||
|
r.Post("/projects", server.HandleAddItemProjects)
|
||||||
|
r.Delete("/projects/{code}", server.HandleRemoveItemProject)
|
||||||
|
|
||||||
// Revisions
|
// Revisions
|
||||||
r.Get("/revisions", server.HandleListRevisions)
|
r.Get("/revisions", server.HandleListRevisions)
|
||||||
r.Post("/revisions", server.HandleCreateRevision)
|
r.Post("/revisions", server.HandleCreateRevision)
|
||||||
|
r.Get("/revisions/compare", server.HandleCompareRevisions)
|
||||||
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
r.Get("/revisions/{revision}", server.HandleGetRevision)
|
||||||
|
r.Patch("/revisions/{revision}", server.HandleUpdateRevision)
|
||||||
|
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
|
||||||
|
|
||||||
// File upload/download
|
// File upload/download
|
||||||
r.Post("/file", server.HandleUploadFile)
|
r.Post("/file", server.HandleUploadFile)
|
||||||
|
|||||||
@@ -126,13 +126,10 @@
|
|||||||
<div class="search-help" id="search-help">
|
<div class="search-help" id="search-help">
|
||||||
<div class="search-help-title">Search Tips</div>
|
<div class="search-help-title">Search Tips</div>
|
||||||
<div class="search-help-item">
|
<div class="search-help-item">
|
||||||
<code>3DX15</code> - Search by project code
|
<code>F01</code> - Search by category code
|
||||||
</div>
|
</div>
|
||||||
<div class="search-help-item">
|
<div class="search-help-item">
|
||||||
<code>A01</code> - Search by category
|
<code>F01-0001</code> - Full part number
|
||||||
</div>
|
|
||||||
<div class="search-help-item">
|
|
||||||
<code>3DX15-A01</code> - Project + category
|
|
||||||
</div>
|
</div>
|
||||||
<div class="search-help-item">
|
<div class="search-help-item">
|
||||||
<code>0001</code> - Search by sequence
|
<code>0001</code> - Search by sequence
|
||||||
@@ -202,36 +199,21 @@
|
|||||||
>
|
>
|
||||||
<option value="">Start from scratch...</option>
|
<option value="">Start from scratch...</option>
|
||||||
<option value="machined-part">
|
<option value="machined-part">
|
||||||
Machined Part (M-category)
|
Machined Part (X-category)
|
||||||
</option>
|
</option>
|
||||||
<option value="printed-part">
|
<option value="printed-part">
|
||||||
3D Printed Part (F-category)
|
3D Printed Part (X-category)
|
||||||
</option>
|
</option>
|
||||||
<option value="fastener">Fastener (R-category)</option>
|
<option value="fastener">Fastener (F-category)</option>
|
||||||
<option value="electronics">
|
<option value="electronics">
|
||||||
Electronics (E-category)
|
Electronics (E-category)
|
||||||
</option>
|
</option>
|
||||||
<option value="assembly">Assembly (A-category)</option>
|
<option value="assembly">Assembly (A-category)</option>
|
||||||
<option value="purchased">
|
<option value="purchased">
|
||||||
Purchased/COTS (S-category)
|
Purchased/COTS (P-category)
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<select class="form-input" id="category" required>
|
<select class="form-input" id="category" required>
|
||||||
@@ -247,6 +229,19 @@
|
|||||||
placeholder="Item description"
|
placeholder="Item description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -414,13 +409,13 @@
|
|||||||
<div class="import-instructions">
|
<div class="import-instructions">
|
||||||
<p>Upload a CSV file to bulk import items. Required columns:</p>
|
<p>Upload a CSV file to bulk import items. Required columns:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>project</code> - 5-character project code</li>
|
|
||||||
<li>
|
<li>
|
||||||
<code>category</code> - Category code (e.g., F01, A01)
|
<code>category</code> - Category code (e.g., F01, A01)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Optional columns: <code>description</code>,
|
Optional columns: <code>description</code>,
|
||||||
|
<code>projects</code> (comma-separated project codes),
|
||||||
<code>part_number</code>, and any property columns.
|
<code>part_number</code>, and any property columns.
|
||||||
</p>
|
</p>
|
||||||
<a href="/api/items/template.csv" class="template-link"
|
<a href="/api/items/template.csv" class="template-link"
|
||||||
@@ -991,6 +986,162 @@
|
|||||||
color: var(--ctp-subtext0);
|
color: var(--ctp-subtext0);
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -1022,14 +1173,17 @@
|
|||||||
|
|
||||||
// Item templates for quick creation
|
// Item templates for quick creation
|
||||||
const itemTemplates = {
|
const itemTemplates = {
|
||||||
"machined-part": { category: "M09", descPrefix: "MACHINED " },
|
"machined-part": { category: "X01", descPrefix: "MACHINED " },
|
||||||
"printed-part": { category: "F10", descPrefix: "3DP " },
|
"printed-part": { category: "X03", descPrefix: "3DP " },
|
||||||
fastener: { category: "R01", descPrefix: "" },
|
fastener: { category: "F01", descPrefix: "" },
|
||||||
electronics: { category: "E01", descPrefix: "" },
|
electronics: { category: "E01", descPrefix: "" },
|
||||||
assembly: { category: "A01", 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
|
// Load schema for create form
|
||||||
async function loadSchema() {
|
async function loadSchema() {
|
||||||
try {
|
try {
|
||||||
@@ -1061,7 +1215,9 @@
|
|||||||
async function loadProjectCodes() {
|
async function loadProjectCodes() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/projects");
|
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
|
// Populate project filter dropdown
|
||||||
const projectFilter = document.getElementById("project-filter");
|
const projectFilter = document.getElementById("project-filter");
|
||||||
@@ -1072,19 +1228,56 @@
|
|||||||
projectFilter.appendChild(option);
|
projectFilter.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate create form project dropdown
|
// Populate create form project tag dropdown
|
||||||
const projectSelect = document.getElementById("project");
|
const projectSelect = document.getElementById("project-select");
|
||||||
projectCodes.forEach((code) => {
|
if (projectSelect) {
|
||||||
const option = document.createElement("option");
|
projectCodes.forEach((code) => {
|
||||||
option.value = code;
|
const option = document.createElement("option");
|
||||||
option.textContent = code;
|
option.value = code;
|
||||||
projectSelect.appendChild(option);
|
option.textContent = code;
|
||||||
});
|
projectSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load project codes:", 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
|
// Apply template to create form
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
const templateId = document.getElementById("template").value;
|
const templateId = document.getElementById("template").value;
|
||||||
@@ -1230,36 +1423,31 @@
|
|||||||
// Create Modal functions
|
// Create Modal functions
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
document.getElementById("create-modal").classList.add("active");
|
document.getElementById("create-modal").classList.add("active");
|
||||||
|
selectedProjectTags = [];
|
||||||
|
renderSelectedTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
document.getElementById("create-modal").classList.remove("active");
|
document.getElementById("create-modal").classList.remove("active");
|
||||||
document.getElementById("create-form").reset();
|
document.getElementById("create-form").reset();
|
||||||
|
selectedProjectTags = [];
|
||||||
|
renderSelectedTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createItem(event) {
|
async function createItem(event) {
|
||||||
event.preventDefault();
|
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 = {
|
const data = {
|
||||||
schema: "kindred-rd",
|
schema: "kindred-rd",
|
||||||
project: project,
|
|
||||||
category: document.getElementById("category").value,
|
category: document.getElementById("category").value,
|
||||||
description: document.getElementById("description").value,
|
description: document.getElementById("description").value,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add project tags if any selected
|
||||||
|
if (selectedProjectTags.length > 0) {
|
||||||
|
data.projects = selectedProjectTags;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/items", {
|
const response = await fetch("/api/items", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1274,7 +1462,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = await response.json();
|
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();
|
closeCreateModal();
|
||||||
loadItems();
|
loadItems();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1424,9 +1616,27 @@
|
|||||||
fetch(`/api/items/${partNumber}/revisions`),
|
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 item = await itemRes.json();
|
||||||
const revisions = await revsRes.json();
|
const revisions = await revsRes.json();
|
||||||
|
|
||||||
|
// Ensure revisions is an array
|
||||||
|
if (!Array.isArray(revisions)) {
|
||||||
|
throw new Error("Invalid revisions response");
|
||||||
|
}
|
||||||
|
|
||||||
currentDetailItem = item;
|
currentDetailItem = item;
|
||||||
currentDetailRevisions = revisions;
|
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
|
// Info tab
|
||||||
document.getElementById("tab-info").innerHTML = `
|
document.getElementById("tab-info").innerHTML = `
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<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>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>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>Description:</strong> ${item.description || "-"}</p>
|
||||||
|
<p><strong>Projects:</strong> ${projectTagsHtml}</p>
|
||||||
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
|
<p><strong>Current Revision:</strong> ${item.current_revision}</p>
|
||||||
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
|
<p><strong>Created:</strong> ${formatDate(item.created_at)}</p>
|
||||||
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
|
<p><strong>Updated:</strong> ${formatDate(item.updated_at)}</p>
|
||||||
@@ -1491,27 +1724,55 @@
|
|||||||
// Properties tab
|
// Properties tab
|
||||||
renderPropertiesTab(item.properties || {}, item.current_revision);
|
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 = `
|
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">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rev</th>
|
<th>Rev</th>
|
||||||
|
<th>Status</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
<th>Comment</th>
|
<th>Comment</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${revisions
|
${revisions
|
||||||
.map(
|
.map(
|
||||||
(rev) => `
|
(rev, idx) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${rev.revision_number}</td>
|
<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>${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.file_key ? `<button class="copy-btn" onclick="downloadFile('${partNumber}', ${rev.revision_number})" title="Download">${icons.download}</button>` : "-"}</td>
|
||||||
<td>${rev.comment || "-"}</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>
|
</tr>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -1520,6 +1781,14 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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) {
|
} catch (error) {
|
||||||
document.getElementById("tab-info").innerHTML =
|
document.getElementById("tab-info").innerHTML =
|
||||||
`<p>Error loading item: ${error.message}</p>`;
|
`<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
|
// Initialize
|
||||||
loadSchema();
|
loadSchema();
|
||||||
loadProjectCodes();
|
loadProjectCodes();
|
||||||
|
|||||||
@@ -38,6 +38,35 @@ type Revision struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
CreatedBy *string
|
CreatedBy *string
|
||||||
Comment *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.
|
// 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.
|
// List retrieves items with optional filtering.
|
||||||
func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) {
|
func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) {
|
||||||
query := `
|
// Build query - use JOIN if filtering by project
|
||||||
SELECT id, part_number, schema_id, item_type, description,
|
var query string
|
||||||
created_at, updated_at, archived_at, current_revision
|
|
||||||
FROM items
|
|
||||||
WHERE archived_at IS NULL
|
|
||||||
`
|
|
||||||
args := []any{}
|
args := []any{}
|
||||||
argNum := 1
|
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 != "" {
|
if opts.ItemType != "" {
|
||||||
query += fmt.Sprintf(" AND item_type = $%d", argNum)
|
query += fmt.Sprintf(" AND item_type = $%d", argNum)
|
||||||
args = append(args, opts.ItemType)
|
args = append(args, opts.ItemType)
|
||||||
argNum++
|
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 != "" {
|
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+"%")
|
args = append(args, "%"+opts.Search+"%")
|
||||||
argNum++
|
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 {
|
if opts.Limit > 0 {
|
||||||
query += fmt.Sprintf(" LIMIT $%d", argNum)
|
query += fmt.Sprintf(" LIMIT $%d", argNum)
|
||||||
@@ -194,13 +241,11 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
|
|||||||
return items, nil
|
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) {
|
func (r *ItemRepository) ListProjects(ctx context.Context) ([]string, error) {
|
||||||
rows, err := r.db.pool.Query(ctx, `
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
SELECT DISTINCT SUBSTRING(part_number FROM 1 FOR 5) as project_code
|
SELECT code FROM projects ORDER BY code
|
||||||
FROM items
|
|
||||||
WHERE archived_at IS NULL
|
|
||||||
ORDER BY project_code
|
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying projects: %w", err)
|
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.
|
// GetRevisions retrieves all revisions for an item.
|
||||||
func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) {
|
func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) {
|
||||||
rows, err := r.db.pool.Query(ctx, `
|
// Check if status column exists (migration 007 applied)
|
||||||
SELECT id, item_id, revision_number, properties, file_key, file_version,
|
var hasStatusColumn bool
|
||||||
file_checksum, file_size, thumbnail_key, created_at, created_by, comment
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
FROM revisions
|
SELECT EXISTS (
|
||||||
WHERE item_id = $1
|
SELECT 1 FROM information_schema.columns
|
||||||
ORDER BY revision_number DESC
|
WHERE table_name = 'revisions' AND column_name = 'status'
|
||||||
`, itemID)
|
)
|
||||||
|
`).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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying revisions: %w", err)
|
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() {
|
for rows.Next() {
|
||||||
rev := &Revision{}
|
rev := &Revision{}
|
||||||
var propsJSON []byte
|
var propsJSON []byte
|
||||||
err := rows.Scan(
|
if hasStatusColumn {
|
||||||
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
|
err = rows.Scan(
|
||||||
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
|
&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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning revision: %w", err)
|
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
|
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.
|
// Archive soft-deletes an item.
|
||||||
func (r *ItemRepository) Archive(ctx context.Context, id string) error {
|
func (r *ItemRepository) Archive(ctx context.Context, id string) error {
|
||||||
_, err := r.db.pool.Exec(ctx, `
|
_, 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")
|
"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
|
# Icon directory
|
||||||
def _get_icon_dir():
|
def _get_icon_dir():
|
||||||
@@ -169,18 +353,21 @@ class SiloClient:
|
|||||||
return self._request("GET", "/items?" + "&".join(params))
|
return self._request("GET", "/items?" + "&".join(params))
|
||||||
|
|
||||||
def create_item(
|
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]:
|
) -> Dict[str, Any]:
|
||||||
return self._request(
|
"""Create a new item with optional project tags."""
|
||||||
"POST",
|
data = {
|
||||||
"/items",
|
"schema": schema,
|
||||||
{
|
"category": category,
|
||||||
"schema": schema,
|
"description": description,
|
||||||
"project": project,
|
}
|
||||||
"category": category,
|
if projects:
|
||||||
"description": description,
|
data["projects"] = projects
|
||||||
},
|
return self._request("POST", "/items", data)
|
||||||
)
|
|
||||||
|
|
||||||
def update_item(
|
def update_item(
|
||||||
self, part_number: str, description: str = None, item_type: str = None
|
self, part_number: str, description: str = None, item_type: str = None
|
||||||
@@ -199,8 +386,21 @@ class SiloClient:
|
|||||||
return self._request("GET", f"/schemas/{name}")
|
return self._request("GET", f"/schemas/{name}")
|
||||||
|
|
||||||
def get_projects(self) -> list:
|
def get_projects(self) -> list:
|
||||||
|
"""Get list of all projects."""
|
||||||
return self._request("GET", "/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]]:
|
def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
|
||||||
"""Check if item has files in MinIO."""
|
"""Check if item has files in MinIO."""
|
||||||
try:
|
try:
|
||||||
@@ -212,6 +412,39 @@ class SiloClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False, None
|
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()
|
_client = SiloClient()
|
||||||
|
|
||||||
@@ -227,54 +460,86 @@ def sanitize_filename(name: str) -> str:
|
|||||||
return sanitized[:50]
|
return sanitized[:50]
|
||||||
|
|
||||||
|
|
||||||
def parse_part_number(part_number: str) -> Tuple[str, str, str]:
|
def parse_part_number(part_number: str) -> Tuple[str, str]:
|
||||||
"""Parse part number into (project, category, sequence)."""
|
"""Parse part number into (category, sequence).
|
||||||
|
|
||||||
|
New format: CCC-NNNN (e.g., F01-0001)
|
||||||
|
Returns: (category_code, sequence)
|
||||||
|
"""
|
||||||
parts = part_number.split("-")
|
parts = part_number.split("-")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 2:
|
||||||
return parts[0], parts[1], parts[2]
|
return parts[0], parts[1]
|
||||||
return part_number, "", ""
|
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:
|
def get_cad_file_path(part_number: str, description: str = "") -> Path:
|
||||||
"""Generate canonical file path for a CAD file."""
|
"""Generate canonical file path for a CAD file.
|
||||||
project_code, _, _ = parse_part_number(part_number)
|
|
||||||
|
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:
|
if description:
|
||||||
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
|
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
|
||||||
else:
|
else:
|
||||||
filename = f"{part_number}.FCStd"
|
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]:
|
def find_file_by_part_number(part_number: str) -> Optional[Path]:
|
||||||
"""Find existing CAD file for a part number."""
|
"""Find existing CAD file for a part number."""
|
||||||
project_code, _, _ = parse_part_number(part_number)
|
category, _ = parse_part_number(part_number)
|
||||||
cad_dir = get_projects_dir() / project_code.lower() / "cad"
|
folder_name = get_category_folder_name(category)
|
||||||
if not cad_dir.exists():
|
cad_dir = get_projects_dir() / "cad" / folder_name
|
||||||
return None
|
|
||||||
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
|
if cad_dir.exists():
|
||||||
return matches[0] if matches else None
|
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:
|
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
|
||||||
"""Search for CAD files in local projects directory."""
|
"""Search for CAD files in local cad directory."""
|
||||||
results = []
|
results = []
|
||||||
base_dir = get_projects_dir()
|
cad_dir = get_projects_dir() / "cad"
|
||||||
if not base_dir.exists():
|
if not cad_dir.exists():
|
||||||
return results
|
return results
|
||||||
|
|
||||||
search_lower = search_term.lower()
|
search_lower = search_term.lower()
|
||||||
|
|
||||||
for project_dir in base_dir.iterdir():
|
for category_dir in cad_dir.iterdir():
|
||||||
if not project_dir.is_dir():
|
if not category_dir.is_dir():
|
||||||
continue
|
|
||||||
if project_filter and project_dir.name.lower() != project_filter.lower():
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cad_dir = project_dir / "cad"
|
# Extract category code from folder name (e.g., "F01_screws_bolts" -> "F01")
|
||||||
if not cad_dir.exists():
|
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
|
continue
|
||||||
|
|
||||||
for fcstd_file in cad_dir.glob("*.FCStd"):
|
for fcstd_file in category_dir.glob("*.FCStd"):
|
||||||
filename = fcstd_file.stem
|
filename = fcstd_file.stem
|
||||||
parts = filename.split("_", 1)
|
parts = filename.split("_", 1)
|
||||||
part_number = parts[0]
|
part_number = parts[0]
|
||||||
@@ -298,7 +563,7 @@ def search_local_files(search_term: str = "", project_filter: str = "") -> list:
|
|||||||
"path": str(fcstd_file),
|
"path": str(fcstd_file),
|
||||||
"part_number": part_number,
|
"part_number": part_number,
|
||||||
"description": description,
|
"description": description,
|
||||||
"project": project_dir.name.upper(),
|
"category": category_code,
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
"source": "local",
|
"source": "local",
|
||||||
}
|
}
|
||||||
@@ -726,27 +991,7 @@ class Silo_New:
|
|||||||
|
|
||||||
sel = FreeCADGui.Selection.getSelection()
|
sel = FreeCADGui.Selection.getSelection()
|
||||||
|
|
||||||
# Project code
|
# Category selection
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
schema = _client.get_schema()
|
schema = _client.get_schema()
|
||||||
categories = schema.get("segments", [])
|
categories = schema.get("segments", [])
|
||||||
@@ -784,8 +1029,52 @@ class Silo_New:
|
|||||||
if not ok:
|
if not ok:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Optional project tagging
|
||||||
|
selected_projects = []
|
||||||
try:
|
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"]
|
part_number = result["part_number"]
|
||||||
|
|
||||||
if sel:
|
if sel:
|
||||||
@@ -800,10 +1089,12 @@ class Silo_New:
|
|||||||
# Create new document
|
# Create new document
|
||||||
_sync.create_document_for_item(result, save=True)
|
_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")
|
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
|
||||||
QtGui.QMessageBox.information(
|
QtGui.QMessageBox.information(None, "Item Created", msg)
|
||||||
None, "Item Created", f"Part number: {part_number}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QtGui.QMessageBox.critical(None, "Error", str(e))
|
QtGui.QMessageBox.critical(None, "Error", str(e))
|
||||||
@@ -1143,22 +1434,39 @@ class Silo_Info:
|
|||||||
item = _client.get_item(part_number)
|
item = _client.get_item(part_number)
|
||||||
revisions = _client.get_revisions(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"<h3>{part_number}</h3>"
|
||||||
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
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>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>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
||||||
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
||||||
|
|
||||||
has_file, _ = _client.has_file(part_number)
|
has_file, _ = _client.has_file(part_number)
|
||||||
msg += f"<p><b>File in MinIO:</b> {'Yes' if has_file else 'No'}</p>"
|
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 += "<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:
|
for rev in revisions:
|
||||||
file_icon = "✓" if rev.get("file_key") else "-"
|
file_icon = "✓" if rev.get("file_key") else "-"
|
||||||
comment = rev.get("comment", "") or "-"
|
comment = rev.get("comment", "") or "-"
|
||||||
date = rev.get("created_at", "")[:10]
|
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>"
|
msg += "</table>"
|
||||||
|
|
||||||
dialog = QtGui.QMessageBox()
|
dialog = QtGui.QMessageBox()
|
||||||
@@ -1174,6 +1482,309 @@ class Silo_Info:
|
|||||||
return FreeCAD.ActiveDocument is not None
|
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
|
# Register commands
|
||||||
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
||||||
FreeCADGui.addCommand("Silo_New", Silo_New())
|
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_Pull", Silo_Pull())
|
||||||
FreeCADGui.addCommand("Silo_Push", Silo_Push())
|
FreeCADGui.addCommand("Silo_Push", Silo_Push())
|
||||||
FreeCADGui.addCommand("Silo_Info", Silo_Info())
|
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
|
# Kindred Systems R&D Part Numbering Schema
|
||||||
#
|
#
|
||||||
# Format: XXXXX-CCC-NNNN
|
# Format: CCC-NNNN
|
||||||
# XXXXX = Project code (5 alphanumeric chars)
|
|
||||||
# CCC = Category/subcategory code (e.g., F01, R27)
|
# CCC = Category/subcategory code (e.g., F01, R27)
|
||||||
# NNNN = Sequence (4 alphanumeric, scoped per category)
|
# NNNN = Sequence (4 digits, scoped per category)
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# CS100-F01-0001 (Current Sensor, Screws/Bolts, seq 1)
|
# F01-0001 (Screws/Bolts, seq 1)
|
||||||
# 3DX15-R27-0001 (3D Printer Extruder, Servo Motor, seq 1)
|
# R27-0001 (Servo Motor, seq 1)
|
||||||
# 3DX10-S05-0002 (Extrusion Screw Unit, T-Slot Extrusion, seq 2)
|
# 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
|
# Note: Documents and drawings share part numbers with the items they describe
|
||||||
# and are managed as attachments/revisions rather than separate items.
|
# and are managed as attachments/revisions rather than separate items.
|
||||||
|
|
||||||
schema:
|
schema:
|
||||||
name: kindred-rd
|
name: kindred-rd
|
||||||
version: 2
|
version: 3
|
||||||
description: "Kindred Systems R&D hierarchical part numbering"
|
description: "Kindred Systems R&D part numbering (category-sequence)"
|
||||||
|
|
||||||
separator: "-"
|
separator: "-"
|
||||||
|
|
||||||
@@ -25,17 +27,6 @@ schema:
|
|||||||
case_sensitive: false
|
case_sensitive: false
|
||||||
|
|
||||||
segments:
|
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
|
# Category/subcategory code
|
||||||
- name: category
|
- name: category
|
||||||
type: enum
|
type: enum
|
||||||
@@ -118,7 +109,7 @@ schema:
|
|||||||
R34: "Pneumatic Actuator"
|
R34: "Pneumatic Actuator"
|
||||||
R35: "Pneumatic Valve"
|
R35: "Pneumatic Valve"
|
||||||
R36: "Pneumatic Regulator"
|
R36: "Pneumatic Regulator"
|
||||||
R37: "Pneumatic FRL Unit"
|
R37: "Other Pneumatic Device"
|
||||||
R38: "Air Compressor"
|
R38: "Air Compressor"
|
||||||
R39: "Vacuum Pump"
|
R39: "Vacuum Pump"
|
||||||
R40: "Hydraulic Cylinder"
|
R40: "Hydraulic Cylinder"
|
||||||
@@ -231,23 +222,23 @@ schema:
|
|||||||
X07: "Laser Cut Part"
|
X07: "Laser Cut Part"
|
||||||
X08: "Waterjet Cut Part"
|
X08: "Waterjet Cut Part"
|
||||||
|
|
||||||
# Sequence number (alphanumeric, scoped per category)
|
# Sequence number (scoped per category)
|
||||||
- name: sequence
|
- name: sequence
|
||||||
type: serial
|
type: serial
|
||||||
length: 4
|
length: 4
|
||||||
padding: "0"
|
padding: "0"
|
||||||
start: 1
|
start: 1
|
||||||
description: "Sequential identifier (alphanumeric, per category)"
|
description: "Sequential identifier (per category)"
|
||||||
scope: "{category}"
|
scope: "{category}"
|
||||||
|
|
||||||
format: "{project}-{category}-{sequence}"
|
format: "{category}-{sequence}"
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
- "CS100-F01-0001"
|
- "F01-0001"
|
||||||
- "3DX15-R27-0001"
|
- "R27-0001"
|
||||||
- "3DX10-S05-0002"
|
- "S05-0002"
|
||||||
- "CS100-E20-0001"
|
- "E20-0001"
|
||||||
- "3DX15-A01-0001"
|
- "A01-0001"
|
||||||
|
|
||||||
# Property schemas per category
|
# Property schemas per category
|
||||||
property_schemas:
|
property_schemas:
|
||||||
|
|||||||
Reference in New Issue
Block a user