- Add MacroFile type to internal/kc and extract silo/macros/* files
from .kc ZIP archives on commit
- Create ItemMacroRepository with ReplaceForItem, ListByItem, and
GetByFilename methods
- Add GET /{partNumber}/macros (list) and
GET /{partNumber}/macros/{filename} (source content) endpoints
- Index macros in extractKCMetadata with SSE broadcast
- List endpoint omits content for lightweight responses
Closes #144
180 lines
4.6 KiB
Go
180 lines
4.6 KiB
Go
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|