From 5d3ec015bd80b1302168c496213afd1deb5c97be Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Fri, 30 Jan 2026 08:05:00 -0500 Subject: [PATCH 1/2] [minor] add a drupal registry feature to interact with entities --- cmd/node.go | 200 ++++++++++++++ cmd/root.go | 1 + drupal/cache.go | 199 ++++++++++++++ drupal/client.go | 179 +++++++++++++ drupal/node.go | 187 +++++++++++++ drupal/registry.go | 244 +++++++++++++++++ go.mod | 2 +- model/bool_field.go | 39 +++ model/config_reference_field.go | 9 + model/edtf_field.go | 32 +++ model/email_field.go | 31 +++ model/entity_reference_field.go | 50 ++++ model/generic_field.go | 44 ++++ model/geolocation_field.go | 56 ++++ model/hierarchical_geographic_field.go | 49 ++++ model/int_field.go | 46 ++++ model/part_detail.go | 47 ++++ model/related_item_field.go | 46 ++++ model/term.go | 8 + model/type_relation_field.go | 47 ++++ model/typed_text_field.go | 56 ++++ scripts/generate-bundles-from-repo.sh | 57 ++++ scripts/generate-bundles/main.go | 348 +++++++++++++++++++++++++ 23 files changed, 1976 insertions(+), 1 deletion(-) create mode 100644 cmd/node.go create mode 100644 drupal/cache.go create mode 100644 drupal/client.go create mode 100644 drupal/node.go create mode 100644 drupal/registry.go create mode 100644 model/bool_field.go create mode 100644 model/config_reference_field.go create mode 100644 model/edtf_field.go create mode 100644 model/email_field.go create mode 100644 model/entity_reference_field.go create mode 100644 model/generic_field.go create mode 100644 model/geolocation_field.go create mode 100644 model/hierarchical_geographic_field.go create mode 100644 model/int_field.go create mode 100644 model/part_detail.go create mode 100644 model/related_item_field.go create mode 100644 model/term.go create mode 100644 model/type_relation_field.go create mode 100644 model/typed_text_field.go create mode 100755 scripts/generate-bundles-from-repo.sh create mode 100644 scripts/generate-bundles/main.go diff --git a/cmd/node.go b/cmd/node.go new file mode 100644 index 0000000..b875cdf --- /dev/null +++ b/cmd/node.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/libops/sitectl-drupal/drupal" + "github.com/spf13/cobra" +) + +// clientKey is the context key for the Drupal client +type clientKey struct{} + +var bundleConfig string + +// nodeCmd represents the node command group +var nodeCmd = &cobra.Command{ + Use: "node", + Short: "Drupal node operations", + Long: `Commands for interacting with Drupal nodes via the JSON API. + +Use --bundle-config to load bundle definitions from a Drupal config sync export +or generated YAML file. This enables field validation and type information.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var opts []drupal.ClientOption + + // Load bundle config if provided + if bundleConfig != "" { + opts = append(opts, drupal.WithBundlesFromPath(bundleConfig)) + } + + // Load auth from environment + opts = append(opts, drupal.WithBasicAuthFromEnv("DRUPAL_USERNAME", "DRUPAL_PASSWORD")) + + client := drupal.NewClient(opts...) + + // Store client in context for subcommands + ctx := context.WithValue(cmd.Context(), clientKey{}, client) + cmd.SetContext(ctx) + + return nil + }, +} + +func init() { + // Register persistent flag for bundle configuration + nodeCmd.PersistentFlags().StringVar(&bundleConfig, "bundle-config", "", + "Path to bundle configuration YAML (from Drupal config sync or generated)") + + // Add subcommands + nodeCmd.AddCommand(nodeFetchCmd) + nodeCmd.AddCommand(nodeValidateCmd) + nodeCmd.AddCommand(nodeBundlesCmd) + nodeCmd.AddCommand(nodeCacheClearCmd) +} + +// getClient retrieves the Drupal client from the command context +func getClient(cmd *cobra.Command) *drupal.Client { + return cmd.Context().Value(clientKey{}).(*drupal.Client) +} + +// nodeFetchCmd fetches and displays a node +var nodeFetchCmd = &cobra.Command{ + Use: "fetch ", + Short: "Fetch a node from the Drupal JSON API", + Long: `Fetches a node from the Drupal JSON API and displays it as JSON. + +The URL should be a full URL to the node JSON endpoint, e.g.: + https://example.com/node/123?_format=json + +If --bundle-config is provided, the node will be validated against its bundle schema.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url := args[0] + client := getClient(cmd) + + node, err := client.FetchNode(url) + if err != nil { + return fmt.Errorf("fetching node: %w", err) + } + + // Validate if registry has bundles loaded + if errors := node.Validate(); len(errors) > 0 { + fmt.Fprintf(os.Stderr, "Warning: validation errors for bundle %q:\n", node.Bundle()) + for _, e := range errors { + fmt.Fprintf(os.Stderr, " - %s\n", e) + } + } + + // Output as JSON + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(node) + }, +} + +// nodeValidateCmd validates a node against bundle schema +var nodeValidateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate a node against its bundle schema", + Long: `Fetches a node and validates it against the bundle schema. + +Requires --bundle-config to be set with bundle definitions.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url := args[0] + client := getClient(cmd) + + node, err := client.FetchNode(url) + if err != nil { + return fmt.Errorf("fetching node: %w", err) + } + + registry := node.Registry() + if registry == nil { + return fmt.Errorf("no bundle definitions loaded - use --bundle-config") + } + + def, ok := registry.GetBundle(node.Bundle()) + if !ok { + return fmt.Errorf("unknown bundle %q - not in registry", node.Bundle()) + } + + fmt.Printf("Validating node (nid: %s) against bundle: %s\n", node.Nid.String(), def.Name) + if def.Description != "" { + fmt.Printf("Bundle description: %s\n", def.Description) + } + fmt.Println() + + errors := node.Validate() + if len(errors) == 0 { + fmt.Println("Node is valid") + return nil + } + + fmt.Println("Validation errors:") + for _, e := range errors { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("validation failed with %d error(s)", len(errors)) + }, +} + +// nodeBundlesCmd lists registered bundles +var nodeBundlesCmd = &cobra.Command{ + Use: "bundles", + Short: "List all registered bundle definitions", + Long: `Lists all bundle definitions loaded from --bundle-config. + +Shows bundle names, descriptions, field counts, and required fields.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := getClient(cmd) + + bundles := client.Registry.ListBundles() + if len(bundles) == 0 { + fmt.Println("No bundles registered") + fmt.Println("Use --bundle-config to load bundle definitions") + return nil + } + + fmt.Println("Registered bundles:") + for _, name := range bundles { + def, _ := client.Registry.GetBundle(name) + fmt.Printf("\n %s (%s)\n", def.Name, def.MachineName) + if def.Description != "" { + fmt.Printf(" %s\n", def.Description) + } + fmt.Printf(" Fields: %d\n", len(def.Fields)) + + // List required fields + var required []string + for _, f := range def.Fields { + if f.Required { + required = append(required, f.Name) + } + } + if len(required) > 0 { + fmt.Printf(" Required: %v\n", required) + } + } + + return nil + }, +} + +// nodeCacheClearCmd clears the bundle definition cache +var nodeCacheClearCmd = &cobra.Command{ + Use: "cache-clear", + Short: "Clear cached bundle definitions", + Long: `Clears the bundle definition cache from ~/.sitectl/cache`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := drupal.ClearCache(); err != nil { + return fmt.Errorf("clearing cache: %w", err) + } + fmt.Println("Bundle definition cache cleared") + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go index aeb8843..51908bf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,4 +21,5 @@ func RegisterCommands(s *plugin.SDK) { sdk.AddCommand(backupCmd) sdk.AddCommand(drushCmd) sdk.AddCommand(loginCmd) + sdk.AddCommand(nodeCmd) } diff --git a/drupal/cache.go b/drupal/cache.go new file mode 100644 index 0000000..b219776 --- /dev/null +++ b/drupal/cache.go @@ -0,0 +1,199 @@ +package drupal + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + cacheDir = ".sitectl/cache" + cacheVersion = "v1" // bump this to invalidate all caches on schema change +) + +// CachedBundleConfig is the serialized format for disk cache +type CachedBundleConfig struct { + Version string `json:"version"` + ConfigPath string `json:"config_path"` + CachedAt time.Time `json:"cached_at"` + Bundles []BundleDefinition `json:"bundles"` +} + +// getCacheDir returns the cache directory path, creating it if needed +func getCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home dir: %w", err) + } + + dir := filepath.Join(home, cacheDir) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + return dir, nil +} + +// getCacheFilename generates a cache filename from the config path +func getCacheFilename(configPath string) string { + hash := md5.Sum([]byte(configPath)) + return fmt.Sprintf("bundles-%s-%s.json", cacheVersion, hex.EncodeToString(hash[:])) +} + +// getNewestFileTime walks a directory and returns the newest modification time +func getNewestFileTime(path string) (time.Time, error) { + var newest time.Time + + info, err := os.Stat(path) + if err != nil { + return newest, err + } + + // If it's a single file, return its mod time + if !info.IsDir() { + return info.ModTime(), nil + } + + // Walk directory for newest YAML file + err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + // Only consider YAML files + if !strings.HasSuffix(p, ".yaml") && !strings.HasSuffix(p, ".yml") { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if info.ModTime().After(newest) { + newest = info.ModTime() + } + return nil + }) + + return newest, err +} + +// LoadCachedRegistry attempts to load a cached registry for the given config path. +// Returns nil if cache doesn't exist or is stale. +func LoadCachedRegistry(configPath string) (*BundleRegistry, error) { + cacheDir, err := getCacheDir() + if err != nil { + return nil, err + } + + cachePath := filepath.Join(cacheDir, getCacheFilename(configPath)) + + // Check if cache file exists + cacheInfo, err := os.Stat(cachePath) + if os.IsNotExist(err) { + return nil, nil // No cache, not an error + } + if err != nil { + return nil, err + } + + // Check if any source file is newer than cache + newestSource, err := getNewestFileTime(configPath) + if err != nil { + return nil, err + } + + if newestSource.After(cacheInfo.ModTime()) { + // Cache is stale + return nil, nil + } + + // Load cache + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, err + } + + var cached CachedBundleConfig + if err := json.Unmarshal(data, &cached); err != nil { + // Corrupted cache, ignore + return nil, nil + } + + // Verify cache version + if cached.Version != cacheVersion { + return nil, nil + } + + // Rebuild registry from cached data + registry := NewBundleRegistry() + for i := range cached.Bundles { + registry.RegisterBundle(&cached.Bundles[i]) + } + + return registry, nil +} + +// SaveRegistryCache saves the bundles from a registry to disk cache +func SaveRegistryCache(configPath string, registry *BundleRegistry) error { + cacheDir, err := getCacheDir() + if err != nil { + return err + } + + // Collect all bundles + bundles := make([]BundleDefinition, 0, len(registry.bundles)) + for _, b := range registry.bundles { + bundles = append(bundles, *b) + } + + cached := CachedBundleConfig{ + Version: cacheVersion, + ConfigPath: configPath, + CachedAt: time.Now(), + Bundles: bundles, + } + + data, err := json.MarshalIndent(cached, "", " ") + if err != nil { + return err + } + + cachePath := filepath.Join(cacheDir, getCacheFilename(configPath)) + return os.WriteFile(cachePath, data, 0644) +} + +// ClearCache removes all cached bundle registries +func ClearCache() error { + cacheDir, err := getCacheDir() + if err != nil { + return err + } + + entries, err := os.ReadDir(cacheDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "bundles-") && strings.HasSuffix(entry.Name(), ".json") { + if err := os.Remove(filepath.Join(cacheDir, entry.Name())); err != nil { + return err + } + } + } + + return nil +} diff --git a/drupal/client.go b/drupal/client.go new file mode 100644 index 0000000..33ef25c --- /dev/null +++ b/drupal/client.go @@ -0,0 +1,179 @@ +package drupal + +import ( + "embed" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" +) + +// Client provides access to Drupal APIs with bundle configuration support. +// Plugins should create their own NewClient wrapper that pre-loads their bundle definitions. +// +// Example usage in a plugin: +// +// //go:embed bundles/*.yaml +// var bundleFS embed.FS +// +// func NewIslandoraClient(opts ...drupal.ClientOption) *drupal.Client { +// // Start with plugin's embedded bundles +// allOpts := []drupal.ClientOption{ +// drupal.WithEmbeddedBundles(bundleFS, "bundles"), +// } +// // Add user's options (may override with custom bundles) +// allOpts = append(allOpts, opts...) +// return drupal.NewClient(allOpts...) +// } +type Client struct { + Registry *BundleRegistry + BaseURL string + + // HTTP client configuration + Username string + Password string +} + +// ClientOption configures a Client +type ClientOption func(*Client) + +// WithEmbeddedBundles loads bundle definitions from an embedded filesystem. +// This is the primary mechanism for plugins to ship their own bundle configs. +func WithEmbeddedBundles(fsys embed.FS, dir string) ClientOption { + return func(c *Client) { + if err := c.Registry.LoadEmbedded(fsys, dir); err != nil { + slog.Warn("Failed to load embedded bundles", "dir", dir, "error", err) + } + } +} + +// WithBundlesFromPath loads bundle definitions from a file or directory. +// Uses disk caching to avoid re-parsing on every invocation. +func WithBundlesFromPath(path string) ClientOption { + return func(c *Client) { + if path == "" { + return + } + + // Try to load from cache first + cached, err := LoadCachedRegistry(path) + if err == nil && cached != nil { + c.Registry.Merge(cached) + return + } + + // Cache miss or stale - load from disk + if err := c.Registry.LoadFromPath(path); err != nil { + slog.Warn("Failed to load bundles from path", "path", path, "error", err) + return + } + + // Save to cache for next time (ignore errors) + _ = SaveRegistryCache(path, c.Registry) + } +} + +// WithBaseURL sets the base URL for API calls +func WithBaseURL(url string) ClientOption { + return func(c *Client) { + c.BaseURL = url + } +} + +// WithBasicAuth sets credentials for HTTP Basic authentication +func WithBasicAuth(username, password string) ClientOption { + return func(c *Client) { + c.Username = username + c.Password = password + } +} + +// WithBasicAuthFromEnv loads credentials from environment variables. +// Falls back to DRUPAL_USERNAME/DRUPAL_PASSWORD if the specified vars are empty. +func WithBasicAuthFromEnv(usernameVar, passwordVar string) ClientOption { + return func(c *Client) { + username := os.Getenv(usernameVar) + if username == "" { + username = os.Getenv("DRUPAL_USERNAME") + } + password := os.Getenv(passwordVar) + if password == "" { + password = os.Getenv("DRUPAL_PASSWORD") + } + c.Username = username + c.Password = password + } +} + +// NewClient creates a new Drupal API client. +// Unlike distribution-specific clients, this starts with an empty registry. +// Use WithEmbeddedBundles or WithBundlesFromPath to load bundle definitions. +func NewClient(opts ...ClientOption) *Client { + c := &Client{ + Registry: NewBundleRegistry(), + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +// FetchNode fetches a single node from the Drupal API and attaches the registry. +func (c *Client) FetchNode(url string) (*Node, error) { + req, err := c.newRequest("GET", url) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %s", resp.Status) + } + + var node Node + if err := json.NewDecoder(resp.Body).Decode(&node); err != nil { + return nil, fmt.Errorf("decode failed: %w", err) + } + + node.SetRegistry(c.Registry) + return &node, nil +} + +// newRequest creates an HTTP request with authentication if configured +func (c *Client) newRequest(method, url string) (*http.Request, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + + if c.Username != "" && c.Password != "" { + req.SetBasicAuth(c.Username, c.Password) + } + + return req, nil +} + +// ValidateConfig checks if bundle configuration loaded successfully +func (c *Client) ValidateConfig() error { + if len(c.Registry.bundles) == 0 { + return ErrNoBundles + } + return nil +} + +// Errors +type clientError string + +func (e clientError) Error() string { return string(e) } + +const ( + ErrNoBundles clientError = "no bundle definitions loaded" +) diff --git a/drupal/node.go b/drupal/node.go new file mode 100644 index 0000000..d18b1e7 --- /dev/null +++ b/drupal/node.go @@ -0,0 +1,187 @@ +package drupal + +import ( + "encoding/json" + "fmt" + + "github.com/libops/sitectl-drupal/model" +) + +// Node represents a Drupal node with static core fields and dynamic bundle fields. +// Core fields (nid, uuid, type, title, etc.) are always present and typed. +// Bundle-specific fields are stored in Fields and can be accessed via typed getters. +type Node struct { + // Core fields - present on all nodes + Nid model.IntField `json:"nid"` + UUID model.GenericField `json:"uuid"` + Type model.ConfigReferenceField `json:"type"` + Title model.GenericField `json:"title"` + Status model.BoolField `json:"status"` + Created model.GenericField `json:"created"` + Changed model.GenericField `json:"changed"` + Langcode model.GenericField `json:"langcode"` + + // Bundle-specific fields stored as raw JSON for lazy decoding + Fields map[string]json.RawMessage `json:"-"` + + // Registry reference for field type lookups and validation + registry *BundleRegistry `json:"-"` +} + +// SetRegistry attaches a bundle registry to this node for validation and field lookups +func (n *Node) SetRegistry(r *BundleRegistry) { + n.registry = r +} + +// Registry returns the attached bundle registry, or nil if none +func (n *Node) Registry() *BundleRegistry { + return n.registry +} + +// Validate checks if this node has all required fields for its bundle. +// Returns nil if valid or no registry is attached. +func (n *Node) Validate() []string { + if n.registry == nil { + return nil + } + return n.registry.ValidateNode(n) +} + +// GetFieldType returns the type of a field on this node's bundle. +// Requires a registry to be attached. +func (n *Node) GetFieldType(fieldName string) (FieldType, bool) { + if n.registry == nil { + return "", false + } + return n.registry.GetFieldType(n.Bundle(), fieldName) +} + +// GetFieldDefinition returns the full field definition for a field on this node's bundle. +// Requires a registry to be attached. +func (n *Node) GetFieldDefinition(fieldName string) (*FieldDefinition, bool) { + if n.registry == nil { + return nil, false + } + return n.registry.GetField(n.Bundle(), fieldName) +} + +// Bundle returns the bundle (content type) machine name +func (n *Node) Bundle() string { + if len(n.Type) > 0 { + return n.Type[0].TargetId + } + return "" +} + +// GetField returns a field value by name as the specified type. +// Returns an error if the field doesn't exist or can't be decoded. +func GetField[T any](n *Node, fieldName string) (T, error) { + var zero T + raw, ok := n.Fields[fieldName] + if !ok { + return zero, fmt.Errorf("field %q not found on node", fieldName) + } + + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return zero, fmt.Errorf("failed to decode field %q: %w", fieldName, err) + } + return result, nil +} + +// MustGetField is like GetField but panics on error. +// Use only when you're certain the field exists and has the correct type. +func MustGetField[T any](n *Node, fieldName string) T { + result, err := GetField[T](n, fieldName) + if err != nil { + panic(err) + } + return result +} + +// GetGenericField returns a GenericField by name (common case) +func (n *Node) GetGenericField(fieldName string) (model.GenericField, error) { + return GetField[model.GenericField](n, fieldName) +} + +// GetEntityReferenceField returns an EntityReferenceField by name +func (n *Node) GetEntityReferenceField(fieldName string) (model.EntityReferenceField, error) { + return GetField[model.EntityReferenceField](n, fieldName) +} + +// GetTypedTextField returns a TypedTextField by name +func (n *Node) GetTypedTextField(fieldName string) (model.TypedTextField, error) { + return GetField[model.TypedTextField](n, fieldName) +} + +// HasField checks if a field exists on the node +func (n *Node) HasField(fieldName string) bool { + _, ok := n.Fields[fieldName] + return ok +} + +// FieldNames returns all field names present on this node +func (n *Node) FieldNames() []string { + names := make([]string, 0, len(n.Fields)) + for name := range n.Fields { + names = append(names, name) + } + return names +} + +// UnmarshalJSON implements custom JSON unmarshaling to capture all fields +func (n *Node) UnmarshalJSON(data []byte) error { + // First, unmarshal into a map to capture all fields + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Core fields to extract + coreFields := map[string]any{ + "nid": &n.Nid, + "uuid": &n.UUID, + "type": &n.Type, + "title": &n.Title, + "status": &n.Status, + "created": &n.Created, + "changed": &n.Changed, + "langcode": &n.Langcode, + } + + // Extract core fields + for name, ptr := range coreFields { + if rawVal, ok := raw[name]; ok { + if err := json.Unmarshal(rawVal, ptr); err != nil { + return fmt.Errorf("failed to unmarshal core field %q: %w", name, err) + } + delete(raw, name) + } + } + + // Remaining fields are bundle-specific + n.Fields = raw + + return nil +} + +// MarshalJSON implements custom JSON marshaling +func (n *Node) MarshalJSON() ([]byte, error) { + // Start with bundle fields + result := make(map[string]any, len(n.Fields)+8) + for k, v := range n.Fields { + result[k] = v + } + + // Add core fields + result["nid"] = n.Nid + result["uuid"] = n.UUID + result["type"] = n.Type + result["title"] = n.Title + result["status"] = n.Status + result["created"] = n.Created + result["changed"] = n.Changed + result["langcode"] = n.Langcode + + return json.Marshal(result) +} diff --git a/drupal/registry.go b/drupal/registry.go new file mode 100644 index 0000000..ad1b936 --- /dev/null +++ b/drupal/registry.go @@ -0,0 +1,244 @@ +// Package drupal provides types and utilities for interacting with Drupal APIs. +// It is designed to be extended by distribution-specific plugins (like Islandora) +// that can register their own bundle definitions. +package drupal + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// FieldType represents the type of a Drupal field +type FieldType string + +const ( + FieldTypeGeneric FieldType = "generic" + FieldTypeInt FieldType = "int" + FieldTypeBool FieldType = "bool" + FieldTypeEmail FieldType = "email" + FieldTypeEdtf FieldType = "edtf" + FieldTypeEntityReference FieldType = "entity_reference" + FieldTypeConfigReference FieldType = "config_reference" + FieldTypeTypedText FieldType = "typed_text" + FieldTypeTypedRelation FieldType = "typed_relation" + FieldTypeGeoLocation FieldType = "geolocation" + FieldTypePartDetail FieldType = "part_detail" + FieldTypeHierarchical FieldType = "hierarchical_geographic" + FieldTypeRelatedItem FieldType = "related_item" +) + +// FieldDefinition describes a field on a bundle +type FieldDefinition struct { + Name string `yaml:"name"` + Type FieldType `yaml:"type"` + Label string `yaml:"label,omitempty"` + Required bool `yaml:"required,omitempty"` + Cardinality int `yaml:"cardinality,omitempty"` // -1 = unlimited, 1 = single, N = max N + Description string `yaml:"description,omitempty"` +} + +// BundleDefinition describes a Drupal content type (bundle) +type BundleDefinition struct { + Name string `yaml:"name"` + MachineName string `yaml:"machine_name"` + Description string `yaml:"description,omitempty"` + Fields []FieldDefinition `yaml:"fields"` +} + +// BundleConfig is the top-level config file format +type BundleConfig struct { + Version string `yaml:"version"` + Bundles []BundleDefinition `yaml:"bundles"` +} + +// BundleRegistry manages bundle definitions from multiple sources. +// Plugins can register their own bundles using the various Load* methods. +type BundleRegistry struct { + bundles map[string]*BundleDefinition // keyed by machine_name + fields map[string]map[string]*FieldDefinition // bundle -> field_name -> definition +} + +// NewBundleRegistry creates an empty registry. +// Use the Load* methods to populate it with bundle definitions. +func NewBundleRegistry() *BundleRegistry { + return &BundleRegistry{ + bundles: make(map[string]*BundleDefinition), + fields: make(map[string]map[string]*FieldDefinition), + } +} + +// LoadEmbedded loads bundle definitions from an embedded filesystem. +// This is the primary mechanism for plugins to ship their own bundle configs. +// +// Example usage in a plugin: +// +// //go:embed bundles/*.yaml +// var bundleFS embed.FS +// +// func init() { +// registry.LoadEmbedded(bundleFS, "bundles") +// } +func (r *BundleRegistry) LoadEmbedded(fsys embed.FS, dir string) error { + return fs.WalkDir(fsys, dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { + return nil + } + + data, err := fsys.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + return r.loadYAML(data, path) + }) +} + +// LoadFromPath loads bundle definitions from a file or directory path. +// This allows users to supply custom configs at runtime. +func (r *BundleRegistry) LoadFromPath(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + + if info.IsDir() { + return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(p, ".yaml") && !strings.HasSuffix(p, ".yml") { + return nil + } + return r.loadFile(p) + }) + } + + return r.loadFile(path) +} + +// LoadFromBytes loads bundle definitions from raw YAML bytes. +// Useful for testing or programmatic bundle registration. +func (r *BundleRegistry) LoadFromBytes(data []byte) error { + return r.loadYAML(data, "") +} + +func (r *BundleRegistry) loadFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + return r.loadYAML(data, path) +} + +func (r *BundleRegistry) loadYAML(data []byte, source string) error { + var config BundleConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parsing %s: %w", source, err) + } + + for i := range config.Bundles { + bundle := &config.Bundles[i] + r.RegisterBundle(bundle) + } + + return nil +} + +// RegisterBundle adds or updates a bundle definition in the registry. +// If a bundle with the same machine name exists, it will be replaced. +// This allows plugins to override base bundle definitions. +func (r *BundleRegistry) RegisterBundle(bundle *BundleDefinition) { + r.bundles[bundle.MachineName] = bundle + r.fields[bundle.MachineName] = make(map[string]*FieldDefinition) + + for i := range bundle.Fields { + field := &bundle.Fields[i] + r.fields[bundle.MachineName][field.Name] = field + } +} + +// GetBundle returns a bundle definition by machine name +func (r *BundleRegistry) GetBundle(machineName string) (*BundleDefinition, bool) { + b, ok := r.bundles[machineName] + return b, ok +} + +// GetField returns a field definition for a bundle +func (r *BundleRegistry) GetField(bundleName, fieldName string) (*FieldDefinition, bool) { + fields, ok := r.fields[bundleName] + if !ok { + return nil, false + } + f, ok := fields[fieldName] + return f, ok +} + +// GetFieldType returns the type of a field on a bundle +func (r *BundleRegistry) GetFieldType(bundleName, fieldName string) (FieldType, bool) { + f, ok := r.GetField(bundleName, fieldName) + if !ok { + return "", false + } + return f.Type, true +} + +// ListBundles returns all registered bundle machine names +func (r *BundleRegistry) ListBundles() []string { + names := make([]string, 0, len(r.bundles)) + for name := range r.bundles { + names = append(names, name) + } + return names +} + +// ListFields returns all field names for a bundle +func (r *BundleRegistry) ListFields(bundleName string) []string { + fields, ok := r.fields[bundleName] + if !ok { + return nil + } + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + return names +} + +// ValidateNode checks if a node has all required fields for its bundle +func (r *BundleRegistry) ValidateNode(n *Node) []string { + var errors []string + bundle := n.Bundle() + + def, ok := r.GetBundle(bundle) + if !ok { + // Unknown bundle - can't validate + return nil + } + + for _, field := range def.Fields { + if field.Required && !n.HasField(field.Name) { + errors = append(errors, fmt.Sprintf("missing required field %q", field.Name)) + } + } + + return errors +} + +// Merge combines another registry into this one. +// Bundles from the other registry will override existing bundles with the same name. +func (r *BundleRegistry) Merge(other *BundleRegistry) { + for _, name := range other.ListBundles() { + if def, ok := other.GetBundle(name); ok { + r.RegisterBundle(def) + } + } +} diff --git a/go.mod b/go.mod index 39de64b..f37a734 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/libops/sitectl v0.3.1 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -42,5 +43,4 @@ require ( golang.org/x/crypto v0.46.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/model/bool_field.go b/model/bool_field.go new file mode 100644 index 0000000..e9fdb10 --- /dev/null +++ b/model/bool_field.go @@ -0,0 +1,39 @@ +package model + +import "strings" + +type BoolField []Bool + +type Bool struct { + Value bool `json:"value"` +} + +func (field *Bool) String() string { + if field.Value { + return "1" + } + + return "0" +} + +func (field BoolField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *BoolField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]Bool, len(values)) + for i, value := range values { + s[i] = Bool{} + if value == "1" { + s[i].Value = true + } else { + s[i].Value = false + } + } + return nil +} diff --git a/model/config_reference_field.go b/model/config_reference_field.go new file mode 100644 index 0000000..5839d3a --- /dev/null +++ b/model/config_reference_field.go @@ -0,0 +1,9 @@ +package model + +type ConfigReferenceField []ConfigReference + +type ConfigReference struct { + TargetId string `json:"target_id"` + TargetType string `json:"target_type"` + TargetUuid string `json:"target_uuid"` +} diff --git a/model/edtf_field.go b/model/edtf_field.go new file mode 100644 index 0000000..4f999cc --- /dev/null +++ b/model/edtf_field.go @@ -0,0 +1,32 @@ +package model + +import "strings" + +type EdtfField []Edtf + +type Edtf struct { + Value string `json:"value"` +} + +func (field EdtfField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *EdtfField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]Edtf, len(values)) + for i, value := range values { + s[i] = Edtf{ + Value: value, + } + } + return nil +} + +func (field *Edtf) String() string { + return field.Value +} diff --git a/model/email_field.go b/model/email_field.go new file mode 100644 index 0000000..6ab43ef --- /dev/null +++ b/model/email_field.go @@ -0,0 +1,31 @@ +package model + +import "strings" + +type EmailField []Email +type Email struct { + Value string `json:"value"` +} + +func (field EmailField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *EmailField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]Email, len(values)) + for i, value := range values { + s[i] = Email{ + Value: value, + } + } + return nil +} + +func (field *Email) String() string { + return field.Value +} diff --git a/model/entity_reference_field.go b/model/entity_reference_field.go new file mode 100644 index 0000000..a95ec2d --- /dev/null +++ b/model/entity_reference_field.go @@ -0,0 +1,50 @@ +package model + +import ( + "strconv" + "strings" +) + +type EntityReferenceField []EntityReference +type EntityReference struct { + TargetId int `json:"target_id"` + TargetType string `json:"target_type"` + TargetUuid string `json:"target_uuid"` + Url string `json:"url"` +} + +func (field EntityReferenceField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *EntityReferenceField) String() string { + values := make([]string, len(*field)) + for i, field := range *field { + values[i] = field.String() + } + + return strings.Join(values, "|") +} + +func (field *EntityReferenceField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]EntityReference, len(values)) + for i, value := range values { + id, err := strconv.Atoi(value) + if err != nil { + return err + } + s[i] = EntityReference{ + TargetId: id, + } + } + return nil +} + +func (field *EntityReference) String() string { + return strconv.Itoa(field.TargetId) +} diff --git a/model/generic_field.go b/model/generic_field.go new file mode 100644 index 0000000..d59af7e --- /dev/null +++ b/model/generic_field.go @@ -0,0 +1,44 @@ +package model + +import ( + "strings" +) + +type GenericField []Generic +type Generic struct { + Format string `json:"format,omitempty"` + Processed string `json:"processed,omitempty"` + Value string `json:"value"` +} + +func (field GenericField) MarshalCSV() string { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|") +} + +func (field *GenericField) String() string { + values := make([]string, len(*field)) + for i, field := range *field { + values[i] = field.String() + } + + return strings.Join(values, "|") +} + +func (field *GenericField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]Generic, len(values)) + for i, value := range values { + s[i] = Generic{ + Value: value, + } + } + return nil +} + +func (field *Generic) String() string { + return field.Value +} diff --git a/model/geolocation_field.go b/model/geolocation_field.go new file mode 100644 index 0000000..631b55c --- /dev/null +++ b/model/geolocation_field.go @@ -0,0 +1,56 @@ +package model + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +type GeoLocationField []GeoLocation +type GeoLocation struct { + Latitude float32 `json:"lat"` + Longitude float32 `json:"lng"` + LatitudeSin float32 `json:"lat_sin"` + LatitudeCos float32 `json:"lat_cos"` + LongitudeRad float32 `json:"lng_rad"` + Data string `json:"data"` +} + +func (field GeoLocationField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *GeoLocationField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]GeoLocation, len(values)) + for i, value := range values { + parts := strings.Split(value, ", ") + if len(parts) != 2 { + return errors.New("invalid CSV format for GeoLocationField") + } + + lat, err := strconv.ParseFloat(parts[0], 32) + if err != nil { + return fmt.Errorf("invalid latitude value: %v", err) + } + + lng, err := strconv.ParseFloat(parts[1], 32) + if err != nil { + return fmt.Errorf("invalid longitude value: %v", err) + } + s[i] = GeoLocation{ + Latitude: float32(lat), + Longitude: float32(lng), + } + } + return nil +} + +func (field *GeoLocation) String() string { + return fmt.Sprintf("%g, %g", field.Latitude, field.Longitude) +} diff --git a/model/hierarchical_geographic_field.go b/model/hierarchical_geographic_field.go new file mode 100644 index 0000000..5773fe3 --- /dev/null +++ b/model/hierarchical_geographic_field.go @@ -0,0 +1,49 @@ +package model + +import ( + "encoding/json" + "log/slog" + "strings" +) + +type HierarchicalGeographicField []HierarchicalGeographic +type HierarchicalGeographic struct { + City string `json:"city,omitempty"` + Continent string `json:"continent,omitempty"` + Country string `json:"country,omitempty"` + County string `json:"county,omitempty"` + State string `json:"state,omitempty"` + Territory string `json:"territory,omitempty"` +} + +func (field *HierarchicalGeographic) String() string { + data, err := json.Marshal(field) + if err != nil { + slog.Error("Unable to marshal hierarchical geo string", "err", err) + return "" + } + + return string(data) +} + +func (field HierarchicalGeographicField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *HierarchicalGeographicField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]HierarchicalGeographic, len(values)) + for i, value := range values { + var f HierarchicalGeographic + err := json.Unmarshal([]byte(value), &f) + if err != nil { + return err + } + s[i] = f + } + return nil +} diff --git a/model/int_field.go b/model/int_field.go new file mode 100644 index 0000000..cd1fd32 --- /dev/null +++ b/model/int_field.go @@ -0,0 +1,46 @@ +package model + +import ( + "strconv" + "strings" +) + +type IntField []Int +type Int struct { + Value int `json:"value"` +} + +func (field IntField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field IntField) String() string { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|") +} + +func (field *IntField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]Int, len(values)) + for i, value := range values { + id, err := strconv.Atoi(value) + if err != nil { + return err + } + s[i] = Int{ + Value: id, + } + } + return nil +} + +func (field *Int) String() string { + return strconv.Itoa(field.Value) +} diff --git a/model/part_detail.go b/model/part_detail.go new file mode 100644 index 0000000..a66d188 --- /dev/null +++ b/model/part_detail.go @@ -0,0 +1,47 @@ +package model + +import ( + "encoding/json" + "log/slog" + "strings" +) + +type PartDetailField []PartDetail +type PartDetail struct { + Type string `json:"type,omitempty"` + Caption string `json:"caption,omitempty"` + Number string `json:"number,omitempty"` + Title string `json:"title,omitempty"` +} + +func (field *PartDetail) String() string { + data, err := json.Marshal(field) + if err != nil { + slog.Error("Unable to marshal PartDetail string", "err", err) + return "" + } + + return string(data) +} + +func (field PartDetailField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *PartDetailField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]PartDetail, len(values)) + for i, value := range values { + var f PartDetail + err := json.Unmarshal([]byte(value), &f) + if err != nil { + return err + } + s[i] = f + } + return nil +} diff --git a/model/related_item_field.go b/model/related_item_field.go new file mode 100644 index 0000000..01a8fe8 --- /dev/null +++ b/model/related_item_field.go @@ -0,0 +1,46 @@ +package model + +import ( + "encoding/json" + "log/slog" + "strings" +) + +type RelatedItemField []RelatedItem +type RelatedItem struct { + Identifier string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Number string `json:"number,omitempty"` +} + +func (field *RelatedItem) String() string { + data, err := json.Marshal(field) + if err != nil { + slog.Error("Unable to marshal PartDetail string", "err", err) + return "" + } + + return string(data) +} + +func (field RelatedItemField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *RelatedItemField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]RelatedItem, len(values)) + for i, value := range values { + var f RelatedItem + err := json.Unmarshal([]byte(value), &f) + if err != nil { + return err + } + s[i] = f + } + return nil +} diff --git a/model/term.go b/model/term.go new file mode 100644 index 0000000..9b0a1a6 --- /dev/null +++ b/model/term.go @@ -0,0 +1,8 @@ +package model + +type TermResponse struct { + ID IntField `json:"tid"` + Name GenericField `json:"name"` + Relationships TypedRelationField `json:"field_relationships"` + Identifier TypedTextField `json:"field_identifier"` +} diff --git a/model/type_relation_field.go b/model/type_relation_field.go new file mode 100644 index 0000000..9636e3b --- /dev/null +++ b/model/type_relation_field.go @@ -0,0 +1,47 @@ +package model + +import ( + "encoding/json" + "log/slog" + "strings" +) + +type TypedRelationField []TypedRelation +type TypedRelation struct { + TargetId int `json:"target_id"` + RelType string `json:"rel_type"` + Url string `json:"url"` +} + +func (field *TypedRelation) String() string { + // TODO: rel:bundle:name + data, err := json.Marshal(field) + if err != nil { + slog.Error("Unable to marshal PartDetail string", "err", err) + return "" + } + + return string(data) +} + +func (field TypedRelationField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *TypedRelationField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]TypedRelation, len(values)) + for i, value := range values { + var f TypedRelation + err := json.Unmarshal([]byte(value), &f) + if err != nil { + return err + } + s[i] = f + } + return nil +} diff --git a/model/typed_text_field.go b/model/typed_text_field.go new file mode 100644 index 0000000..31e15ca --- /dev/null +++ b/model/typed_text_field.go @@ -0,0 +1,56 @@ +package model + +import ( + "encoding/json" + "log/slog" + "strings" +) + +type TypedTextField []TypedText +type TypedText struct { + Attr0 string `json:"attr0,omitempty"` + Attr1 string `json:"attr1,omitempty"` + Format string `json:"format,omitempty"` + Value string `json:"value"` +} + +func (field *TypedText) String() string { + data, err := json.Marshal(field) + if err != nil { + slog.Error("Unable to marshal PartDetail string", "err", err) + return "" + } + + return string(data) +} + +func (field *TypedTextField) String() string { + values := make([]string, len(*field)) + for i, field := range *field { + values[i] = field.String() + } + + return strings.Join(values, "|") +} + +func (field TypedTextField) MarshalCSV() (string, error) { + values := make([]string, len(field)) + for i, field := range field { + values[i] = field.String() + } + return strings.Join(values, "|"), nil +} + +func (field *TypedTextField) UnmarshalCSV(csv string) error { + values := strings.Split(csv, "|") + s := make([]TypedText, len(values)) + for i, value := range values { + var f TypedText + err := json.Unmarshal([]byte(value), &f) + if err != nil { + return err + } + s[i] = f + } + return nil +} diff --git a/scripts/generate-bundles-from-repo.sh b/scripts/generate-bundles-from-repo.sh new file mode 100755 index 0000000..f14bb09 --- /dev/null +++ b/scripts/generate-bundles-from-repo.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Generate bundle definitions from a Drupal distribution's config sync +# +# Usage: ./scripts/generate-bundles-from-repo.sh [branch] +# +# Example for Islandora: +# ./scripts/generate-bundles-from-repo.sh \ +# https://github.com/Islandora-Devops/islandora-starter-site.git \ +# ./bundles \ +# main +# +# This script: +# 1. Clones the repo (shallow clone) +# 2. Runs the generate-bundles script to parse Drupal config +# 3. Outputs bundle definitions to the specified directory + +set -e + +if [ $# -lt 2 ]; then + echo "Usage: $0 [branch]" + echo "" + echo "Example:" + echo " $0 https://github.com/Islandora-Devops/islandora-starter-site.git ./bundles main" + exit 1 +fi + +REPO="$1" +OUTPUT="$2" +BRANCH="${3:-main}" + +TMPDIR=$(mktemp -d) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +echo "Cloning $REPO (branch: $BRANCH)..." +git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMPDIR/repo" 2>/dev/null + +# Find config/sync directory +CONFIG_SYNC="$TMPDIR/repo/config/sync" +if [ ! -d "$CONFIG_SYNC" ]; then + echo "Error: config/sync directory not found in repository" + exit 1 +fi + +echo "Generating bundle definitions..." +cd "$PROJECT_ROOT" +go run ./scripts/generate-bundles \ + --config-sync "$CONFIG_SYNC" \ + --output "$OUTPUT" + +echo "" +echo "Done! Bundle definitions written to $OUTPUT" diff --git a/scripts/generate-bundles/main.go b/scripts/generate-bundles/main.go new file mode 100644 index 0000000..7eac105 --- /dev/null +++ b/scripts/generate-bundles/main.go @@ -0,0 +1,348 @@ +// Script to generate bundle definitions from a Drupal config sync export. +// Usage: go run ./scripts/generate-bundles --config-sync /path/to/config/sync --output ./bundles +// +// This parses node.type.*.yml, field.field.node.*.yml, and field.storage.node.*.yml +// files and generates bundle definition YAML files compatible with sitectl-drupal. +// +// Plugins (like sitectl-isle) can use this script to generate bundle configs +// from their distribution's config sync, then embed the output in their binary. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Drupal config structures + +// NodeType represents a Drupal node.type.*.yml file +type NodeType struct { + UUID string `yaml:"uuid"` + LangCode string `yaml:"langcode"` + Status bool `yaml:"status"` + Name string `yaml:"name"` + Type string `yaml:"type"` + Description string `yaml:"description"` +} + +// FieldStorage represents a Drupal field.storage.node.*.yml file +type FieldStorage struct { + UUID string `yaml:"uuid"` + FieldName string `yaml:"field_name"` + EntityType string `yaml:"entity_type"` + Type string `yaml:"type"` + Cardinality int `yaml:"cardinality"` // -1 = unlimited + Settings map[string]any `yaml:"settings"` +} + +// FieldConfig represents a Drupal field.field.node.*.yml file +type FieldConfig struct { + UUID string `yaml:"uuid"` + FieldName string `yaml:"field_name"` + EntityType string `yaml:"entity_type"` + Bundle string `yaml:"bundle"` + Label string `yaml:"label"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + FieldType string `yaml:"field_type"` + Settings map[string]any `yaml:"settings"` +} + +// Output structures (our format) + +type BundleConfig struct { + Version string `yaml:"version"` + Bundles []BundleDefinition `yaml:"bundles"` +} + +type BundleDefinition struct { + Name string `yaml:"name"` + MachineName string `yaml:"machine_name"` + Description string `yaml:"description,omitempty"` + Fields []FieldDefinition `yaml:"fields"` +} + +type FieldDefinition struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Label string `yaml:"label,omitempty"` + Required bool `yaml:"required,omitempty"` + Cardinality int `yaml:"cardinality,omitempty"` + Description string `yaml:"description,omitempty"` +} + +// Map Drupal field types to our simplified types +var fieldTypeMap = map[string]string{ + // Text fields + "string": "generic", + "string_long": "generic", + "text": "generic", + "text_long": "typed_text", + "text_with_summary": "typed_text", + + // Numeric + "integer": "int", + "decimal": "generic", + "float": "generic", + "boolean": "bool", + + // Reference fields + "entity_reference": "entity_reference", + "file": "entity_reference", + "image": "entity_reference", + + // Typed relation (Islandora-specific) + "typed_relation": "typed_relation", + + // Link + "link": "generic", + + // Date/time + "datetime": "generic", + "timestamp": "generic", + "daterange": "generic", + + // EDTF (Controlled Access Terms) + "edtf": "edtf", + + // Email + "email": "email", + + // Geolocation + "geolocation": "geolocation", + "geofield": "geolocation", + + // Paragraph/complex + "entity_reference_revisions": "entity_reference", + + // Fallback + "default": "generic", +} + +func mapFieldType(drupalType string) string { + if mapped, ok := fieldTypeMap[drupalType]; ok { + return mapped + } + return "generic" +} + +func main() { + configSync := flag.String("config-sync", "", "Path to Drupal config/sync directory (required)") + output := flag.String("output", "./api/defaults", "Output directory for bundle definitions") + flag.Parse() + + if *configSync == "" { + fmt.Println("Usage: go run ./scripts/generate-bundles --config-sync /path/to/config/sync") + fmt.Println() + fmt.Println("Example with islandora-starter-site:") + fmt.Println(" git clone --depth 1 https://github.com/Islandora-Devops/islandora-starter-site.git /tmp/starter") + fmt.Println(" go run ./scripts/generate-bundles --config-sync /tmp/starter/config/sync") + os.Exit(1) + } + + // Verify config sync directory exists + if _, err := os.Stat(*configSync); os.IsNotExist(err) { + log.Fatalf("Config sync directory does not exist: %s", *configSync) + } + + // Parse all node types + nodeTypes, err := parseNodeTypes(*configSync) + if err != nil { + log.Fatalf("Failed to parse node types: %v", err) + } + fmt.Printf("Found %d node types\n", len(nodeTypes)) + + // Parse all field storage configs + fieldStorage, err := parseFieldStorage(*configSync) + if err != nil { + log.Fatalf("Failed to parse field storage: %v", err) + } + fmt.Printf("Found %d field storage definitions\n", len(fieldStorage)) + + // Parse all field configs + fieldConfigs, err := parseFieldConfigs(*configSync) + if err != nil { + log.Fatalf("Failed to parse field configs: %v", err) + } + fmt.Printf("Found %d field configurations\n", len(fieldConfigs)) + + // Group field configs by bundle + fieldsByBundle := make(map[string][]FieldConfig) + for _, fc := range fieldConfigs { + fieldsByBundle[fc.Bundle] = append(fieldsByBundle[fc.Bundle], fc) + } + + // Build bundle definitions + var bundles []BundleDefinition + for _, nt := range nodeTypes { + bundle := BundleDefinition{ + Name: nt.Name, + MachineName: nt.Type, + Description: nt.Description, + } + + // Add fields for this bundle + fields := fieldsByBundle[nt.Type] + sort.Slice(fields, func(i, j int) bool { + return fields[i].FieldName < fields[j].FieldName + }) + + for _, fc := range fields { + // Get cardinality from storage + cardinality := 1 + if storage, ok := fieldStorage[fc.FieldName]; ok { + cardinality = storage.Cardinality + } + + fieldDef := FieldDefinition{ + Name: fc.FieldName, + Type: mapFieldType(fc.FieldType), + Label: fc.Label, + Required: fc.Required, + Description: fc.Description, + } + + // Only include cardinality if not single-value + if cardinality != 1 { + fieldDef.Cardinality = cardinality + } + + bundle.Fields = append(bundle.Fields, fieldDef) + } + + bundles = append(bundles, bundle) + } + + // Sort bundles by machine name + sort.Slice(bundles, func(i, j int) bool { + return bundles[i].MachineName < bundles[j].MachineName + }) + + // Create output directory + if err := os.MkdirAll(*output, 0755); err != nil { + log.Fatalf("Failed to create output directory: %v", err) + } + + // Write single combined file + config := BundleConfig{ + Version: "1.0", + Bundles: bundles, + } + + outputPath := filepath.Join(*output, "islandora_starter_site.yaml") + data, err := yaml.Marshal(config) + if err != nil { + log.Fatalf("Failed to marshal YAML: %v", err) + } + + // Add header comment + header := `# Auto-generated from Islandora Starter Site config/sync +# Source: https://github.com/Islandora-Devops/islandora-starter-site +# Regenerate with: go run ./scripts/generate-bundles --config-sync /path/to/config/sync +# +` + data = append([]byte(header), data...) + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + log.Fatalf("Failed to write output file: %v", err) + } + + fmt.Printf("Generated %s with %d bundles\n", outputPath, len(bundles)) + + // Print summary + fmt.Println("\nBundles:") + for _, b := range bundles { + fmt.Printf(" - %s (%s): %d fields\n", b.Name, b.MachineName, len(b.Fields)) + } +} + +func parseNodeTypes(configSync string) ([]NodeType, error) { + pattern := filepath.Join(configSync, "node.type.*.yml") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + var nodeTypes []NodeType + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", f, err) + } + + var nt NodeType + if err := yaml.Unmarshal(data, &nt); err != nil { + return nil, fmt.Errorf("parsing %s: %w", f, err) + } + + nodeTypes = append(nodeTypes, nt) + } + + return nodeTypes, nil +} + +func parseFieldStorage(configSync string) (map[string]FieldStorage, error) { + pattern := filepath.Join(configSync, "field.storage.node.*.yml") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + storage := make(map[string]FieldStorage) + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", f, err) + } + + var fs FieldStorage + if err := yaml.Unmarshal(data, &fs); err != nil { + return nil, fmt.Errorf("parsing %s: %w", f, err) + } + + storage[fs.FieldName] = fs + } + + return storage, nil +} + +func parseFieldConfigs(configSync string) ([]FieldConfig, error) { + pattern := filepath.Join(configSync, "field.field.node.*.yml") + files, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + var configs []FieldConfig + for _, f := range files { + // Extract bundle and field name from filename + // Format: field.field.node.BUNDLE.FIELD_NAME.yml + base := filepath.Base(f) + base = strings.TrimSuffix(base, ".yml") + parts := strings.Split(base, ".") + if len(parts) < 5 { + continue + } + + data, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", f, err) + } + + var fc FieldConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + return nil, fmt.Errorf("parsing %s: %w", f, err) + } + + configs = append(configs, fc) + } + + return configs, nil +} From bc2602874ab0c3fb3d009ec5f3b2d5e9088f1835 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Fri, 30 Jan 2026 08:17:00 -0500 Subject: [PATCH 2/2] Add other entities --- cmd/node.go | 42 ++-- drupal/cache.go | 12 +- drupal/client.go | 63 ++++- drupal/media.go | 167 +++++++++++++ drupal/node.go | 11 +- drupal/registry.go | 213 +++++++++++----- drupal/term.go | 175 ++++++++++++++ drupal/user.go | 169 +++++++++++++ scripts/generate-bundles/main.go | 401 +++++++++++++++++++------------ 9 files changed, 1005 insertions(+), 248 deletions(-) create mode 100644 drupal/media.go create mode 100644 drupal/term.go create mode 100644 drupal/user.go diff --git a/cmd/node.go b/cmd/node.go index b875cdf..729daca 100644 --- a/cmd/node.go +++ b/cmd/node.go @@ -118,7 +118,7 @@ Requires --bundle-config to be set with bundle definitions.`, return fmt.Errorf("no bundle definitions loaded - use --bundle-config") } - def, ok := registry.GetBundle(node.Bundle()) + def, ok := registry.GetNodeBundle(node.Bundle()) if !ok { return fmt.Errorf("unknown bundle %q - not in registry", node.Bundle()) } @@ -153,31 +153,39 @@ Shows bundle names, descriptions, field counts, and required fields.`, RunE: func(cmd *cobra.Command, args []string) error { client := getClient(cmd) - bundles := client.Registry.ListBundles() - if len(bundles) == 0 { + entityTypes := client.Registry.ListAllEntityTypes() + if len(entityTypes) == 0 { fmt.Println("No bundles registered") fmt.Println("Use --bundle-config to load bundle definitions") return nil } fmt.Println("Registered bundles:") - for _, name := range bundles { - def, _ := client.Registry.GetBundle(name) - fmt.Printf("\n %s (%s)\n", def.Name, def.MachineName) - if def.Description != "" { - fmt.Printf(" %s\n", def.Description) + for _, entityType := range entityTypes { + bundles := client.Registry.ListBundles(entityType) + if len(bundles) == 0 { + continue } - fmt.Printf(" Fields: %d\n", len(def.Fields)) - // List required fields - var required []string - for _, f := range def.Fields { - if f.Required { - required = append(required, f.Name) + fmt.Printf("\n%s:\n", entityType) + for _, name := range bundles { + def, _ := client.Registry.GetBundle(entityType, name) + fmt.Printf("\n %s (%s)\n", def.Name, def.MachineName) + if def.Description != "" { + fmt.Printf(" %s\n", def.Description) + } + fmt.Printf(" Fields: %d\n", len(def.Fields)) + + // List required fields + var required []string + for _, f := range def.Fields { + if f.Required { + required = append(required, f.Name) + } + } + if len(required) > 0 { + fmt.Printf(" Required: %v\n", required) } - } - if len(required) > 0 { - fmt.Printf(" Required: %v\n", required) } } diff --git a/drupal/cache.go b/drupal/cache.go index b219776..061a6fb 100644 --- a/drupal/cache.go +++ b/drupal/cache.go @@ -150,10 +150,14 @@ func SaveRegistryCache(configPath string, registry *BundleRegistry) error { return err } - // Collect all bundles - bundles := make([]BundleDefinition, 0, len(registry.bundles)) - for _, b := range registry.bundles { - bundles = append(bundles, *b) + // Collect all bundles from all entity types + var bundles []BundleDefinition + for _, et := range registry.ListAllEntityTypes() { + for _, name := range registry.ListBundles(et) { + if def, ok := registry.GetBundle(et, name); ok { + bundles = append(bundles, *def) + } + } } cached := CachedBundleConfig{ diff --git a/drupal/client.go b/drupal/client.go index 33ef25c..33cbb3a 100644 --- a/drupal/client.go +++ b/drupal/client.go @@ -123,28 +123,66 @@ func NewClient(opts ...ClientOption) *Client { // FetchNode fetches a single node from the Drupal API and attaches the registry. func (c *Client) FetchNode(url string) (*Node, error) { + var node Node + if err := c.fetch(url, &node); err != nil { + return nil, err + } + node.SetRegistry(c.Registry) + return &node, nil +} + +// FetchTerm fetches a single taxonomy term from the Drupal API and attaches the registry. +func (c *Client) FetchTerm(url string) (*Term, error) { + var term Term + if err := c.fetch(url, &term); err != nil { + return nil, err + } + term.SetRegistry(c.Registry) + return &term, nil +} + +// FetchMedia fetches a single media entity from the Drupal API and attaches the registry. +func (c *Client) FetchMedia(url string) (*Media, error) { + var media Media + if err := c.fetch(url, &media); err != nil { + return nil, err + } + media.SetRegistry(c.Registry) + return &media, nil +} + +// FetchUser fetches a single user from the Drupal API and attaches the registry. +func (c *Client) FetchUser(url string) (*User, error) { + var user User + if err := c.fetch(url, &user); err != nil { + return nil, err + } + user.SetRegistry(c.Registry) + return &user, nil +} + +// fetch is a helper that performs an HTTP GET and decodes JSON +func (c *Client) fetch(url string, v any) error { req, err := c.newRequest("GET", url) if err != nil { - return nil, err + return err } resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, fmt.Errorf("fetch failed: %w", err) + return fmt.Errorf("fetch failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status: %s", resp.Status) + return fmt.Errorf("unexpected status: %s", resp.Status) } - var node Node - if err := json.NewDecoder(resp.Body).Decode(&node); err != nil { - return nil, fmt.Errorf("decode failed: %w", err) + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + return fmt.Errorf("decode failed: %w", err) } - node.SetRegistry(c.Registry) - return &node, nil + return nil } // newRequest creates an HTTP request with authentication if configured @@ -163,10 +201,13 @@ func (c *Client) newRequest(method, url string) (*http.Request, error) { // ValidateConfig checks if bundle configuration loaded successfully func (c *Client) ValidateConfig() error { - if len(c.Registry.bundles) == 0 { - return ErrNoBundles + // Check if any entity type has bundles registered + for _, et := range c.Registry.ListAllEntityTypes() { + if len(c.Registry.ListBundles(et)) > 0 { + return nil + } } - return nil + return ErrNoBundles } // Errors diff --git a/drupal/media.go b/drupal/media.go new file mode 100644 index 0000000..a7882c4 --- /dev/null +++ b/drupal/media.go @@ -0,0 +1,167 @@ +package drupal + +import ( + "encoding/json" + "fmt" + + "github.com/libops/sitectl-drupal/model" +) + +// Media represents a Drupal media entity with static core fields and dynamic media type fields. +type Media struct { + // Core fields - present on all media + Mid model.IntField `json:"mid"` + UUID model.GenericField `json:"uuid"` + Bundle model.ConfigReferenceField `json:"bundle"` // media type reference + Name model.GenericField `json:"name"` + Status model.BoolField `json:"status"` + Created model.GenericField `json:"created"` + Changed model.GenericField `json:"changed"` + Langcode model.GenericField `json:"langcode"` + UID model.EntityReferenceField `json:"uid"` // author + + // Media type-specific fields stored as raw JSON for lazy decoding + Fields map[string]json.RawMessage `json:"-"` + + // Registry reference for field type lookups and validation + registry *BundleRegistry `json:"-"` +} + +// SetRegistry attaches a bundle registry to this media entity +func (m *Media) SetRegistry(r *BundleRegistry) { + m.registry = r +} + +// Registry returns the attached bundle registry, or nil if none +func (m *Media) Registry() *BundleRegistry { + return m.registry +} + +// Validate checks if this media has all required fields for its media type. +func (m *Media) Validate() []string { + if m.registry == nil { + return nil + } + return m.registry.ValidateEntity(EntityTypeMedia, m.MediaType(), m.HasField) +} + +// GetFieldType returns the type of a field on this media's type. +func (m *Media) GetFieldType(fieldName string) (FieldType, bool) { + if m.registry == nil { + return "", false + } + return m.registry.GetFieldType(EntityTypeMedia, m.MediaType(), fieldName) +} + +// GetFieldDefinition returns the full field definition for a field. +func (m *Media) GetFieldDefinition(fieldName string) (*FieldDefinition, bool) { + if m.registry == nil { + return nil, false + } + return m.registry.GetField(EntityTypeMedia, m.MediaType(), fieldName) +} + +// EntityType returns the entity type for media +func (m *Media) EntityType() EntityType { + return EntityTypeMedia +} + +// MediaType returns the media type (bundle) machine name +func (m *Media) MediaType() string { + if len(m.Bundle) > 0 { + return m.Bundle[0].TargetId + } + return "" +} + +// GetField returns a field value by name as the specified type. +func GetMediaField[T any](m *Media, fieldName string) (T, error) { + var zero T + raw, ok := m.Fields[fieldName] + if !ok { + return zero, fmt.Errorf("field %q not found on media", fieldName) + } + + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return zero, fmt.Errorf("failed to decode field %q: %w", fieldName, err) + } + return result, nil +} + +// GetGenericField returns a GenericField by name +func (m *Media) GetGenericField(fieldName string) (model.GenericField, error) { + return GetMediaField[model.GenericField](m, fieldName) +} + +// GetEntityReferenceField returns an EntityReferenceField by name +func (m *Media) GetEntityReferenceField(fieldName string) (model.EntityReferenceField, error) { + return GetMediaField[model.EntityReferenceField](m, fieldName) +} + +// HasField checks if a field exists on the media +func (m *Media) HasField(fieldName string) bool { + _, ok := m.Fields[fieldName] + return ok +} + +// FieldNames returns all field names present on this media +func (m *Media) FieldNames() []string { + names := make([]string, 0, len(m.Fields)) + for name := range m.Fields { + names = append(names, name) + } + return names +} + +// UnmarshalJSON implements custom JSON unmarshaling to capture all fields +func (m *Media) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + coreFields := map[string]any{ + "mid": &m.Mid, + "uuid": &m.UUID, + "bundle": &m.Bundle, + "name": &m.Name, + "status": &m.Status, + "created": &m.Created, + "changed": &m.Changed, + "langcode": &m.Langcode, + "uid": &m.UID, + } + + for name, ptr := range coreFields { + if rawVal, ok := raw[name]; ok { + if err := json.Unmarshal(rawVal, ptr); err != nil { + return fmt.Errorf("failed to unmarshal core field %q: %w", name, err) + } + delete(raw, name) + } + } + + m.Fields = raw + return nil +} + +// MarshalJSON implements custom JSON marshaling +func (m *Media) MarshalJSON() ([]byte, error) { + result := make(map[string]any, len(m.Fields)+9) + for k, v := range m.Fields { + result[k] = v + } + + result["mid"] = m.Mid + result["uuid"] = m.UUID + result["bundle"] = m.Bundle + result["name"] = m.Name + result["status"] = m.Status + result["created"] = m.Created + result["changed"] = m.Changed + result["langcode"] = m.Langcode + result["uid"] = m.UID + + return json.Marshal(result) +} diff --git a/drupal/node.go b/drupal/node.go index d18b1e7..6c67f8e 100644 --- a/drupal/node.go +++ b/drupal/node.go @@ -44,7 +44,7 @@ func (n *Node) Validate() []string { if n.registry == nil { return nil } - return n.registry.ValidateNode(n) + return n.registry.ValidateEntity(EntityTypeNode, n.Bundle(), n.HasField) } // GetFieldType returns the type of a field on this node's bundle. @@ -53,7 +53,7 @@ func (n *Node) GetFieldType(fieldName string) (FieldType, bool) { if n.registry == nil { return "", false } - return n.registry.GetFieldType(n.Bundle(), fieldName) + return n.registry.GetFieldType(EntityTypeNode, n.Bundle(), fieldName) } // GetFieldDefinition returns the full field definition for a field on this node's bundle. @@ -62,7 +62,12 @@ func (n *Node) GetFieldDefinition(fieldName string) (*FieldDefinition, bool) { if n.registry == nil { return nil, false } - return n.registry.GetField(n.Bundle(), fieldName) + return n.registry.GetField(EntityTypeNode, n.Bundle(), fieldName) +} + +// EntityType returns the entity type for nodes +func (n *Node) EntityType() EntityType { + return EntityTypeNode } // Bundle returns the bundle (content type) machine name diff --git a/drupal/registry.go b/drupal/registry.go index ad1b936..e25c77c 100644 --- a/drupal/registry.go +++ b/drupal/registry.go @@ -14,6 +14,16 @@ import ( "gopkg.in/yaml.v3" ) +// EntityType represents a Drupal entity type +type EntityType string + +const ( + EntityTypeNode EntityType = "node" + EntityTypeTaxonomyTerm EntityType = "taxonomy_term" + EntityTypeMedia EntityType = "media" + EntityTypeUser EntityType = "user" +) + // FieldType represents the type of a Drupal field type FieldType string @@ -31,6 +41,10 @@ const ( FieldTypePartDetail FieldType = "part_detail" FieldTypeHierarchical FieldType = "hierarchical_geographic" FieldTypeRelatedItem FieldType = "related_item" + FieldTypeFile FieldType = "file" + FieldTypeImage FieldType = "image" + FieldTypeLink FieldType = "link" + FieldTypePassword FieldType = "password" ) // FieldDefinition describes a field on a bundle @@ -43,47 +57,49 @@ type FieldDefinition struct { Description string `yaml:"description,omitempty"` } -// BundleDefinition describes a Drupal content type (bundle) +// BundleDefinition describes a Drupal bundle (content type, vocabulary, media type, etc.) type BundleDefinition struct { + EntityType EntityType `yaml:"entity_type"` Name string `yaml:"name"` MachineName string `yaml:"machine_name"` Description string `yaml:"description,omitempty"` Fields []FieldDefinition `yaml:"fields"` } -// BundleConfig is the top-level config file format -type BundleConfig struct { - Version string `yaml:"version"` - Bundles []BundleDefinition `yaml:"bundles"` +// EntityConfig is the top-level config file format +type EntityConfig struct { + Version string `yaml:"version"` + Entities []BundleDefinition `yaml:"entities"` + // Legacy support: "bundles" is an alias for "entities" with entity_type=node + Bundles []BundleDefinition `yaml:"bundles,omitempty"` } // BundleRegistry manages bundle definitions from multiple sources. // Plugins can register their own bundles using the various Load* methods. type BundleRegistry struct { - bundles map[string]*BundleDefinition // keyed by machine_name - fields map[string]map[string]*FieldDefinition // bundle -> field_name -> definition + // bundles[entity_type][machine_name] = definition + bundles map[EntityType]map[string]*BundleDefinition + // fields[entity_type][bundle_name][field_name] = definition + fields map[EntityType]map[string]map[string]*FieldDefinition } // NewBundleRegistry creates an empty registry. // Use the Load* methods to populate it with bundle definitions. func NewBundleRegistry() *BundleRegistry { - return &BundleRegistry{ - bundles: make(map[string]*BundleDefinition), - fields: make(map[string]map[string]*FieldDefinition), + r := &BundleRegistry{ + bundles: make(map[EntityType]map[string]*BundleDefinition), + fields: make(map[EntityType]map[string]map[string]*FieldDefinition), } + // Initialize maps for known entity types + for _, et := range []EntityType{EntityTypeNode, EntityTypeTaxonomyTerm, EntityTypeMedia, EntityTypeUser} { + r.bundles[et] = make(map[string]*BundleDefinition) + r.fields[et] = make(map[string]map[string]*FieldDefinition) + } + return r } // LoadEmbedded loads bundle definitions from an embedded filesystem. // This is the primary mechanism for plugins to ship their own bundle configs. -// -// Example usage in a plugin: -// -// //go:embed bundles/*.yaml -// var bundleFS embed.FS -// -// func init() { -// registry.LoadEmbedded(bundleFS, "bundles") -// } func (r *BundleRegistry) LoadEmbedded(fsys embed.FS, dir string) error { return fs.WalkDir(fsys, dir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -103,7 +119,6 @@ func (r *BundleRegistry) LoadEmbedded(fsys embed.FS, dir string) error { } // LoadFromPath loads bundle definitions from a file or directory path. -// This allows users to supply custom configs at runtime. func (r *BundleRegistry) LoadFromPath(path string) error { info, err := os.Stat(path) if err != nil { @@ -126,7 +141,6 @@ func (r *BundleRegistry) LoadFromPath(path string) error { } // LoadFromBytes loads bundle definitions from raw YAML bytes. -// Useful for testing or programmatic bundle registration. func (r *BundleRegistry) LoadFromBytes(data []byte) error { return r.loadYAML(data, "") } @@ -140,13 +154,23 @@ func (r *BundleRegistry) loadFile(path string) error { } func (r *BundleRegistry) loadYAML(data []byte, source string) error { - var config BundleConfig + var config EntityConfig if err := yaml.Unmarshal(data, &config); err != nil { return fmt.Errorf("parsing %s: %w", source, err) } + // Load entities + for i := range config.Entities { + bundle := &config.Entities[i] + r.RegisterBundle(bundle) + } + + // Legacy support: "bundles" without entity_type defaults to node for i := range config.Bundles { bundle := &config.Bundles[i] + if bundle.EntityType == "" { + bundle.EntityType = EntityTypeNode + } r.RegisterBundle(bundle) } @@ -154,27 +178,64 @@ func (r *BundleRegistry) loadYAML(data []byte, source string) error { } // RegisterBundle adds or updates a bundle definition in the registry. -// If a bundle with the same machine name exists, it will be replaced. -// This allows plugins to override base bundle definitions. func (r *BundleRegistry) RegisterBundle(bundle *BundleDefinition) { - r.bundles[bundle.MachineName] = bundle - r.fields[bundle.MachineName] = make(map[string]*FieldDefinition) + et := bundle.EntityType + if et == "" { + et = EntityTypeNode // default + } + + // Ensure maps exist for this entity type + if r.bundles[et] == nil { + r.bundles[et] = make(map[string]*BundleDefinition) + } + if r.fields[et] == nil { + r.fields[et] = make(map[string]map[string]*FieldDefinition) + } + + r.bundles[et][bundle.MachineName] = bundle + r.fields[et][bundle.MachineName] = make(map[string]*FieldDefinition) for i := range bundle.Fields { field := &bundle.Fields[i] - r.fields[bundle.MachineName][field.Name] = field + r.fields[et][bundle.MachineName][field.Name] = field } } -// GetBundle returns a bundle definition by machine name -func (r *BundleRegistry) GetBundle(machineName string) (*BundleDefinition, bool) { - b, ok := r.bundles[machineName] +// GetBundle returns a bundle definition by entity type and machine name +func (r *BundleRegistry) GetBundle(entityType EntityType, machineName string) (*BundleDefinition, bool) { + if r.bundles[entityType] == nil { + return nil, false + } + b, ok := r.bundles[entityType][machineName] return b, ok } -// GetField returns a field definition for a bundle -func (r *BundleRegistry) GetField(bundleName, fieldName string) (*FieldDefinition, bool) { - fields, ok := r.fields[bundleName] +// GetNodeBundle is a convenience method for GetBundle(EntityTypeNode, name) +func (r *BundleRegistry) GetNodeBundle(machineName string) (*BundleDefinition, bool) { + return r.GetBundle(EntityTypeNode, machineName) +} + +// GetTermBundle is a convenience method for GetBundle(EntityTypeTaxonomyTerm, name) +func (r *BundleRegistry) GetTermBundle(machineName string) (*BundleDefinition, bool) { + return r.GetBundle(EntityTypeTaxonomyTerm, machineName) +} + +// GetMediaBundle is a convenience method for GetBundle(EntityTypeMedia, name) +func (r *BundleRegistry) GetMediaBundle(machineName string) (*BundleDefinition, bool) { + return r.GetBundle(EntityTypeMedia, machineName) +} + +// GetUserBundle returns the user "bundle" (users don't have bundles, but have fields) +func (r *BundleRegistry) GetUserBundle() (*BundleDefinition, bool) { + return r.GetBundle(EntityTypeUser, "user") +} + +// GetField returns a field definition for an entity type and bundle +func (r *BundleRegistry) GetField(entityType EntityType, bundleName, fieldName string) (*FieldDefinition, bool) { + if r.fields[entityType] == nil { + return nil, false + } + fields, ok := r.fields[entityType][bundleName] if !ok { return nil, false } @@ -182,27 +243,48 @@ func (r *BundleRegistry) GetField(bundleName, fieldName string) (*FieldDefinitio return f, ok } -// GetFieldType returns the type of a field on a bundle -func (r *BundleRegistry) GetFieldType(bundleName, fieldName string) (FieldType, bool) { - f, ok := r.GetField(bundleName, fieldName) +// GetFieldType returns the type of a field +func (r *BundleRegistry) GetFieldType(entityType EntityType, bundleName, fieldName string) (FieldType, bool) { + f, ok := r.GetField(entityType, bundleName, fieldName) if !ok { return "", false } return f.Type, true } -// ListBundles returns all registered bundle machine names -func (r *BundleRegistry) ListBundles() []string { - names := make([]string, 0, len(r.bundles)) - for name := range r.bundles { +// ListBundles returns all registered bundle machine names for an entity type +func (r *BundleRegistry) ListBundles(entityType EntityType) []string { + if r.bundles[entityType] == nil { + return nil + } + names := make([]string, 0, len(r.bundles[entityType])) + for name := range r.bundles[entityType] { names = append(names, name) } return names } -// ListFields returns all field names for a bundle -func (r *BundleRegistry) ListFields(bundleName string) []string { - fields, ok := r.fields[bundleName] +// ListNodeBundles is a convenience method for ListBundles(EntityTypeNode) +func (r *BundleRegistry) ListNodeBundles() []string { + return r.ListBundles(EntityTypeNode) +} + +// ListTermBundles is a convenience method for ListBundles(EntityTypeTaxonomyTerm) +func (r *BundleRegistry) ListTermBundles() []string { + return r.ListBundles(EntityTypeTaxonomyTerm) +} + +// ListMediaBundles is a convenience method for ListBundles(EntityTypeMedia) +func (r *BundleRegistry) ListMediaBundles() []string { + return r.ListBundles(EntityTypeMedia) +} + +// ListFields returns all field names for an entity type and bundle +func (r *BundleRegistry) ListFields(entityType EntityType, bundleName string) []string { + if r.fields[entityType] == nil { + return nil + } + fields, ok := r.fields[entityType][bundleName] if !ok { return nil } @@ -213,32 +295,43 @@ func (r *BundleRegistry) ListFields(bundleName string) []string { return names } -// ValidateNode checks if a node has all required fields for its bundle -func (r *BundleRegistry) ValidateNode(n *Node) []string { +// ListAllEntityTypes returns all entity types that have registered bundles +func (r *BundleRegistry) ListAllEntityTypes() []EntityType { + var types []EntityType + for et, bundles := range r.bundles { + if len(bundles) > 0 { + types = append(types, et) + } + } + return types +} + +// Merge combines another registry into this one. +func (r *BundleRegistry) Merge(other *BundleRegistry) { + for et := range other.bundles { + for _, name := range other.ListBundles(et) { + if def, ok := other.GetBundle(et, name); ok { + r.RegisterBundle(def) + } + } + } +} + +// ValidateEntity checks if an entity has all required fields for its bundle. +// Works for any entity type that implements the EntityWithBundle interface. +func (r *BundleRegistry) ValidateEntity(entityType EntityType, bundleName string, hasField func(string) bool) []string { var errors []string - bundle := n.Bundle() - def, ok := r.GetBundle(bundle) + def, ok := r.GetBundle(entityType, bundleName) if !ok { - // Unknown bundle - can't validate - return nil + return nil // Unknown bundle - can't validate } for _, field := range def.Fields { - if field.Required && !n.HasField(field.Name) { + if field.Required && !hasField(field.Name) { errors = append(errors, fmt.Sprintf("missing required field %q", field.Name)) } } return errors } - -// Merge combines another registry into this one. -// Bundles from the other registry will override existing bundles with the same name. -func (r *BundleRegistry) Merge(other *BundleRegistry) { - for _, name := range other.ListBundles() { - if def, ok := other.GetBundle(name); ok { - r.RegisterBundle(def) - } - } -} diff --git a/drupal/term.go b/drupal/term.go new file mode 100644 index 0000000..a50b31e --- /dev/null +++ b/drupal/term.go @@ -0,0 +1,175 @@ +package drupal + +import ( + "encoding/json" + "fmt" + + "github.com/libops/sitectl-drupal/model" +) + +// Term represents a Drupal taxonomy term with static core fields and dynamic vocabulary fields. +type Term struct { + // Core fields - present on all terms + Tid model.IntField `json:"tid"` + UUID model.GenericField `json:"uuid"` + Vid model.ConfigReferenceField `json:"vid"` // vocabulary reference + Name model.GenericField `json:"name"` + Status model.BoolField `json:"status"` + Created model.GenericField `json:"created"` + Changed model.GenericField `json:"changed"` + Langcode model.GenericField `json:"langcode"` + Weight model.IntField `json:"weight"` + Parent model.EntityReferenceField `json:"parent"` // parent term reference + + // Vocabulary-specific fields stored as raw JSON for lazy decoding + Fields map[string]json.RawMessage `json:"-"` + + // Registry reference for field type lookups and validation + registry *BundleRegistry `json:"-"` +} + +// SetRegistry attaches a bundle registry to this term +func (t *Term) SetRegistry(r *BundleRegistry) { + t.registry = r +} + +// Registry returns the attached bundle registry, or nil if none +func (t *Term) Registry() *BundleRegistry { + return t.registry +} + +// Validate checks if this term has all required fields for its vocabulary. +func (t *Term) Validate() []string { + if t.registry == nil { + return nil + } + return t.registry.ValidateEntity(EntityTypeTaxonomyTerm, t.Vocabulary(), t.HasField) +} + +// GetFieldType returns the type of a field on this term's vocabulary. +func (t *Term) GetFieldType(fieldName string) (FieldType, bool) { + if t.registry == nil { + return "", false + } + return t.registry.GetFieldType(EntityTypeTaxonomyTerm, t.Vocabulary(), fieldName) +} + +// GetFieldDefinition returns the full field definition for a field. +func (t *Term) GetFieldDefinition(fieldName string) (*FieldDefinition, bool) { + if t.registry == nil { + return nil, false + } + return t.registry.GetField(EntityTypeTaxonomyTerm, t.Vocabulary(), fieldName) +} + +// EntityType returns the entity type for terms +func (t *Term) EntityType() EntityType { + return EntityTypeTaxonomyTerm +} + +// Vocabulary returns the vocabulary (bundle) machine name +func (t *Term) Vocabulary() string { + if len(t.Vid) > 0 { + return t.Vid[0].TargetId + } + return "" +} + +// Bundle is an alias for Vocabulary for interface consistency +func (t *Term) Bundle() string { + return t.Vocabulary() +} + +// GetField returns a field value by name as the specified type. +func GetTermField[T any](t *Term, fieldName string) (T, error) { + var zero T + raw, ok := t.Fields[fieldName] + if !ok { + return zero, fmt.Errorf("field %q not found on term", fieldName) + } + + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return zero, fmt.Errorf("failed to decode field %q: %w", fieldName, err) + } + return result, nil +} + +// GetGenericField returns a GenericField by name +func (t *Term) GetGenericField(fieldName string) (model.GenericField, error) { + return GetTermField[model.GenericField](t, fieldName) +} + +// GetEntityReferenceField returns an EntityReferenceField by name +func (t *Term) GetEntityReferenceField(fieldName string) (model.EntityReferenceField, error) { + return GetTermField[model.EntityReferenceField](t, fieldName) +} + +// HasField checks if a field exists on the term +func (t *Term) HasField(fieldName string) bool { + _, ok := t.Fields[fieldName] + return ok +} + +// FieldNames returns all field names present on this term +func (t *Term) FieldNames() []string { + names := make([]string, 0, len(t.Fields)) + for name := range t.Fields { + names = append(names, name) + } + return names +} + +// UnmarshalJSON implements custom JSON unmarshaling to capture all fields +func (t *Term) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + coreFields := map[string]any{ + "tid": &t.Tid, + "uuid": &t.UUID, + "vid": &t.Vid, + "name": &t.Name, + "status": &t.Status, + "created": &t.Created, + "changed": &t.Changed, + "langcode": &t.Langcode, + "weight": &t.Weight, + "parent": &t.Parent, + } + + for name, ptr := range coreFields { + if rawVal, ok := raw[name]; ok { + if err := json.Unmarshal(rawVal, ptr); err != nil { + return fmt.Errorf("failed to unmarshal core field %q: %w", name, err) + } + delete(raw, name) + } + } + + t.Fields = raw + return nil +} + +// MarshalJSON implements custom JSON marshaling +func (t *Term) MarshalJSON() ([]byte, error) { + result := make(map[string]any, len(t.Fields)+10) + for k, v := range t.Fields { + result[k] = v + } + + result["tid"] = t.Tid + result["uuid"] = t.UUID + result["vid"] = t.Vid + result["name"] = t.Name + result["status"] = t.Status + result["created"] = t.Created + result["changed"] = t.Changed + result["langcode"] = t.Langcode + result["weight"] = t.Weight + result["parent"] = t.Parent + + return json.Marshal(result) +} diff --git a/drupal/user.go b/drupal/user.go new file mode 100644 index 0000000..8dc749c --- /dev/null +++ b/drupal/user.go @@ -0,0 +1,169 @@ +package drupal + +import ( + "encoding/json" + "fmt" + + "github.com/libops/sitectl-drupal/model" +) + +// User represents a Drupal user entity. +// Users don't have bundles in Drupal, but they do have configurable fields. +type User struct { + // Core fields - present on all users + UID model.IntField `json:"uid"` + UUID model.GenericField `json:"uuid"` + Name model.GenericField `json:"name"` + Mail model.GenericField `json:"mail"` + Status model.BoolField `json:"status"` + Created model.GenericField `json:"created"` + Changed model.GenericField `json:"changed"` + Langcode model.GenericField `json:"langcode"` + Timezone model.GenericField `json:"timezone"` + Roles model.ConfigReferenceField `json:"roles"` + + // User fields stored as raw JSON for lazy decoding + Fields map[string]json.RawMessage `json:"-"` + + // Registry reference for field type lookups and validation + registry *BundleRegistry `json:"-"` +} + +// SetRegistry attaches a bundle registry to this user +func (u *User) SetRegistry(r *BundleRegistry) { + u.registry = r +} + +// Registry returns the attached bundle registry, or nil if none +func (u *User) Registry() *BundleRegistry { + return u.registry +} + +// Validate checks if this user has all required fields. +// Users use "user" as their bundle name for registry lookups. +func (u *User) Validate() []string { + if u.registry == nil { + return nil + } + return u.registry.ValidateEntity(EntityTypeUser, "user", u.HasField) +} + +// GetFieldType returns the type of a field on users. +func (u *User) GetFieldType(fieldName string) (FieldType, bool) { + if u.registry == nil { + return "", false + } + return u.registry.GetFieldType(EntityTypeUser, "user", fieldName) +} + +// GetFieldDefinition returns the full field definition for a field. +func (u *User) GetFieldDefinition(fieldName string) (*FieldDefinition, bool) { + if u.registry == nil { + return nil, false + } + return u.registry.GetField(EntityTypeUser, "user", fieldName) +} + +// EntityType returns the entity type for users +func (u *User) EntityType() EntityType { + return EntityTypeUser +} + +// Bundle returns "user" - users don't have bundles but we use this for consistency +func (u *User) Bundle() string { + return "user" +} + +// GetField returns a field value by name as the specified type. +func GetUserField[T any](u *User, fieldName string) (T, error) { + var zero T + raw, ok := u.Fields[fieldName] + if !ok { + return zero, fmt.Errorf("field %q not found on user", fieldName) + } + + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return zero, fmt.Errorf("failed to decode field %q: %w", fieldName, err) + } + return result, nil +} + +// GetGenericField returns a GenericField by name +func (u *User) GetGenericField(fieldName string) (model.GenericField, error) { + return GetUserField[model.GenericField](u, fieldName) +} + +// GetEntityReferenceField returns an EntityReferenceField by name +func (u *User) GetEntityReferenceField(fieldName string) (model.EntityReferenceField, error) { + return GetUserField[model.EntityReferenceField](u, fieldName) +} + +// HasField checks if a field exists on the user +func (u *User) HasField(fieldName string) bool { + _, ok := u.Fields[fieldName] + return ok +} + +// FieldNames returns all field names present on this user +func (u *User) FieldNames() []string { + names := make([]string, 0, len(u.Fields)) + for name := range u.Fields { + names = append(names, name) + } + return names +} + +// UnmarshalJSON implements custom JSON unmarshaling to capture all fields +func (u *User) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + coreFields := map[string]any{ + "uid": &u.UID, + "uuid": &u.UUID, + "name": &u.Name, + "mail": &u.Mail, + "status": &u.Status, + "created": &u.Created, + "changed": &u.Changed, + "langcode": &u.Langcode, + "timezone": &u.Timezone, + "roles": &u.Roles, + } + + for name, ptr := range coreFields { + if rawVal, ok := raw[name]; ok { + if err := json.Unmarshal(rawVal, ptr); err != nil { + return fmt.Errorf("failed to unmarshal core field %q: %w", name, err) + } + delete(raw, name) + } + } + + u.Fields = raw + return nil +} + +// MarshalJSON implements custom JSON marshaling +func (u *User) MarshalJSON() ([]byte, error) { + result := make(map[string]any, len(u.Fields)+10) + for k, v := range u.Fields { + result[k] = v + } + + result["uid"] = u.UID + result["uuid"] = u.UUID + result["name"] = u.Name + result["mail"] = u.Mail + result["status"] = u.Status + result["created"] = u.Created + result["changed"] = u.Changed + result["langcode"] = u.Langcode + result["timezone"] = u.Timezone + result["roles"] = u.Roles + + return json.Marshal(result) +} diff --git a/scripts/generate-bundles/main.go b/scripts/generate-bundles/main.go index 7eac105..9bee5b3 100644 --- a/scripts/generate-bundles/main.go +++ b/scripts/generate-bundles/main.go @@ -1,8 +1,8 @@ // Script to generate bundle definitions from a Drupal config sync export. // Usage: go run ./scripts/generate-bundles --config-sync /path/to/config/sync --output ./bundles // -// This parses node.type.*.yml, field.field.node.*.yml, and field.storage.node.*.yml -// files and generates bundle definition YAML files compatible with sitectl-drupal. +// This parses config files for nodes, taxonomy vocabularies, media types, and users, +// generating bundle definition YAML files compatible with sitectl-drupal. // // Plugins (like sitectl-isle) can use this script to generate bundle configs // from their distribution's config sync, then embed the output in their binary. @@ -22,17 +22,33 @@ import ( // Drupal config structures -// NodeType represents a Drupal node.type.*.yml file -type NodeType struct { +// GenericType represents any Drupal type config (node.type, taxonomy.vocabulary, media.type) +type GenericType struct { UUID string `yaml:"uuid"` LangCode string `yaml:"langcode"` Status bool `yaml:"status"` Name string `yaml:"name"` - Type string `yaml:"type"` + ID string `yaml:"id"` // vocabulary, media type + Type string `yaml:"type"` // node type + Vid string `yaml:"vid"` // vocabulary id Description string `yaml:"description"` } -// FieldStorage represents a Drupal field.storage.node.*.yml file +// MachineName returns the machine name depending on the type +func (g GenericType) MachineName() string { + if g.Type != "" { + return g.Type + } + if g.Vid != "" { + return g.Vid + } + if g.ID != "" { + return g.ID + } + return "" +} + +// FieldStorage represents a Drupal field.storage.*.yml file type FieldStorage struct { UUID string `yaml:"uuid"` FieldName string `yaml:"field_name"` @@ -42,7 +58,7 @@ type FieldStorage struct { Settings map[string]any `yaml:"settings"` } -// FieldConfig represents a Drupal field.field.node.*.yml file +// FieldConfig represents a Drupal field.field.*.yml file type FieldConfig struct { UUID string `yaml:"uuid"` FieldName string `yaml:"field_name"` @@ -57,16 +73,17 @@ type FieldConfig struct { // Output structures (our format) -type BundleConfig struct { - Version string `yaml:"version"` - Bundles []BundleDefinition `yaml:"bundles"` +type EntityConfig struct { + Version string `yaml:"version"` + Entities []BundleDefinition `yaml:"entities"` } type BundleDefinition struct { + EntityType string `yaml:"entity_type"` Name string `yaml:"name"` MachineName string `yaml:"machine_name"` Description string `yaml:"description,omitempty"` - Fields []FieldDefinition `yaml:"fields"` + Fields []FieldDefinition `yaml:"fields,omitempty"` } type FieldDefinition struct { @@ -88,21 +105,23 @@ var fieldTypeMap = map[string]string{ "text_with_summary": "typed_text", // Numeric - "integer": "int", - "decimal": "generic", - "float": "generic", - "boolean": "bool", + "integer": "int", + "decimal": "generic", + "float": "generic", + "boolean": "bool", + "list_integer": "int", + "list_string": "generic", // Reference fields "entity_reference": "entity_reference", - "file": "entity_reference", - "image": "entity_reference", + "file": "file", + "image": "image", // Typed relation (Islandora-specific) "typed_relation": "typed_relation", // Link - "link": "generic", + "link": "link", // Date/time "datetime": "generic", @@ -122,8 +141,8 @@ var fieldTypeMap = map[string]string{ // Paragraph/complex "entity_reference_revisions": "entity_reference", - // Fallback - "default": "generic", + // Password + "password": "password", } func mapFieldType(drupalType string) string { @@ -135,94 +154,59 @@ func mapFieldType(drupalType string) string { func main() { configSync := flag.String("config-sync", "", "Path to Drupal config/sync directory (required)") - output := flag.String("output", "./api/defaults", "Output directory for bundle definitions") + output := flag.String("output", "./bundles", "Output directory for bundle definitions") + outputFile := flag.String("output-file", "entities.yaml", "Output filename") flag.Parse() if *configSync == "" { fmt.Println("Usage: go run ./scripts/generate-bundles --config-sync /path/to/config/sync") fmt.Println() - fmt.Println("Example with islandora-starter-site:") - fmt.Println(" git clone --depth 1 https://github.com/Islandora-Devops/islandora-starter-site.git /tmp/starter") - fmt.Println(" go run ./scripts/generate-bundles --config-sync /tmp/starter/config/sync") + fmt.Println("Parses Drupal config sync to generate bundle definitions for:") + fmt.Println(" - Node types (content types)") + fmt.Println(" - Taxonomy vocabularies") + fmt.Println(" - Media types") + fmt.Println(" - User fields") + fmt.Println() + fmt.Println("Example:") + fmt.Println(" go run ./scripts/generate-bundles --config-sync /path/to/config/sync --output ./bundles") os.Exit(1) } - // Verify config sync directory exists if _, err := os.Stat(*configSync); os.IsNotExist(err) { log.Fatalf("Config sync directory does not exist: %s", *configSync) } - // Parse all node types - nodeTypes, err := parseNodeTypes(*configSync) - if err != nil { - log.Fatalf("Failed to parse node types: %v", err) - } - fmt.Printf("Found %d node types\n", len(nodeTypes)) - - // Parse all field storage configs - fieldStorage, err := parseFieldStorage(*configSync) - if err != nil { - log.Fatalf("Failed to parse field storage: %v", err) - } - fmt.Printf("Found %d field storage definitions\n", len(fieldStorage)) - - // Parse all field configs - fieldConfigs, err := parseFieldConfigs(*configSync) - if err != nil { - log.Fatalf("Failed to parse field configs: %v", err) + var allEntities []BundleDefinition + + // Parse node types + nodeTypes, nodeStorage, nodeFields := parseEntityType(*configSync, "node", "node.type.*.yml") + fmt.Printf("Found %d node types, %d field storage, %d field configs\n", len(nodeTypes), len(nodeStorage), len(nodeFields)) + allEntities = append(allEntities, buildBundles("node", nodeTypes, nodeStorage, nodeFields)...) + + // Parse taxonomy vocabularies + vocabTypes, vocabStorage, vocabFields := parseEntityType(*configSync, "taxonomy_term", "taxonomy.vocabulary.*.yml") + fmt.Printf("Found %d vocabularies, %d field storage, %d field configs\n", len(vocabTypes), len(vocabStorage), len(vocabFields)) + allEntities = append(allEntities, buildBundles("taxonomy_term", vocabTypes, vocabStorage, vocabFields)...) + + // Parse media types + mediaTypes, mediaStorage, mediaFields := parseEntityType(*configSync, "media", "media.type.*.yml") + fmt.Printf("Found %d media types, %d field storage, %d field configs\n", len(mediaTypes), len(mediaStorage), len(mediaFields)) + allEntities = append(allEntities, buildBundles("media", mediaTypes, mediaStorage, mediaFields)...) + + // Parse user fields (users don't have types, just fields) + userStorage, userFields := parseUserFields(*configSync) + if len(userFields) > 0 { + fmt.Printf("Found %d user field storage, %d user field configs\n", len(userStorage), len(userFields)) + userBundle := buildUserBundle(userStorage, userFields) + allEntities = append(allEntities, userBundle) } - fmt.Printf("Found %d field configurations\n", len(fieldConfigs)) - - // Group field configs by bundle - fieldsByBundle := make(map[string][]FieldConfig) - for _, fc := range fieldConfigs { - fieldsByBundle[fc.Bundle] = append(fieldsByBundle[fc.Bundle], fc) - } - - // Build bundle definitions - var bundles []BundleDefinition - for _, nt := range nodeTypes { - bundle := BundleDefinition{ - Name: nt.Name, - MachineName: nt.Type, - Description: nt.Description, - } - - // Add fields for this bundle - fields := fieldsByBundle[nt.Type] - sort.Slice(fields, func(i, j int) bool { - return fields[i].FieldName < fields[j].FieldName - }) - - for _, fc := range fields { - // Get cardinality from storage - cardinality := 1 - if storage, ok := fieldStorage[fc.FieldName]; ok { - cardinality = storage.Cardinality - } - - fieldDef := FieldDefinition{ - Name: fc.FieldName, - Type: mapFieldType(fc.FieldType), - Label: fc.Label, - Required: fc.Required, - Description: fc.Description, - } - // Only include cardinality if not single-value - if cardinality != 1 { - fieldDef.Cardinality = cardinality - } - - bundle.Fields = append(bundle.Fields, fieldDef) + // Sort entities by type then name + sort.Slice(allEntities, func(i, j int) bool { + if allEntities[i].EntityType != allEntities[j].EntityType { + return allEntities[i].EntityType < allEntities[j].EntityType } - - bundles = append(bundles, bundle) - } - - // Sort bundles by machine name - sort.Slice(bundles, func(i, j int) bool { - return bundles[i].MachineName < bundles[j].MachineName + return allEntities[i].MachineName < allEntities[j].MachineName }) // Create output directory @@ -230,21 +214,19 @@ func main() { log.Fatalf("Failed to create output directory: %v", err) } - // Write single combined file - config := BundleConfig{ - Version: "1.0", - Bundles: bundles, + // Write output file + config := EntityConfig{ + Version: "1.0", + Entities: allEntities, } - outputPath := filepath.Join(*output, "islandora_starter_site.yaml") + outputPath := filepath.Join(*output, *outputFile) data, err := yaml.Marshal(config) if err != nil { log.Fatalf("Failed to marshal YAML: %v", err) } - // Add header comment - header := `# Auto-generated from Islandora Starter Site config/sync -# Source: https://github.com/Islandora-Devops/islandora-starter-site + header := `# Auto-generated from Drupal config/sync # Regenerate with: go run ./scripts/generate-bundles --config-sync /path/to/config/sync # ` @@ -254,95 +236,208 @@ func main() { log.Fatalf("Failed to write output file: %v", err) } - fmt.Printf("Generated %s with %d bundles\n", outputPath, len(bundles)) + fmt.Printf("\nGenerated %s with %d entity definitions\n", outputPath, len(allEntities)) - // Print summary - fmt.Println("\nBundles:") - for _, b := range bundles { - fmt.Printf(" - %s (%s): %d fields\n", b.Name, b.MachineName, len(b.Fields)) + // Print summary by entity type + fmt.Println("\nSummary:") + entityCounts := make(map[string]int) + for _, e := range allEntities { + entityCounts[e.EntityType]++ + } + for et, count := range entityCounts { + fmt.Printf(" %s: %d bundles\n", et, count) } } -func parseNodeTypes(configSync string) ([]NodeType, error) { - pattern := filepath.Join(configSync, "node.type.*.yml") - files, err := filepath.Glob(pattern) - if err != nil { - return nil, err - } +func parseEntityType(configSync, entityType, typePattern string) ([]GenericType, map[string]FieldStorage, []FieldConfig) { + // Parse types + pattern := filepath.Join(configSync, typePattern) + files, _ := filepath.Glob(pattern) - var nodeTypes []NodeType + var types []GenericType for _, f := range files { data, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("reading %s: %w", f, err) + continue + } + var t GenericType + if err := yaml.Unmarshal(data, &t); err != nil { + continue } + types = append(types, t) + } + + // Parse field storage + storagePattern := filepath.Join(configSync, fmt.Sprintf("field.storage.%s.*.yml", entityType)) + storageFiles, _ := filepath.Glob(storagePattern) - var nt NodeType - if err := yaml.Unmarshal(data, &nt); err != nil { - return nil, fmt.Errorf("parsing %s: %w", f, err) + storage := make(map[string]FieldStorage) + for _, f := range storageFiles { + data, err := os.ReadFile(f) + if err != nil { + continue } + var fs FieldStorage + if err := yaml.Unmarshal(data, &fs); err != nil { + continue + } + storage[fs.FieldName] = fs + } + + // Parse field configs + fieldPattern := filepath.Join(configSync, fmt.Sprintf("field.field.%s.*.yml", entityType)) + fieldFiles, _ := filepath.Glob(fieldPattern) - nodeTypes = append(nodeTypes, nt) + var fields []FieldConfig + for _, f := range fieldFiles { + data, err := os.ReadFile(f) + if err != nil { + continue + } + var fc FieldConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + continue + } + fields = append(fields, fc) } - return nodeTypes, nil + return types, storage, fields } -func parseFieldStorage(configSync string) (map[string]FieldStorage, error) { - pattern := filepath.Join(configSync, "field.storage.node.*.yml") - files, err := filepath.Glob(pattern) - if err != nil { - return nil, err - } +func parseUserFields(configSync string) (map[string]FieldStorage, []FieldConfig) { + // Parse user field storage + storagePattern := filepath.Join(configSync, "field.storage.user.*.yml") + storageFiles, _ := filepath.Glob(storagePattern) storage := make(map[string]FieldStorage) - for _, f := range files { + for _, f := range storageFiles { data, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("reading %s: %w", f, err) + continue } - var fs FieldStorage if err := yaml.Unmarshal(data, &fs); err != nil { - return nil, fmt.Errorf("parsing %s: %w", f, err) + continue } - storage[fs.FieldName] = fs } - return storage, nil + // Parse user field configs + fieldPattern := filepath.Join(configSync, "field.field.user.*.yml") + fieldFiles, _ := filepath.Glob(fieldPattern) + + var fields []FieldConfig + for _, f := range fieldFiles { + data, err := os.ReadFile(f) + if err != nil { + continue + } + var fc FieldConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + continue + } + fields = append(fields, fc) + } + + return storage, fields } -func parseFieldConfigs(configSync string) ([]FieldConfig, error) { - pattern := filepath.Join(configSync, "field.field.node.*.yml") - files, err := filepath.Glob(pattern) - if err != nil { - return nil, err +func buildBundles(entityType string, types []GenericType, storage map[string]FieldStorage, fieldConfigs []FieldConfig) []BundleDefinition { + // Group fields by bundle + fieldsByBundle := make(map[string][]FieldConfig) + for _, fc := range fieldConfigs { + fieldsByBundle[fc.Bundle] = append(fieldsByBundle[fc.Bundle], fc) } - var configs []FieldConfig - for _, f := range files { - // Extract bundle and field name from filename - // Format: field.field.node.BUNDLE.FIELD_NAME.yml - base := filepath.Base(f) - base = strings.TrimSuffix(base, ".yml") - parts := strings.Split(base, ".") - if len(parts) < 5 { - continue + var bundles []BundleDefinition + for _, t := range types { + machineName := t.MachineName() + bundle := BundleDefinition{ + EntityType: entityType, + Name: t.Name, + MachineName: machineName, + Description: cleanDescription(t.Description), } - data, err := os.ReadFile(f) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", f, err) + // Add fields + fields := fieldsByBundle[machineName] + sort.Slice(fields, func(i, j int) bool { + return fields[i].FieldName < fields[j].FieldName + }) + + for _, fc := range fields { + cardinality := 1 + if s, ok := storage[fc.FieldName]; ok { + cardinality = s.Cardinality + } + + fieldDef := FieldDefinition{ + Name: fc.FieldName, + Type: mapFieldType(fc.FieldType), + Label: fc.Label, + Required: fc.Required, + Description: cleanDescription(fc.Description), + } + + if cardinality != 1 { + fieldDef.Cardinality = cardinality + } + + bundle.Fields = append(bundle.Fields, fieldDef) } - var fc FieldConfig - if err := yaml.Unmarshal(data, &fc); err != nil { - return nil, fmt.Errorf("parsing %s: %w", f, err) + bundles = append(bundles, bundle) + } + + return bundles +} + +func buildUserBundle(storage map[string]FieldStorage, fieldConfigs []FieldConfig) BundleDefinition { + bundle := BundleDefinition{ + EntityType: "user", + Name: "User", + MachineName: "user", + Description: "Drupal user account", + } + + sort.Slice(fieldConfigs, func(i, j int) bool { + return fieldConfigs[i].FieldName < fieldConfigs[j].FieldName + }) + + for _, fc := range fieldConfigs { + cardinality := 1 + if s, ok := storage[fc.FieldName]; ok { + cardinality = s.Cardinality + } + + fieldDef := FieldDefinition{ + Name: fc.FieldName, + Type: mapFieldType(fc.FieldType), + Label: fc.Label, + Required: fc.Required, + Description: cleanDescription(fc.Description), } - configs = append(configs, fc) + if cardinality != 1 { + fieldDef.Cardinality = cardinality + } + + bundle.Fields = append(bundle.Fields, fieldDef) } - return configs, nil + return bundle +} + +// cleanDescription removes HTML tags and excessive whitespace +func cleanDescription(desc string) string { + // Simple HTML tag removal - not comprehensive but handles common cases + desc = strings.ReplaceAll(desc, "
", " ") + desc = strings.ReplaceAll(desc, "
", " ") + desc = strings.ReplaceAll(desc, "
", " ") + desc = strings.ReplaceAll(desc, "\r\n", " ") + desc = strings.ReplaceAll(desc, "\n", " ") + + // Trim and collapse whitespace + fields := strings.Fields(desc) + return strings.Join(fields, " ") }