// Package kc extracts and parses the silo/ metadata directory from .kc files. // // A .kc file is a ZIP archive (superset of .fcstd) that contains a silo/ // directory with JSON metadata entries. This package handles extraction and // packing — no database or HTTP dependencies. package kc import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "strings" ) // Manifest represents the contents of silo/manifest.json. type Manifest struct { UUID string `json:"uuid"` KCVersion string `json:"kc_version"` RevisionHash string `json:"revision_hash"` SiloInstance string `json:"silo_instance"` } // Metadata represents the contents of silo/metadata.json. type Metadata struct { SchemaName string `json:"schema_name"` Tags []string `json:"tags"` LifecycleState string `json:"lifecycle_state"` Fields map[string]any `json:"fields"` } // Dependency represents one entry in silo/dependencies.json. type Dependency struct { UUID string `json:"uuid"` PartNumber string `json:"part_number"` Revision int `json:"revision"` Quantity float64 `json:"quantity"` Label string `json:"label"` Relationship string `json:"relationship"` } // MacroFile represents a script file found under silo/macros/. type MacroFile struct { Filename string Content string } // ExtractResult holds the parsed silo/ directory contents from a .kc file. type ExtractResult struct { Manifest *Manifest Metadata *Metadata Dependencies []Dependency Macros []MacroFile } // HistoryEntry represents one entry in silo/history.json. type HistoryEntry struct { RevisionNumber int `json:"revision_number"` CreatedAt string `json:"created_at"` CreatedBy *string `json:"created_by,omitempty"` Comment *string `json:"comment,omitempty"` Status string `json:"status"` Labels []string `json:"labels"` } // ApprovalEntry represents one entry in silo/approvals.json. type ApprovalEntry struct { ID string `json:"id"` WorkflowName string `json:"workflow"` ECONumber string `json:"eco_number,omitempty"` State string `json:"state"` UpdatedAt string `json:"updated_at"` UpdatedBy string `json:"updated_by,omitempty"` Signatures []SignatureEntry `json:"signatures"` } // SignatureEntry represents one signer in an approval. type SignatureEntry struct { Username string `json:"username"` Role string `json:"role"` Status string `json:"status"` SignedAt string `json:"signed_at,omitempty"` Comment string `json:"comment,omitempty"` } // PackInput holds all the data needed to repack silo/ entries in a .kc file. // Each field is optional — nil/empty means the entry is omitted from the ZIP. type PackInput struct { Manifest *Manifest Metadata *Metadata History []HistoryEntry Dependencies []Dependency Approvals []ApprovalEntry } // Extract opens a ZIP archive from data and parses the silo/ directory. // Returns nil, nil if no silo/ directory is found (plain .fcstd file). // Returns nil, error if silo/ entries exist but fail to parse. func Extract(data []byte) (*ExtractResult, error) { r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, fmt.Errorf("kc: open zip: %w", err) } var manifestFile, metadataFile, dependenciesFile *zip.File var macroFiles []*zip.File hasSiloDir := false for _, f := range r.File { if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") { hasSiloDir = true } switch f.Name { case "silo/manifest.json": manifestFile = f case "silo/metadata.json": metadataFile = f case "silo/dependencies.json": dependenciesFile = f default: if strings.HasPrefix(f.Name, "silo/macros/") && !f.FileInfo().IsDir() { name := strings.TrimPrefix(f.Name, "silo/macros/") if name != "" { macroFiles = append(macroFiles, f) } } } } if !hasSiloDir { return nil, nil // plain .fcstd, no extraction } result := &ExtractResult{} if manifestFile != nil { m, err := readJSON[Manifest](manifestFile) if err != nil { return nil, fmt.Errorf("kc: parse manifest.json: %w", err) } result.Manifest = m } if metadataFile != nil { m, err := readJSON[Metadata](metadataFile) if err != nil { return nil, fmt.Errorf("kc: parse metadata.json: %w", err) } result.Metadata = m } if dependenciesFile != nil { deps, err := readJSON[[]Dependency](dependenciesFile) if err != nil { return nil, fmt.Errorf("kc: parse dependencies.json: %w", err) } if deps != nil { result.Dependencies = *deps } } for _, mf := range macroFiles { rc, err := mf.Open() if err != nil { return nil, fmt.Errorf("kc: open macro %s: %w", mf.Name, err) } content, err := io.ReadAll(rc) rc.Close() if err != nil { return nil, fmt.Errorf("kc: read macro %s: %w", mf.Name, err) } result.Macros = append(result.Macros, MacroFile{ Filename: strings.TrimPrefix(mf.Name, "silo/macros/"), Content: string(content), }) } return result, nil } // readJSON opens a zip.File and decodes its contents as JSON into T. func readJSON[T any](f *zip.File) (*T, error) { rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() data, err := io.ReadAll(rc) if err != nil { return nil, err } var v T if err := json.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }