Phase 2 of the parts-row autogen design — Crucible (sow-tools) side only. Resolves parts 2DA rows from the immutable \`part.yml\` published with the asset HAK release, over the anonymous Bunny CDN channel, with the parts consumer **required** (no fail-open). ## Changes - **Resolver (T3):** generalize \`resolveCDNChannelManifest\` — derive manifest basename + provenance label from config; drop hardcoded \`assets/vfxs.yml\` so \`part.yml\` resolves through the same path. VFX behavior unchanged. - **Parts filter (T4):** \`filterCDNChannelEntries\` dispatches on consumer mode; new \`filterPartsCDNChannelEntries\` implements the inventory contract — \`restype: mdl\` under \`part/<supported-cat>/\`, row id from trailing digits, dedup l/r/race/gender variants by (category,rowID), sort by source, **zero accepted rows = hard fail**, reject row id 0 / non-numeric / malformed / no-assets. - **Category (T5):** add \`hand\` + \`parts/hand\` mapping (12 datasets; \`leg→legs\` already present). - **Pipeline (T6):** native build runs one explicit augment → normalize → override sequence under the configured \`parts_rows\` policy; overrides may update an existing/discovered row but **never synthesize** one (orphan override now errors). - **Validation (T7):** reject more than one \`parts_rows\` consumer. ## Tests 9 new tests: parts filter contract, 12-dataset coverage, offline manifest-basename, orphan-override reject + discovered-row update, and a parity guard proving a bare CDN-only parts build needs no wrapper/token/NWN_ROOT/checkout. Gate green: \`go test ./internal/topdata/... ./internal/project/...\` + full \`go build ./... && go test ./...\`. ## Scope / ordering Phase 2 is inert until **Phase 1** (sow-assets-manifest publishes \`part.yml\`) ships and the operational **Gate** (release publish/promote) runs, then **Phase 3** enables the sow-topdata consumer. Do not enable the consumer against a channel whose current release predates \`part.yml\`. Plan: \`sow-topdata/docs/superpowers/plans/2026-06-25-topdata-parts-autogen-channel.md\` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #25 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
1594 lines
54 KiB
Go
1594 lines
54 KiB
Go
package topdata
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const autogenManifestCacheMaxAge = time.Hour
|
|
|
|
const defaultBunnyCDNBase = "https://cdn-a7f3k9.westgate.pw"
|
|
|
|
// errAutogenManifestUnavailable marks a released autogen manifest that could not
|
|
// be located or fetched (network failure, missing release/asset, empty payload,
|
|
// or an undeterminable sow-assets repo). It is deliberately distinct from an
|
|
// asset that was found but reports a row removed: for an optional consumer this
|
|
// sentinel triggers a fail-open path that *preserves* the consumer's existing
|
|
// pinned lock entries instead of pruning them, so StrRef/row IDs are not
|
|
// reshuffled while the asset source is merely temporarily out of reach.
|
|
var errAutogenManifestUnavailable = errors.New("autogen manifest unavailable")
|
|
|
|
func unavailableAutogenManifest(err error) error {
|
|
return fmt.Errorf("%w: %w", errAutogenManifestUnavailable, err)
|
|
}
|
|
|
|
type autogenManifest struct {
|
|
ID string `json:"id"`
|
|
Repo string `json:"repo"`
|
|
Ref string `json:"ref"`
|
|
GeneratedAt string `json:"generated_at"`
|
|
AccessoryVisualeffects *project.AccessoryVisualeffectsConfig `json:"accessory_visualeffects,omitempty"`
|
|
Entries []autogenManifestEntry `json:"entries"`
|
|
}
|
|
|
|
type autogenManifestEntry struct {
|
|
Source string `json:"source"`
|
|
Group string `json:"group,omitempty"`
|
|
Subgroup string `json:"subgroup,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
ModelStem string `json:"model_stem,omitempty"`
|
|
RowID int `json:"row_id,omitempty"`
|
|
}
|
|
|
|
type autogenManifestAssetMetadata struct {
|
|
DownloadURL string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type ProducedAutogenManifest struct {
|
|
ID string
|
|
AssetName string
|
|
OutputPath string
|
|
Payload []byte
|
|
}
|
|
|
|
func applyAutogenConsumers(p *project.Project, collected []nativeCollectedDataset, progress func(string)) ([]nativeCollectedDataset, error) {
|
|
if len(p.Config.Autogen.Consumers) == 0 {
|
|
return collected, nil
|
|
}
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
|
|
result := append([]nativeCollectedDataset(nil), collected...)
|
|
for _, consumer := range p.Config.Autogen.Consumers {
|
|
if !autogenConsumerTargetsCollectedDataset(result, consumer) {
|
|
continue
|
|
}
|
|
manifest, err := resolveAutogenConsumerManifest(p, consumer, progress)
|
|
if err != nil {
|
|
if consumer.Optional && errors.Is(err, errAutogenManifestUnavailable) {
|
|
// Fail open: the asset manifest is merely unreachable, not
|
|
// authoritatively empty. Build without its rows, but keep the
|
|
// consumer's already-pinned lock entries so their IDs are not
|
|
// freed and reshuffled on a later build once the asset returns.
|
|
preserved, perr := preserveAutogenConsumerLockEntries(result, consumer)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
result = preserved
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Autogen consumer %s: released manifest unavailable (%v); preserving existing lock entries, generating no new rows", consumer.ID, err))
|
|
}
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
entries := manifest.Entries
|
|
if len(entries) == 0 {
|
|
continue
|
|
}
|
|
switch consumer.Mode {
|
|
case "parts_rows":
|
|
result = augmentWithAutogeneratedParts(result, autogenPartsInventory(entries))
|
|
case "accessory_visualeffects":
|
|
result, err = augmentWithAutogeneratedAccessoryVisualeffects(result, entries, consumer, manifest.AccessoryVisualeffects)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported autogen consumer mode %q", consumer.Mode)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func autogenConsumerTargetsCollectedDataset(collected []nativeCollectedDataset, consumer project.AutogenConsumerConfig) bool {
|
|
for _, dataset := range collected {
|
|
if autogenConsumerTargetsDataset(dataset, consumer) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// preserveAutogenConsumerLockEntries restores the lock keys that an autogen
|
|
// consumer owns from the on-disk lockfile back into the in-memory dataset,
|
|
// reusing this build's already-collected (and pruned) state. It is called only
|
|
// when the consumer's released manifest is unavailable, so the rows themselves
|
|
// are not regenerated; we just keep the key->ID pins alive. Keys are matched
|
|
// against the consumer's own naming policy so unrelated (authored) rows that were
|
|
// legitimately removed are not resurrected.
|
|
func preserveAutogenConsumerLockEntries(collected []nativeCollectedDataset, consumer project.AutogenConsumerConfig) ([]nativeCollectedDataset, error) {
|
|
matches := autogenConsumerManagedLockKeyMatcher(consumer)
|
|
if matches == nil {
|
|
// No managed-key matcher for this mode: nothing safe to preserve.
|
|
return collected, nil
|
|
}
|
|
|
|
result := append([]nativeCollectedDataset(nil), collected...)
|
|
for i, dataset := range result {
|
|
if !autogenConsumerTargetsDataset(dataset, consumer) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(dataset.Dataset.LockPath) == "" {
|
|
continue
|
|
}
|
|
onDisk, err := loadLockfile(dataset.Dataset.LockPath)
|
|
if err != nil {
|
|
// No readable lockfile means there is nothing pinned to preserve.
|
|
continue
|
|
}
|
|
lockData := dataset.LockData
|
|
if lockData == nil {
|
|
lockData = map[string]int{}
|
|
}
|
|
restored := 0
|
|
for key, rowID := range onDisk {
|
|
if _, present := lockData[key]; present {
|
|
continue
|
|
}
|
|
if !matches(key) {
|
|
continue
|
|
}
|
|
lockData[key] = rowID
|
|
restored++
|
|
}
|
|
if restored == 0 {
|
|
continue
|
|
}
|
|
result[i].LockData = lockData
|
|
result[i].LockModified = !lockDataEqual(onDisk, lockData)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// autogenConsumerManagedLockKeyMatcher returns a predicate identifying the lock
|
|
// keys an autogen consumer is responsible for, or nil for modes whose keys we
|
|
// cannot scope precisely (in which case preservation is skipped rather than
|
|
// risk resurrecting unrelated keys).
|
|
func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig) func(string) bool {
|
|
switch consumer.Mode {
|
|
case "accessory_visualeffects":
|
|
dataset := strings.TrimSpace(consumer.Dataset)
|
|
if dataset == "" {
|
|
dataset = "visualeffects"
|
|
}
|
|
prefix := dataset + ":"
|
|
// Build the group set from the resolved policy so consumers that omit
|
|
// AccessoryVisualeffects.Groups still match the four default group names.
|
|
// NOTE: this matcher assumes the default folder_name+preserve group
|
|
// representation. Keys produced under case:lower or
|
|
// group_token_source:prefix would not match and would escape pruning;
|
|
// no production config uses those combinations.
|
|
policy := resolveAccessoryVisualeffectsPolicy(consumer, nil)
|
|
groups := make(map[string]struct{}, len(policy.Groups))
|
|
for group := range policy.Groups {
|
|
if group = strings.TrimSpace(group); group != "" {
|
|
groups[group] = struct{}{}
|
|
}
|
|
}
|
|
if len(groups) == 0 {
|
|
return nil
|
|
}
|
|
return func(key string) bool {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
return false
|
|
}
|
|
rest := key[len(prefix):]
|
|
idx := strings.Index(rest, "/")
|
|
if idx <= 0 {
|
|
return false
|
|
}
|
|
_, ok := groups[rest[:idx]]
|
|
return ok
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func autogenConsumerTargetsDataset(dataset nativeCollectedDataset, consumer project.AutogenConsumerConfig) bool {
|
|
switch consumer.Mode {
|
|
case "parts_rows":
|
|
return isAutogenEligiblePartsDataset(dataset)
|
|
case "accessory_visualeffects":
|
|
return dataset.Dataset.Name == "visualeffects"
|
|
case "cachedmodels_rows":
|
|
return dataset.Dataset.Name == "cachedmodels"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func resolveAutogenConsumerManifest(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) (*autogenManifest, error) {
|
|
if strings.TrimSpace(consumer.Source.Kind) == "cdn_channel" {
|
|
return resolveCDNChannelManifest(p, consumer, consumer.Source, progress)
|
|
}
|
|
if manifestFile := strings.TrimSpace(consumer.ManifestFile); manifestFile != "" {
|
|
return readAutogenConsumerManifestFile(p, consumer, manifestFile, progress)
|
|
}
|
|
overrideRoot, err := resolveAutogenLocalOverrideRoot(p, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if overrideRoot != "" {
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Scanning local autogen override for %s from %s...", consumer.ID, overrideRoot))
|
|
}
|
|
entries, err := scanLocalAutogenEntries(overrideRoot, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &autogenManifest{ID: consumer.Producer, Entries: entries}, nil
|
|
}
|
|
return resolveReleasedAutogenManifest(p, consumer, progress)
|
|
}
|
|
|
|
// readAutogenConsumerManifestFile reads a pre-resolved autogenManifest JSON from
|
|
// a local file (relative paths resolve against the project root). A missing file
|
|
// is an ignorable-optional condition — it returns errAutogenManifestUnavailable
|
|
// so an optional consumer fails open and preserves its pinned lock IDs. A present
|
|
// but malformed file, or an id that disagrees with the consumer's producer, is a
|
|
// hard error.
|
|
func readAutogenConsumerManifestFile(p *project.Project, consumer project.AutogenConsumerConfig, manifestFile string, progress func(string)) (*autogenManifest, error) {
|
|
path := manifestFile
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(p.Root, path)
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("manifest file %s not found", path))
|
|
}
|
|
return nil, fmt.Errorf("read autogen manifest file %s: %w", path, err)
|
|
}
|
|
var manifest autogenManifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, fmt.Errorf("parse autogen manifest file %s: %w", path, err)
|
|
}
|
|
if manifest.ID != "" && manifest.ID != consumer.Producer {
|
|
return nil, fmt.Errorf("autogen manifest file %s declared id %q, expected %q", path, manifest.ID, consumer.Producer)
|
|
}
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Reading local autogen manifest for %s from %s...", consumer.ID, path))
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
// resolveCDNChannelManifest resolves the accessory-VFX model list anonymously
|
|
// from the asset CDN: channel pointer -> tag -> per-tag vfxs.yml. It ports the
|
|
// fail policy of the deleted resolve-accessory-vfx.sh: unreachable inputs fail
|
|
// OPEN (errAutogenManifestUnavailable -> optional consumer preserves lock IDs);
|
|
// a published-but-broken release (vfxs.yml 404 while haks.json present) or a
|
|
// malformed vfxs.yml HARD fail.
|
|
func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsumerConfig, src project.AutogenSourceConfig, progress func(string)) (*autogenManifest, error) {
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
|
|
// Manifest basename + provenance label are derived from config, not hardcoded,
|
|
// so the same resolver serves vfxs.yml (accessory VFX) and part.yml (parts).
|
|
manifestBase := filepath.Base(filepath.FromSlash(strings.TrimSpace(src.ManifestPath)))
|
|
if manifestBase == "." || manifestBase == "" || manifestBase == string(filepath.Separator) {
|
|
manifestBase = "manifest.yml"
|
|
}
|
|
label := strings.TrimSpace(consumer.ID)
|
|
if label == "" {
|
|
label = "autogen"
|
|
}
|
|
|
|
// 1. Offline / air-gapped override: a manifest file path or a manifest-repo
|
|
// checkout root containing assets/<manifestBase>. Skips the network entirely.
|
|
if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" {
|
|
if override := strings.TrimSpace(os.Getenv(envName)); override != "" {
|
|
manifestPath := override
|
|
if info, err := os.Stat(override); err == nil && info.IsDir() {
|
|
manifestPath = filepath.Join(override, "assets", manifestBase)
|
|
}
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("offline %s override %s: %w", manifestBase, manifestPath, err)
|
|
}
|
|
entries, err := filterCDNChannelEntries(raw, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
progress(fmt.Sprintf("Using offline %s override for %s from %s...", manifestBase, consumer.ID, manifestPath))
|
|
return &autogenManifest{ID: consumer.Producer, Ref: "local", Entries: entries}, nil
|
|
}
|
|
}
|
|
|
|
// 2. Channel: explicit env, else derived from the git tag.
|
|
channel := strings.TrimSpace(os.Getenv(strings.TrimSpace(src.ChannelEnv)))
|
|
if channel == "" {
|
|
channel = deriveAssetChannel(p.Root)
|
|
}
|
|
|
|
// 3. CDN base: baked default, then env override, then config literal (most specific wins).
|
|
cdnBase := defaultBunnyCDNBase
|
|
if envName := strings.TrimSpace(src.CDNBaseEnv); envName != "" {
|
|
if v := strings.TrimSpace(os.Getenv(envName)); v != "" {
|
|
cdnBase = v
|
|
}
|
|
}
|
|
if v := strings.TrimSpace(src.CDNBase); v != "" {
|
|
cdnBase = v
|
|
}
|
|
cdnBase = strings.TrimRight(cdnBase, "/")
|
|
|
|
join := func(path, tag string) string {
|
|
return cdnBase + "/" + strings.TrimLeft(strings.ReplaceAll(path, "{tag}", tag), "/")
|
|
}
|
|
|
|
// 4. channels.json -> tag. Unreachable / non-JSON / channel-absent = fail open.
|
|
channelsURL := join(src.ChannelsPath, "")
|
|
status, body, err := httpGetStatus(channelsURL)
|
|
if err != nil {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json unreachable %s: %w", channelsURL, err))
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json HTTP %d %s", status, channelsURL))
|
|
}
|
|
var channels map[string]string
|
|
if err := json.Unmarshal(body, &channels); err != nil {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json not JSON %s: %w", channelsURL, err))
|
|
}
|
|
tag := strings.TrimSpace(channels[channel])
|
|
if tag == "" {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("channel %q absent in channels.json (%s)", channel, channelsURL))
|
|
}
|
|
progress(fmt.Sprintf("%s: channel %s -> tag %s", label, channel, tag))
|
|
|
|
// 5. per-tag manifest for the tag.
|
|
manifestURL := join(src.ManifestPath, tag)
|
|
vstatus, vbody, err := httpGetStatus(manifestURL)
|
|
if err != nil {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s unreachable %s: %w", manifestBase, manifestURL, err))
|
|
}
|
|
switch vstatus {
|
|
case http.StatusOK:
|
|
// fall through to parse
|
|
case http.StatusNotFound:
|
|
// 404 + release marker present = published-but-broken release: HARD fail
|
|
// (this is the v0.1.4 silent-drop bug). 404 + marker absent = no published
|
|
// release for this tag: fail open.
|
|
if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" {
|
|
markerURL := join(marker, tag)
|
|
if mstatus, _, merr := httpGetStatus(markerURL); merr == nil && mstatus == http.StatusOK {
|
|
return nil, fmt.Errorf("release %s has %s but no %s (%s) — rows would silently vanish; backfill/republish %s for %s", tag, marker, manifestBase, manifestURL, manifestBase, tag)
|
|
}
|
|
}
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s 404 %s (no published release for %s)", manifestBase, manifestURL, tag))
|
|
default:
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s HTTP %d %s", manifestBase, vstatus, manifestURL))
|
|
}
|
|
|
|
entries, err := filterCDNChannelEntries(vbody, consumer)
|
|
if err != nil {
|
|
return nil, err // malformed manifest = HARD fail
|
|
}
|
|
progress(fmt.Sprintf("%s: resolved %d model entries from %s", label, len(entries), manifestURL))
|
|
return &autogenManifest{ID: consumer.Producer, Ref: tag, Entries: entries}, nil
|
|
}
|
|
|
|
// filterCDNChannelEntries dispatches per-tag manifest parsing on the consumer
|
|
// mode. parts_rows consumes part.yml (the parts inventory contract); every other
|
|
// mode consumes vfxs.yml (the accessory-VFX contract).
|
|
func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
|
switch strings.TrimSpace(consumer.Mode) {
|
|
case "parts_rows":
|
|
return filterPartsCDNChannelEntries(raw)
|
|
default:
|
|
return filterVFXCDNChannelEntries(raw, consumer)
|
|
}
|
|
}
|
|
|
|
// filterPartsCDNChannelEntries parses part.yml and keeps restype==mdl assets
|
|
// under part/<supported-category>/.../<stem><digits>.mdl. The asset category is
|
|
// the path segment after part/; the row ID is the trailing decimal of the
|
|
// filename stem. Body/race/gender/left-right variants dedup by (category,rowID).
|
|
// Zero accepted rows is a HARD fail (silent-drop guard).
|
|
func filterPartsCDNChannelEntries(raw []byte) ([]autogenManifestEntry, error) {
|
|
var manifest struct {
|
|
Assets *[]struct {
|
|
Path string `yaml:"path"`
|
|
Restype string `yaml:"restype"`
|
|
} `yaml:"assets"`
|
|
}
|
|
if err := yaml.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, fmt.Errorf("malformed part.yml (cannot parse): %w", err)
|
|
}
|
|
if manifest.Assets == nil {
|
|
return nil, fmt.Errorf("malformed part.yml (no assets array)")
|
|
}
|
|
seen := map[string]struct{}{}
|
|
var entries []autogenManifestEntry
|
|
for _, a := range *manifest.Assets {
|
|
if a.Restype != "mdl" {
|
|
continue
|
|
}
|
|
path := filepath.ToSlash(strings.TrimSpace(a.Path))
|
|
if !strings.HasPrefix(path, "part/") {
|
|
continue
|
|
}
|
|
rel := strings.TrimPrefix(path, "part/")
|
|
segs := strings.Split(rel, "/")
|
|
if len(segs) < 2 {
|
|
continue
|
|
}
|
|
category := segs[0]
|
|
if !isSupportedPartCategory(category) {
|
|
continue // ignores _masters, cloak, head, helm, tail, wings
|
|
}
|
|
stem := strings.TrimSuffix(segs[len(segs)-1], ".mdl")
|
|
match := trailingNumberRegex.FindString(stem)
|
|
if match == "" {
|
|
return nil, fmt.Errorf("part.yml: supported-category model %q has no trailing row number", path)
|
|
}
|
|
rowID, err := strconv.Atoi(match)
|
|
if err != nil || rowID == 0 {
|
|
return nil, fmt.Errorf("part.yml: supported-category model %q resolves to invalid row id %q", path, match)
|
|
}
|
|
key := category + "/" + strconv.Itoa(rowID)
|
|
if _, dup := seen[key]; dup {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
entries = append(entries, autogenManifestEntry{
|
|
Source: rel, Group: category, ModelStem: stem, RowID: rowID,
|
|
})
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil, fmt.Errorf("part.yml: zero supported part rows after filtering")
|
|
}
|
|
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
|
return strings.Compare(a.Source, b.Source)
|
|
})
|
|
return entries, nil
|
|
}
|
|
|
|
// filterVFXCDNChannelEntries parses vfxs.yml and keeps restype==mdl assets under
|
|
// vfxs/<group>/ for the consumer's 4 accessory groups, stripping the leading
|
|
// vfxs/ from each source path. A present-but-malformed manifest (unparseable, or
|
|
// no assets array) is an error; an empty assets array yields zero entries.
|
|
func filterVFXCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
|
var manifest struct {
|
|
Assets *[]struct {
|
|
Path string `yaml:"path"`
|
|
Restype string `yaml:"restype"`
|
|
} `yaml:"assets"`
|
|
}
|
|
if err := yaml.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, fmt.Errorf("malformed vfxs.yml (cannot parse): %w", err)
|
|
}
|
|
if manifest.Assets == nil {
|
|
return nil, fmt.Errorf("malformed vfxs.yml (no assets array)")
|
|
}
|
|
groups := make(map[string]struct{}, len(consumer.AccessoryVisualeffects.Groups))
|
|
for g := range consumer.AccessoryVisualeffects.Groups {
|
|
if g = strings.TrimSpace(g); g != "" {
|
|
groups[g] = struct{}{}
|
|
}
|
|
}
|
|
var entries []autogenManifestEntry
|
|
for _, a := range *manifest.Assets {
|
|
if a.Restype != "mdl" {
|
|
continue
|
|
}
|
|
path := filepath.ToSlash(strings.TrimSpace(a.Path))
|
|
if !strings.HasPrefix(path, "vfxs/") {
|
|
continue
|
|
}
|
|
rel := strings.TrimPrefix(path, "vfxs/")
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
if _, ok := groups[parts[0]]; !ok {
|
|
continue
|
|
}
|
|
entry := autogenManifestEntry{
|
|
Source: rel,
|
|
Group: parts[0],
|
|
ModelStem: strings.TrimSuffix(parts[len(parts)-1], ".mdl"),
|
|
}
|
|
if len(parts) > 2 {
|
|
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
|
return strings.Compare(a.Source, b.Source)
|
|
})
|
|
return entries, nil
|
|
}
|
|
|
|
// deriveAssetChannel maps the current git tag (or GITHUB_REF_NAME) to a channel:
|
|
// a prerelease tag (v*-*) -> testing; a stable tag (v*) -> current; anything
|
|
// else (branch/PR/dev) -> current. Mirrors resolve-accessory-vfx.sh.
|
|
func deriveAssetChannel(root string) string {
|
|
ref := strings.TrimSpace(os.Getenv("GITHUB_REF_NAME"))
|
|
if ref == "" {
|
|
if out, err := gitOutput(root, "describe", "--tags", "--exact-match"); err == nil {
|
|
ref = strings.TrimSpace(out)
|
|
}
|
|
}
|
|
if strings.HasPrefix(ref, "v") && strings.Contains(ref, "-") {
|
|
return "testing"
|
|
}
|
|
return "current"
|
|
}
|
|
|
|
func resolveAutogenLocalOverrideRoot(p *project.Project, consumer project.AutogenConsumerConfig) (string, error) {
|
|
root := strings.TrimSpace(consumer.LocalOverrideRoot)
|
|
if root != "" {
|
|
if filepath.IsAbs(root) {
|
|
return root, nil
|
|
}
|
|
return filepath.Join(p.Root, root), nil
|
|
}
|
|
|
|
spec := strings.TrimSpace(p.Config.TopData.Assets)
|
|
if spec == "" {
|
|
return "", nil
|
|
}
|
|
if looksLikeGitRepoSpec(spec) {
|
|
return "", fmt.Errorf("topdata.assets no longer supports git repo specs for autogen discovery; use a local path override or the default released manifest")
|
|
}
|
|
path := p.TopDataAssetsDir()
|
|
if path == "" {
|
|
return "", nil
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
return "", fmt.Errorf("configured topdata.assets path %s is not accessible: %w", path, err)
|
|
}
|
|
candidate := filepath.Join(path, consumer.Root)
|
|
info, err := os.Stat(candidate)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("configured topdata.assets override root %s is not accessible: %w", candidate, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("configured topdata.assets override root %s is not a directory", candidate)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func scanLocalAutogenEntries(overrideRoot string, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
|
scanRoot := filepath.Join(overrideRoot, consumer.Root)
|
|
return scanConfiguredAutogenEntries(scanRoot, consumer.Include, consumer.Derive)
|
|
}
|
|
|
|
func scanConfiguredAutogenEntries(scanRoot string, include []string, derive project.AutogenDeriveConfig) ([]autogenManifestEntry, error) {
|
|
info, err := os.Stat(scanRoot)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("stat autogen override root %s: %w", scanRoot, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("autogen override root %s is not a directory", scanRoot)
|
|
}
|
|
|
|
var entries []autogenManifestEntry
|
|
err = filepath.Walk(scanRoot, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(scanRoot, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if !matchesAutogenInclude(rel, include) {
|
|
return nil
|
|
}
|
|
entry, ok := deriveAutogenManifestEntry(rel, derive)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
entries = append(entries, entry)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan autogen override %s: %w", scanRoot, err)
|
|
}
|
|
|
|
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
|
return strings.Compare(a.Source, b.Source)
|
|
})
|
|
return entries, nil
|
|
}
|
|
|
|
func ProduceAutogenManifests(p *project.Project) ([]ProducedAutogenManifest, error) {
|
|
if len(p.Config.Autogen.Producers) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
assetsRoot := strings.TrimSpace(p.AssetsDir())
|
|
if assetsRoot == "" {
|
|
return nil, fmt.Errorf("autogen producers require paths.assets to be configured")
|
|
}
|
|
info, err := os.Stat(assetsRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat assets root %s: %w", assetsRoot, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("assets root %s is not a directory", assetsRoot)
|
|
}
|
|
|
|
repo, ref := autogenManifestSourceMetadata(p.Root)
|
|
results := make([]ProducedAutogenManifest, 0, len(p.Config.Autogen.Producers))
|
|
for _, producer := range p.Config.Autogen.Producers {
|
|
scanRoot := filepath.Join(assetsRoot, producer.Root)
|
|
entries, err := scanConfiguredAutogenEntries(scanRoot, producer.Include, producer.Derive)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan autogen producer %s: %w", producer.ID, err)
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil, fmt.Errorf("autogen producer %s discovered no matching entries under %s", producer.ID, scanRoot)
|
|
}
|
|
payload, err := formatAutogenManifest(p.Root, producer, repo, ref, entries)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("format autogen producer %s manifest: %w", producer.ID, err)
|
|
}
|
|
payload = append(payload, '\n')
|
|
results = append(results, ProducedAutogenManifest{
|
|
ID: producer.ID,
|
|
AssetName: producer.Manifest.AssetName,
|
|
OutputPath: filepath.Join(p.BuildDir(), producer.Manifest.AssetName),
|
|
Payload: payload,
|
|
})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func autogenManifestSourceMetadata(root string) (repo string, ref string) {
|
|
if remote, err := gitOutput(root, "remote", "get-url", "origin"); err == nil {
|
|
if spec, ok := parseRepoSpec(strings.TrimSpace(remote)); ok {
|
|
repo = spec.Owner + "/" + spec.Repo
|
|
}
|
|
}
|
|
if head, err := gitOutput(root, "rev-parse", "HEAD"); err == nil {
|
|
ref = strings.TrimSpace(head)
|
|
}
|
|
return repo, ref
|
|
}
|
|
|
|
func matchesAutogenInclude(rel string, include []string) bool {
|
|
rel = filepath.ToSlash(rel)
|
|
for _, pattern := range include {
|
|
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case pattern == "**/*.mdl":
|
|
if strings.HasSuffix(strings.ToLower(rel), ".mdl") {
|
|
return true
|
|
}
|
|
case strings.HasSuffix(pattern, "/**/*.mdl"):
|
|
prefix := strings.TrimSuffix(pattern, "/**/*.mdl")
|
|
if strings.HasPrefix(rel, prefix+"/") && strings.HasSuffix(strings.ToLower(rel), ".mdl") {
|
|
return true
|
|
}
|
|
case strings.HasSuffix(pattern, "/*.mdl"):
|
|
prefix := strings.TrimSuffix(pattern, "/*.mdl")
|
|
dir := filepath.ToSlash(filepath.Dir(rel))
|
|
if dir == prefix && strings.HasSuffix(strings.ToLower(rel), ".mdl") {
|
|
return true
|
|
}
|
|
case rel == pattern:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func deriveAutogenManifestEntry(rel string, derive project.AutogenDeriveConfig) (autogenManifestEntry, bool) {
|
|
rel = filepath.ToSlash(rel)
|
|
stem := strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))
|
|
entry := autogenManifestEntry{
|
|
Source: rel,
|
|
ModelStem: stem,
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
if derive.GroupFrom == "first_path_segment" && len(parts) > 1 {
|
|
entry.Group = parts[0]
|
|
if len(parts) > 2 {
|
|
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
|
|
entry.Category = parts[len(parts)-2]
|
|
}
|
|
}
|
|
|
|
switch derive.Kind {
|
|
case "trailing_numeric_suffix":
|
|
rowID, ok := extractTrailingNumericSuffix(stem)
|
|
if !ok {
|
|
return autogenManifestEntry{}, false
|
|
}
|
|
entry.RowID = rowID
|
|
case "model_stem":
|
|
default:
|
|
return autogenManifestEntry{}, false
|
|
}
|
|
|
|
return entry, true
|
|
}
|
|
|
|
func resolveReleasedAutogenManifest(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) (*autogenManifest, error) {
|
|
cachePath := p.AutogenCachePath(consumer.Manifest.CacheName)
|
|
if strings.TrimSpace(os.Getenv(p.AutogenRefreshEnv())) == "" {
|
|
manifest, fresh, err := readFreshAutogenManifestCache(cachePath, p.AutogenCacheMaxAge())
|
|
if err != nil {
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Ignoring cached autogen manifest %s: %v", consumer.Manifest.CacheName, err))
|
|
}
|
|
} else if fresh {
|
|
if current, remoteTime, err := releasedAutogenManifestCacheCurrent(p, consumer, cachePath, manifest); err != nil {
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Using cached released autogen manifest %s (remote check failed: %v)...", cachePath, err))
|
|
}
|
|
return manifest, nil
|
|
} else if current {
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Using cached released autogen manifest %s...", cachePath))
|
|
}
|
|
return manifest, nil
|
|
} else if progress != nil {
|
|
progress(fmt.Sprintf("Refreshing released autogen manifest %s; remote asset changed at %s.", consumer.Manifest.CacheName, remoteTime.Format(time.RFC3339)))
|
|
}
|
|
}
|
|
}
|
|
|
|
spec, err := deriveSowAssetsRepoSpec(p)
|
|
if err != nil {
|
|
return nil, unavailableAutogenManifest(err)
|
|
}
|
|
manifestURL, err := resolveAutogenManifestAssetURL(spec, consumer.Manifest.ReleaseTag, consumer.Manifest.AssetName)
|
|
if err != nil {
|
|
if consumer.Mode == "parts_rows" {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s", strings.Replace(err.Error(), "autogen manifest", "parts manifest", 1)))
|
|
}
|
|
return nil, unavailableAutogenManifest(err)
|
|
}
|
|
if progress != nil {
|
|
progress(fmt.Sprintf("Fetching released autogen manifest from %s...", manifestURL))
|
|
}
|
|
manifest, err := fetchAutogenManifest(manifestURL)
|
|
if err != nil {
|
|
if consumer.Mode == "parts_rows" {
|
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s", strings.Replace(err.Error(), "autogen manifest", "parts manifest", 1)))
|
|
}
|
|
return nil, unavailableAutogenManifest(err)
|
|
}
|
|
if manifest.ID != "" && manifest.ID != consumer.Producer {
|
|
return nil, fmt.Errorf("autogen manifest %s declared id %q, expected %q", consumer.Manifest.AssetName, manifest.ID, consumer.Producer)
|
|
}
|
|
if err := writeAutogenManifestCache(cachePath, manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func resolveReleasedAutogenManifestEntries(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) ([]autogenManifestEntry, error) {
|
|
manifest, err := resolveReleasedAutogenManifest(p, consumer, progress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return manifest.Entries, nil
|
|
}
|
|
|
|
func releasedAutogenManifestCacheCurrent(p *project.Project, consumer project.AutogenConsumerConfig, cachePath string, manifest *autogenManifest) (bool, time.Time, error) {
|
|
spec, err := deriveSowAssetsRepoSpec(p)
|
|
if err != nil {
|
|
return false, time.Time{}, err
|
|
}
|
|
asset, err := resolveAutogenManifestAssetMetadata(spec, consumer.Manifest.ReleaseTag, consumer.Manifest.AssetName)
|
|
if err != nil {
|
|
return false, time.Time{}, err
|
|
}
|
|
if asset.UpdatedAt.IsZero() {
|
|
return true, time.Time{}, nil
|
|
}
|
|
return !asset.UpdatedAt.After(localAutogenManifestComparableTime(cachePath, manifest)), asset.UpdatedAt, nil
|
|
}
|
|
|
|
func localAutogenManifestComparableTime(cachePath string, manifest *autogenManifest) time.Time {
|
|
best := time.Time{}
|
|
if info, err := os.Stat(cachePath); err == nil {
|
|
best = info.ModTime()
|
|
}
|
|
if generatedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(manifest.GeneratedAt)); err == nil && generatedAt.After(best) {
|
|
best = generatedAt
|
|
}
|
|
return best
|
|
}
|
|
|
|
func newestReleasedAutogenManifestInput(p *project.Project, consumer project.AutogenConsumerConfig, now time.Time, cachePath string) (time.Time, string, error) {
|
|
if strings.TrimSpace(os.Getenv(p.AutogenRefreshEnv())) != "" {
|
|
return now, cachePath, nil
|
|
}
|
|
|
|
info, err := os.Stat(cachePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return now, cachePath, nil
|
|
}
|
|
return time.Time{}, "", fmt.Errorf("stat autogen manifest cache %s: %w", cachePath, err)
|
|
}
|
|
if autogenManifestCacheMaxAge > 0 && now.Sub(info.ModTime()) > autogenManifestCacheMaxAge {
|
|
return now, cachePath, nil
|
|
}
|
|
|
|
raw, err := os.ReadFile(cachePath)
|
|
if err != nil {
|
|
return info.ModTime(), cachePath, nil
|
|
}
|
|
var manifest autogenManifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return info.ModTime(), cachePath, nil
|
|
}
|
|
|
|
current, remoteTime, err := releasedAutogenManifestCacheCurrent(p, consumer, cachePath, &manifest)
|
|
if err != nil || current || remoteTime.IsZero() {
|
|
return info.ModTime(), cachePath, nil
|
|
}
|
|
return remoteTime, consumer.Manifest.AssetName, nil
|
|
}
|
|
|
|
func resolveAutogenManifestAssetURL(spec giteaRepoSpec, releaseTag, assetName string) (string, error) {
|
|
asset, err := resolveAutogenManifestAssetMetadata(spec, releaseTag, assetName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return asset.DownloadURL, nil
|
|
}
|
|
|
|
func resolveAutogenManifestAssetMetadata(spec giteaRepoSpec, releaseTag, assetName string) (autogenManifestAssetMetadata, error) {
|
|
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(spec.BaseURL, "/"), releasePathEscape(spec.Owner), releasePathEscape(spec.Repo), releasePathEscape(releaseTag))
|
|
var release struct {
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
DownloadURL string `json:"download_url"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
} `json:"assets"`
|
|
}
|
|
if err := fetchJSON(apiURL, &release); err != nil {
|
|
return autogenManifestAssetMetadata{}, fmt.Errorf("fetch autogen manifest release metadata: %w", err)
|
|
}
|
|
for _, asset := range release.Assets {
|
|
if asset.Name != assetName {
|
|
continue
|
|
}
|
|
metadata := autogenManifestAssetMetadata{}
|
|
if updatedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(asset.UpdatedAt)); err == nil {
|
|
metadata.UpdatedAt = updatedAt
|
|
}
|
|
if asset.DownloadURL != "" {
|
|
metadata.DownloadURL = asset.DownloadURL
|
|
return metadata, nil
|
|
}
|
|
if asset.BrowserDownloadURL != "" {
|
|
metadata.DownloadURL = asset.BrowserDownloadURL
|
|
return metadata, nil
|
|
}
|
|
}
|
|
return autogenManifestAssetMetadata{}, fmt.Errorf("autogen manifest asset %s not found in release %s/%s:%s", assetName, spec.Owner, spec.Repo, releaseTag)
|
|
}
|
|
|
|
func releasePathEscape(value string) string {
|
|
replacer := strings.NewReplacer("/", "%2F")
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
func fetchAutogenManifest(manifestURL string) (*autogenManifest, error) {
|
|
var manifest autogenManifest
|
|
if err := fetchJSON(manifestURL, &manifest); err != nil {
|
|
return nil, fmt.Errorf("download autogen manifest: %w", err)
|
|
}
|
|
if len(manifest.Entries) == 0 {
|
|
var legacy partsManifest
|
|
if err := fetchJSON(manifestURL, &legacy); err == nil && len(legacy.Categories) > 0 {
|
|
return legacyPartsManifestToAutogen(&legacy), nil
|
|
}
|
|
}
|
|
if len(manifest.Entries) == 0 {
|
|
return nil, fmt.Errorf("download autogen manifest: entries is empty")
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
func readFreshAutogenManifestCache(path string, maxAge time.Duration) (*autogenManifest, bool, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
if maxAge > 0 && time.Since(info.ModTime()) > maxAge {
|
|
return nil, false, nil
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
var manifest autogenManifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(manifest.Entries) == 0 {
|
|
var legacy partsManifest
|
|
if err := json.Unmarshal(raw, &legacy); err == nil && len(legacy.Categories) > 0 {
|
|
return legacyPartsManifestToAutogen(&legacy), true, nil
|
|
}
|
|
}
|
|
if len(manifest.Entries) == 0 {
|
|
return nil, false, fmt.Errorf("entries is empty")
|
|
}
|
|
return &manifest, true, nil
|
|
}
|
|
|
|
func legacyPartsManifestToAutogen(legacy *partsManifest) *autogenManifest {
|
|
entries := make([]autogenManifestEntry, 0)
|
|
for _, category := range supportedPartCategories {
|
|
for _, rowID := range legacy.Categories[category] {
|
|
entries = append(entries, autogenManifestEntry{
|
|
Group: category,
|
|
RowID: rowID,
|
|
})
|
|
}
|
|
}
|
|
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
|
if cmp := strings.Compare(a.Group, b.Group); cmp != 0 {
|
|
return cmp
|
|
}
|
|
return a.RowID - b.RowID
|
|
})
|
|
return &autogenManifest{
|
|
ID: "parts",
|
|
Repo: legacy.Repo,
|
|
Ref: legacy.Ref,
|
|
GeneratedAt: legacy.GeneratedAt,
|
|
Entries: entries,
|
|
}
|
|
}
|
|
|
|
func writeAutogenManifestCache(path string, manifest *autogenManifest) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create autogen manifest cache dir: %w", err)
|
|
}
|
|
payload, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal autogen manifest cache: %w", err)
|
|
}
|
|
payload = append(payload, '\n')
|
|
current, err := os.ReadFile(path)
|
|
if err == nil && bytes.Equal(current, payload) {
|
|
now := time.Now()
|
|
if err := os.Chtimes(path, now, now); err != nil {
|
|
return fmt.Errorf("refresh autogen manifest cache timestamp: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("read autogen manifest cache: %w", err)
|
|
}
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, payload, 0o644); err != nil {
|
|
return fmt.Errorf("write autogen manifest cache: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return fmt.Errorf("replace autogen manifest cache: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func autogenPartsInventory(entries []autogenManifestEntry) map[string]map[int]struct{} {
|
|
result := make(map[string]map[int]struct{}, len(supportedPartCategories))
|
|
for _, category := range supportedPartCategories {
|
|
result[category] = map[int]struct{}{}
|
|
}
|
|
for _, entry := range entries {
|
|
if !isSupportedPartCategory(entry.Group) || entry.RowID <= 0 {
|
|
continue
|
|
}
|
|
result[entry.Group][entry.RowID] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func augmentWithAutogeneratedCachedModels(collected []nativeCollectedDataset, entries []autogenManifestEntry) ([]nativeCollectedDataset, error) {
|
|
if len(entries) == 0 {
|
|
return collected, nil
|
|
}
|
|
|
|
result := append([]nativeCollectedDataset(nil), collected...)
|
|
for i, dataset := range result {
|
|
if dataset.Dataset.Name != "cachedmodels" {
|
|
continue
|
|
}
|
|
if !slices.Contains(dataset.Columns, "Model") {
|
|
return nil, fmt.Errorf("cachedmodels dataset must include Model column for cachedmodels_rows autogen")
|
|
}
|
|
|
|
rows := make([]map[string]any, len(dataset.Rows), len(dataset.Rows)+len(entries))
|
|
seenModels := make(map[string]struct{}, len(dataset.Rows)+len(entries))
|
|
nextID := 0
|
|
for index, row := range dataset.Rows {
|
|
cloned := cloneRowMap(row)
|
|
rows[index] = cloned
|
|
if rowID, ok := cloned["id"].(int); ok && rowID >= nextID {
|
|
nextID = rowID + 1
|
|
}
|
|
if model, ok := cloned["Model"].(string); ok {
|
|
model = strings.TrimSpace(model)
|
|
if model != "" && model != nullValue {
|
|
seenModels[strings.ToLower(model)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
model := strings.TrimSpace(entry.ModelStem)
|
|
if model == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(model)
|
|
if _, exists := seenModels[key]; exists {
|
|
continue
|
|
}
|
|
rows = append(rows, map[string]any{
|
|
"id": nextID,
|
|
"Model": model,
|
|
})
|
|
seenModels[key] = struct{}{}
|
|
nextID++
|
|
}
|
|
|
|
result[i].Rows = rows
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func augmentWithAutogeneratedAccessoryVisualeffects(collected []nativeCollectedDataset, entries []autogenManifestEntry, consumer project.AutogenConsumerConfig, manifestPolicy *project.AccessoryVisualeffectsConfig) ([]nativeCollectedDataset, error) {
|
|
if len(entries) == 0 {
|
|
return collected, nil
|
|
}
|
|
|
|
policy := resolveAccessoryVisualeffectsPolicy(consumer, manifestPolicy)
|
|
result := append([]nativeCollectedDataset(nil), collected...)
|
|
for i, dataset := range result {
|
|
if dataset.Dataset.Name != "visualeffects" {
|
|
continue
|
|
}
|
|
|
|
rows := make([]map[string]any, len(dataset.Rows))
|
|
rowByKey := make(map[string]map[string]any, len(dataset.Rows))
|
|
historicalLockData := make(map[string]int, len(dataset.LockData))
|
|
for key, rowID := range dataset.LockData {
|
|
historicalLockData[key] = rowID
|
|
}
|
|
if dataset.Dataset.LockPath != "" {
|
|
if existingLockData, err := loadLockfile(dataset.Dataset.LockPath); err == nil {
|
|
for key, rowID := range existingLockData {
|
|
if _, ok := historicalLockData[key]; !ok {
|
|
historicalLockData[key] = rowID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
usedIDs := make(map[int]struct{}, len(dataset.Rows)+len(historicalLockData))
|
|
lockData := make(map[string]int, len(dataset.LockData))
|
|
for _, rowID := range historicalLockData {
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
for key, rowID := range dataset.LockData {
|
|
lockData[key] = rowID
|
|
}
|
|
for index, row := range dataset.Rows {
|
|
cloned := cloneRowMap(row)
|
|
rows[index] = cloned
|
|
if key, ok := cloned["key"].(string); ok && key != "" {
|
|
rowByKey[key] = cloned
|
|
}
|
|
if rowID, ok := cloned["id"].(int); ok {
|
|
usedIDs[rowID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
nextID := nextAvailableAutogenID(usedIDs)
|
|
allocateNextID := func() int {
|
|
rowID := nextID
|
|
usedIDs[rowID] = struct{}{}
|
|
nextID = nextAvailableAutogenID(usedIDs)
|
|
return rowID
|
|
}
|
|
|
|
liveLockKeys := make(map[string]struct{}, len(entries))
|
|
for _, entry := range entries {
|
|
key, label, modelStem, groupPolicy, ok := accessoryVisualeffectIdentity(dataset.Dataset.Name, entry, policy)
|
|
if !ok {
|
|
continue
|
|
}
|
|
lockKey := accessoryVisualeffectLockKey(dataset.Dataset.Name, entry)
|
|
liveLockKeys[lockKey] = struct{}{}
|
|
|
|
if existing, exists := rowByKey[key]; exists {
|
|
applyDiscoveredAccessoryVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy)
|
|
if _, pinned := lockData[lockKey]; !pinned {
|
|
if rowID, ok := existing["id"].(int); ok {
|
|
lockData[lockKey] = rowID
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
rowID, ok := lockData[lockKey]
|
|
if !ok {
|
|
switch {
|
|
case hasKey(historicalLockData, lockKey):
|
|
rowID = historicalLockData[lockKey]
|
|
case hasKey(historicalLockData, key):
|
|
// One-time cutover: adopt the ID from the old config-derived key
|
|
// so IDs carry over with zero shift on the key-scheme switch. The
|
|
// stale old key is pruned below.
|
|
rowID = historicalLockData[key]
|
|
default:
|
|
rowID = allocateNextID()
|
|
}
|
|
lockData[lockKey] = rowID
|
|
}
|
|
newRow := createDefaultAccessoryVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy)
|
|
rows = append(rows, newRow)
|
|
rowByKey[key] = newRow
|
|
}
|
|
|
|
// Stale pruning: drop managed lock keys (this consumer's accessory keys,
|
|
// incl. now-superseded old config-scheme keys) that no live entry resolves
|
|
// to, freeing their IDs for first-free reuse. Safe here because the
|
|
// augmentor only runs on a successful, non-empty resolution; the fail-open
|
|
// paths skip it entirely and never reach this code.
|
|
if managed := autogenConsumerManagedLockKeyMatcher(consumer); managed != nil {
|
|
for lk := range lockData {
|
|
if _, live := liveLockKeys[lk]; live {
|
|
continue
|
|
}
|
|
if managed(lk) {
|
|
delete(lockData, lk)
|
|
}
|
|
}
|
|
}
|
|
|
|
slices.SortFunc(rows, func(a, b map[string]any) int {
|
|
return a["id"].(int) - b["id"].(int)
|
|
})
|
|
result[i].Rows = rows
|
|
result[i].LockData = lockData
|
|
result[i].LockModified = !lockDataEqual(dataset.LockData, lockData)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func nextAvailableAutogenID(used map[int]struct{}) int {
|
|
for rowID := 0; ; rowID++ {
|
|
if _, ok := used[rowID]; !ok {
|
|
return rowID
|
|
}
|
|
}
|
|
}
|
|
|
|
// accessoryVisualeffectLockKey is the model-anchored autogen lock identity:
|
|
// the dataset namespace prefix plus the model source path from vfxs.yml (leading
|
|
// vfxs/ already stripped on entry.Source). Config-derived presentation parts
|
|
// (group token, category, delimiter, case, prefix-stripping) are deliberately
|
|
// excluded so a config-only edit never reshuffles row IDs.
|
|
func accessoryVisualeffectLockKey(dataset string, entry autogenManifestEntry) string {
|
|
return dataset + ":" + strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(entry.Source)), "vfxs/")
|
|
}
|
|
|
|
func hasKey(m map[string]int, key string) bool {
|
|
_, ok := m[key]
|
|
return ok
|
|
}
|
|
|
|
type accessoryVisualeffectsPolicy struct {
|
|
Groups map[string]accessoryVisualeffectsGroupPolicy
|
|
GroupTokenSource string
|
|
CategoryFrom string
|
|
Delimiter string
|
|
Case string
|
|
StripModelPrefixes []string
|
|
KeyFormat string
|
|
LabelFormat string
|
|
ModelColumn string
|
|
RowDefaults map[string]string
|
|
}
|
|
|
|
type accessoryVisualeffectsGroupPolicy struct {
|
|
Prefix string
|
|
ModelColumns []string
|
|
RowDefaults map[string]string
|
|
}
|
|
|
|
func resolveAccessoryVisualeffectsPolicy(consumer project.AutogenConsumerConfig, manifestPolicy *project.AccessoryVisualeffectsConfig) accessoryVisualeffectsPolicy {
|
|
policy := defaultAccessoryVisualeffectsPolicy()
|
|
if manifestPolicy != nil {
|
|
applyAccessoryVisualeffectsConfig(&policy, *manifestPolicy)
|
|
}
|
|
applyAccessoryVisualeffectsConfig(&policy, consumer.AccessoryVisualeffects)
|
|
return policy
|
|
}
|
|
|
|
func applyAccessoryVisualeffectsConfig(policy *accessoryVisualeffectsPolicy, cfg project.AccessoryVisualeffectsConfig) {
|
|
for group, groupCfg := range cfg.Groups {
|
|
group = strings.TrimSpace(group)
|
|
prefix := strings.TrimSpace(groupCfg.Prefix)
|
|
if group == "" {
|
|
continue
|
|
}
|
|
groupPolicy := policy.Groups[group]
|
|
if prefix != "" {
|
|
groupPolicy.Prefix = prefix
|
|
}
|
|
if columns := normalizeAccessoryVisualeffectsModelColumns(groupCfg.ModelColumn, groupCfg.ModelColumns); len(columns) > 0 {
|
|
groupPolicy.ModelColumns = columns
|
|
}
|
|
if groupPolicy.RowDefaults == nil {
|
|
groupPolicy.RowDefaults = map[string]string{}
|
|
}
|
|
mergeStringMap(groupPolicy.RowDefaults, normalizeAccessoryVisualeffectsRowDefaults(groupCfg.RowDefaults))
|
|
policy.Groups[group] = groupPolicy
|
|
}
|
|
if strings.TrimSpace(cfg.GroupTokenSource) != "" {
|
|
policy.GroupTokenSource = strings.TrimSpace(cfg.GroupTokenSource)
|
|
}
|
|
if strings.TrimSpace(cfg.CategoryFrom) != "" {
|
|
policy.CategoryFrom = strings.TrimSpace(cfg.CategoryFrom)
|
|
}
|
|
if cfg.Delimiter != "" {
|
|
policy.Delimiter = cfg.Delimiter
|
|
}
|
|
if strings.TrimSpace(cfg.Case) != "" {
|
|
policy.Case = strings.TrimSpace(cfg.Case)
|
|
}
|
|
if len(cfg.StripModelPrefixes) > 0 {
|
|
policy.StripModelPrefixes = append([]string(nil), cfg.StripModelPrefixes...)
|
|
}
|
|
if strings.TrimSpace(cfg.KeyFormat) != "" {
|
|
policy.KeyFormat = strings.TrimSpace(cfg.KeyFormat)
|
|
}
|
|
if strings.TrimSpace(cfg.LabelFormat) != "" {
|
|
policy.LabelFormat = strings.TrimSpace(cfg.LabelFormat)
|
|
}
|
|
if strings.TrimSpace(cfg.ModelColumn) != "" {
|
|
policy.ModelColumn = strings.TrimSpace(cfg.ModelColumn)
|
|
}
|
|
mergeStringMap(policy.RowDefaults, normalizeAccessoryVisualeffectsRowDefaults(cfg.RowDefaults))
|
|
}
|
|
|
|
func defaultAccessoryVisualeffectsPolicy() accessoryVisualeffectsPolicy {
|
|
return accessoryVisualeffectsPolicy{
|
|
Groups: map[string]accessoryVisualeffectsGroupPolicy{
|
|
"chest_accessories": {},
|
|
"head_accessories": {},
|
|
"head_decorations": {},
|
|
"head_features": {},
|
|
},
|
|
GroupTokenSource: "folder_name",
|
|
CategoryFrom: "immediate_parent",
|
|
Delimiter: "/",
|
|
Case: "preserve",
|
|
StripModelPrefixes: []string{"hfx_", "tfx_", "vfx_", "cfx_"},
|
|
KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}",
|
|
LabelFormat: "{group}{delimiter}{category_segment}{stem}",
|
|
ModelColumn: "Imp_HeadCon_Node",
|
|
RowDefaults: map[string]string{
|
|
"Type_FD": "D",
|
|
"OrientWithGround": "0",
|
|
"OrientWithObject": "1",
|
|
},
|
|
}
|
|
}
|
|
|
|
func normalizeAccessoryVisualeffectsModelColumns(modelColumn string, modelColumns []string) []string {
|
|
var result []string
|
|
if column := strings.TrimSpace(modelColumn); column != "" {
|
|
result = append(result, column)
|
|
}
|
|
for _, column := range modelColumns {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
continue
|
|
}
|
|
if !slices.Contains(result, column) {
|
|
result = append(result, column)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeAccessoryVisualeffectsRowDefaults(defaults map[string]string) map[string]string {
|
|
result := map[string]string{}
|
|
for column, value := range defaults {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
continue
|
|
}
|
|
result[column] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func mergeStringMap(target map[string]string, source map[string]string) {
|
|
for key, value := range source {
|
|
target[key] = value
|
|
}
|
|
}
|
|
|
|
func accessoryVisualeffectIdentity(dataset string, entry autogenManifestEntry, policy accessoryVisualeffectsPolicy) (string, string, string, accessoryVisualeffectsGroupPolicy, bool) {
|
|
entry = normalizeAccessoryVisualeffectEntry(entry)
|
|
group, ok := policy.Groups[entry.Group]
|
|
if !ok || strings.TrimSpace(entry.ModelStem) == "" {
|
|
return "", "", "", accessoryVisualeffectsGroupPolicy{}, false
|
|
}
|
|
modelStem := strings.TrimSpace(entry.ModelStem)
|
|
stem := stripAccessoryVisualeffectModelPrefix(modelStem, policy)
|
|
stem = strings.TrimSpace(stem)
|
|
if stem == "" {
|
|
return "", "", "", accessoryVisualeffectsGroupPolicy{}, false
|
|
}
|
|
category := accessoryVisualeffectCategory(entry, policy)
|
|
groupToken := accessoryVisualeffectGroupToken(entry.Group, group, policy)
|
|
values := map[string]string{
|
|
"dataset": dataset,
|
|
"group": applyAccessoryVisualeffectCase(groupToken, policy),
|
|
"group_raw": entry.Group,
|
|
"prefix": applyAccessoryVisualeffectCase(group.Prefix, policy),
|
|
"category": applyAccessoryVisualeffectCase(category, policy),
|
|
"category_upper": strings.ToUpper(category),
|
|
"category_segment": accessoryVisualeffectDelimitedSegment(applyAccessoryVisualeffectCase(category, policy), policy.Delimiter),
|
|
"category_segment_upper": accessoryVisualeffectDelimitedSegment(strings.ToUpper(category), policy.Delimiter),
|
|
"subgroup": applyAccessoryVisualeffectCase(entry.Subgroup, policy),
|
|
"stem": applyAccessoryVisualeffectCase(stem, policy),
|
|
"stem_upper": strings.ToUpper(stem),
|
|
"model_stem": modelStem,
|
|
"delimiter": policy.Delimiter,
|
|
}
|
|
key := expandAccessoryVisualeffectsFormat(policy.KeyFormat, values)
|
|
label := expandAccessoryVisualeffectsFormat(policy.LabelFormat, values)
|
|
if strings.TrimSpace(key) == "" || strings.TrimSpace(label) == "" {
|
|
return "", "", "", accessoryVisualeffectsGroupPolicy{}, false
|
|
}
|
|
return key, label, modelStem, group, true
|
|
}
|
|
|
|
func normalizeAccessoryVisualeffectEntry(entry autogenManifestEntry) autogenManifestEntry {
|
|
entry.Group = strings.TrimSpace(entry.Group)
|
|
if strings.TrimSpace(entry.Group) == "" && strings.TrimSpace(entry.Source) != "" {
|
|
parts := strings.Split(filepath.ToSlash(entry.Source), "/")
|
|
if len(parts) > 1 {
|
|
entry.Group = parts[0]
|
|
}
|
|
}
|
|
if strings.TrimSpace(entry.Subgroup) == "" && strings.TrimSpace(entry.Source) != "" {
|
|
parts := strings.Split(filepath.ToSlash(entry.Source), "/")
|
|
if len(parts) > 2 {
|
|
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
|
|
}
|
|
}
|
|
if strings.TrimSpace(entry.Category) == "" && strings.TrimSpace(entry.Source) != "" {
|
|
parts := strings.Split(filepath.ToSlash(entry.Source), "/")
|
|
if len(parts) > 2 {
|
|
entry.Category = parts[len(parts)-2]
|
|
}
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func stripAccessoryVisualeffectModelPrefix(modelStem string, policy accessoryVisualeffectsPolicy) string {
|
|
stem := modelStem
|
|
for _, prefix := range policy.StripModelPrefixes {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
stem = strings.TrimPrefix(stem, prefix)
|
|
}
|
|
return stem
|
|
}
|
|
|
|
func accessoryVisualeffectGroupToken(groupName string, group accessoryVisualeffectsGroupPolicy, policy accessoryVisualeffectsPolicy) string {
|
|
if policy.GroupTokenSource == "folder_name" {
|
|
return groupName
|
|
}
|
|
if strings.TrimSpace(group.Prefix) != "" {
|
|
return group.Prefix
|
|
}
|
|
return groupName
|
|
}
|
|
|
|
func accessoryVisualeffectCategory(entry autogenManifestEntry, policy accessoryVisualeffectsPolicy) string {
|
|
switch policy.CategoryFrom {
|
|
case "immediate_parent":
|
|
return strings.TrimSpace(entry.Category)
|
|
case "full_subgroup":
|
|
return strings.TrimSpace(entry.Subgroup)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func accessoryVisualeffectDelimitedSegment(value, delimiter string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
return value + delimiter
|
|
}
|
|
|
|
func applyAccessoryVisualeffectCase(value string, policy accessoryVisualeffectsPolicy) string {
|
|
switch policy.Case {
|
|
case "lower":
|
|
return strings.ToLower(value)
|
|
case "upper":
|
|
return strings.ToUpper(value)
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func expandAccessoryVisualeffectsFormat(format string, values map[string]string) string {
|
|
result := format
|
|
for key, value := range values {
|
|
result = strings.ReplaceAll(result, "{"+key+"}", value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func createDefaultAccessoryVisualeffectRow(columns []string, rowID int, key, label, modelStem string, policy accessoryVisualeffectsPolicy, group accessoryVisualeffectsGroupPolicy) map[string]any {
|
|
row := map[string]any{
|
|
"id": rowID,
|
|
"key": key,
|
|
}
|
|
for _, column := range columns {
|
|
row[column] = nullValue
|
|
}
|
|
row["Label"] = label
|
|
for column, value := range policy.RowDefaults {
|
|
row[column] = value
|
|
}
|
|
for column, value := range group.RowDefaults {
|
|
row[column] = value
|
|
}
|
|
for _, column := range accessoryVisualeffectModelColumns(policy, group) {
|
|
row[column] = modelStem
|
|
}
|
|
return row
|
|
}
|
|
|
|
func applyDiscoveredAccessoryVisualeffectDefaults(row map[string]any, columns []string, modelStem, label string, policy accessoryVisualeffectsPolicy, group accessoryVisualeffectsGroupPolicy) {
|
|
if isNullLikeValue(row["Label"]) {
|
|
row["Label"] = label
|
|
}
|
|
for column, value := range policy.RowDefaults {
|
|
if isNullLikeValue(row[column]) {
|
|
row[column] = value
|
|
}
|
|
}
|
|
for column, value := range group.RowDefaults {
|
|
if isNullLikeValue(row[column]) {
|
|
row[column] = value
|
|
}
|
|
}
|
|
for _, column := range accessoryVisualeffectModelColumns(policy, group) {
|
|
if isNullLikeValue(row[column]) {
|
|
row[column] = modelStem
|
|
}
|
|
}
|
|
for _, column := range columns {
|
|
if _, ok := row[column]; !ok {
|
|
row[column] = nullValue
|
|
}
|
|
}
|
|
}
|
|
|
|
func accessoryVisualeffectModelColumns(policy accessoryVisualeffectsPolicy, group accessoryVisualeffectsGroupPolicy) []string {
|
|
if len(group.ModelColumns) > 0 {
|
|
return group.ModelColumns
|
|
}
|
|
if strings.TrimSpace(policy.ModelColumn) == "" {
|
|
return nil
|
|
}
|
|
return []string{policy.ModelColumn}
|
|
}
|
|
|
|
func formatAutogenManifest(root string, producer project.AutogenProducerConfig, repo, ref string, entries []autogenManifestEntry) ([]byte, error) {
|
|
manifest := autogenManifest{
|
|
ID: producer.ID,
|
|
Repo: repo,
|
|
Ref: ref,
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Entries: entries,
|
|
}
|
|
if autogenAccessoryVisualeffectsConfigConfigured(producer.AccessoryVisualeffects) {
|
|
cfg := producer.AccessoryVisualeffects
|
|
manifest.AccessoryVisualeffects = &cfg
|
|
}
|
|
return json.MarshalIndent(manifest, "", " ")
|
|
}
|
|
|
|
func autogenAccessoryVisualeffectsConfigConfigured(cfg project.AccessoryVisualeffectsConfig) bool {
|
|
return len(cfg.Groups) > 0 ||
|
|
strings.TrimSpace(cfg.GroupTokenSource) != "" ||
|
|
strings.TrimSpace(cfg.CategoryFrom) != "" ||
|
|
cfg.Delimiter != "" ||
|
|
strings.TrimSpace(cfg.Case) != "" ||
|
|
len(cfg.StripModelPrefixes) > 0 ||
|
|
strings.TrimSpace(cfg.KeyFormat) != "" ||
|
|
strings.TrimSpace(cfg.LabelFormat) != "" ||
|
|
strings.TrimSpace(cfg.ModelColumn) != "" ||
|
|
len(cfg.RowDefaults) > 0
|
|
}
|
|
|
|
func parseAutogenManifestEntryList(raw []byte) ([]autogenManifestEntry, error) {
|
|
var manifest autogenManifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
return manifest.Entries, nil
|
|
}
|
|
|
|
func normalizeAutogenNumericValue(value any) string {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case float64:
|
|
return strconv.Itoa(int(typed))
|
|
default:
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
}
|