Implements issue #141 — .kc server-side metadata integration Phase 1. When a .kc file is uploaded, the server extracts silo/manifest.json and silo/metadata.json from the ZIP archive and indexes them into the item_metadata table. Plain .fcstd files continue to work unchanged. Extraction is best-effort: failures are logged but do not block the upload. New packages: - internal/kc: ZIP extraction library (Extract, Manifest, Metadata types) - internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields, UpdateLifecycle, SetTags) New API endpoints under /api/items/{partNumber}: - GET /metadata — read indexed metadata (viewer) - PUT /metadata — merge fields into JSONB (editor) - PATCH /metadata/lifecycle — transition lifecycle state (editor) - PATCH /metadata/tags — add/remove tags (editor) SSE events: metadata.updated, metadata.lifecycle, metadata.tags Lifecycle transitions (Phase 1): draft→review→released→obsolete, review→draft (reject). Closes #141
107 lines
2.5 KiB
Go
107 lines
2.5 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 only —
|
|
// 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"`
|
|
}
|
|
|
|
// ExtractResult holds the parsed silo/ directory contents from a .kc file.
|
|
type ExtractResult struct {
|
|
Manifest *Manifest
|
|
Metadata *Metadata
|
|
}
|
|
|
|
// 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 *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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|