gary.info

here be dragons

PocketBase Blueprint

#go
pb_blueprint.go
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"path"
	"regexp"
	"strings"
	"time"

	"github.com/iancoleman/strcase"
	"gopkg.in/yaml.v3"
)

// --- PocketBase API Structures ---

// Auth Response
type AdminAuthResponse struct {
	Token string `json:"token"`
	Admin struct {
		ID    string `json:"id"`
		Email string `json:"email"`
	} `json:"admin"`
}

// Collection List Item (Simplified)
type PBListCollectionItem struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	Type       string `json:"type"`
	System     bool   `json:"system"`
	Schema     []PocketBaseField `json:"schema"` // Include schema for comparison/update
	ListRule   *string           `json:"listRule"`
	ViewRule   *string           `json:"viewRule"`
	CreateRule *string           `json:"createRule"`
	UpdateRule *string           `json:"updateRule"`
	DeleteRule *string           `json:"deleteRule"`
	Options    map[string]interface{} `json:"options"`
}

// API Response for listing collections
type PBListCollectionsResponse struct {
	Page       int                  `json:"page"`
	PerPage    int                  `json:"perPage"`
	TotalItems int                  `json:"totalItems"`
	TotalPages int                  `json:"totalPages"`
	Items      []PBListCollectionItem `json:"items"`
}

// Structures match the previous script (PocketBaseSchema, PocketBaseField, PocketBaseOptions)
type PocketBaseSchema struct {
	ID         string                 `json:"id,omitempty"` // Needed for updates if known
	Name       string                 `json:"name"`
	Type       string                 `json:"type"` // "base", "auth", "view"
	System     bool                   `json:"system"`
	Schema     []PocketBaseField      `json:"schema"`
	Indexes    []string               `json:"indexes,omitempty"` // Usually managed by PB
	ListRule   *string                `json:"listRule"`
	ViewRule   *string                `json:"viewRule"`
	CreateRule *string                `json:"createRule"`
	UpdateRule *string                `json:"updateRule"`
	DeleteRule *string                `json:"deleteRule"`
	Options    map[string]interface{} `json:"options"`
}

type PocketBaseField struct {
	ID      string              `json:"id,omitempty"` // PB assigns this
	Name    string              `json:"name"`
	Type    string              `json:"type"`
	System  bool                `json:"system"`
	Required bool               `json:"required"`
	Presentable bool            `json:"presentable"`
	Unique  bool                `json:"unique"`
	Options PocketBaseOptions `json:"options"`
}

type PocketBaseOptions map[string]interface{}

// --- Blueprint YAML Structures (same as before) ---
type BlueprintDraft struct {
	Models      map[string]map[string]interface{} `yaml:"models"`
	Controllers interface{}                       `yaml:"controllers,omitempty"`
	Seeders     interface{}                       `yaml:"seeders,omitempty"`
}

// --- Globals and Regex (same as before) ---
var (
	httpClient = &http.Client{Timeout: 15 * time.Second}
	adminToken = "" // Store authenticated token globally
	pbBaseURL  = "" // Store base URL globally

	fieldRegex        = regexp.MustCompile(`^([\w:]+)(?:\(([^)]+)\))?((?:\s+\w+(?::'?[\w\s,]+'?)?)*)$`)
	relationshipRegex = regexp.MustCompile(`^(\w+)\s+([\w\/]+)(.*)$`)
	modifierRegex     = regexp.MustCompile(`\s+(\w+)(?::'?([\w\s,]+)'?)?`)
)

// Intermediate structure to hold relation info before IDs are known
type PendingRelation struct {
	OwnerCollectionName string // Name of the collection this field belongs to
	FieldName           string // Name of the relation field (snake_case)
	TargetModelName     string // Original Blueprint model name of the target
	IsMultiple          bool   // Is this a hasMany/belongsToMany style relation?
	Required            bool   // Is the relation required?
	CascadeDelete       bool   // Should cascade delete be enabled?
}

func main() {
	// --- Command Line Flags ---
	inputFile := flag.String("i", "draft.yml", "Input Blueprint YAML file")
	pbURL := flag.String("url", "http://127.0.0.1:8090", "PocketBase instance URL")
	adminEmail := flag.String("email", "", "PocketBase Admin Email")
	adminPassword := flag.String("password", "", "PocketBase Admin Password")
	defaultListRule := flag.String("listRule", "", "Default PocketBase listRule (eg. \"@request.auth.id != ''\") - leave empty for null")
	defaultViewRule := flag.String("viewRule", "", "Default PocketBase viewRule")
	defaultCreateRule := flag.String("createRule", "", "Default PocketBase createRule")
	defaultUpdateRule := flag.String("updateRule", "", "Default PocketBase updateRule")
	defaultDeleteRule := flag.String("deleteRule", "", "Default PocketBase deleteRule")
	flag.Parse()

	if *adminEmail == "" || *adminPassword == "" {
		log.Fatal("Admin email and password are required (-email, -password)")
	}
	pbBaseURL = *pbURL // Store globally

	// --- Read YAML ---
	yamlFile, err := ioutil.ReadFile(*inputFile)
	if err != nil {
		log.Fatalf("Error reading YAML file %s: %v", *inputFile, err)
	}
	var draft BlueprintDraft
	err = yaml.Unmarshal(yamlFile, &draft)
	if err != nil {
		log.Fatalf("Error unmarshalling YAML: %v", err)
	}

	// Convert models definitions to map[string]string
	convertedModels := make(map[string]map[string]string)
	for modelName, fields := range draft.Models {
		convFields := make(map[string]string)
		for fname, def := range fields {
			if s, ok := def.(string); ok {
				convFields[fname] = s
			} else {
				convFields[fname] = fmt.Sprintf("%v", def)
			}
		}
		convertedModels[modelName] = convFields
	}

	if len(convertedModels) == 0 {
		log.Fatalf("No 'models' section found in %s", *inputFile)
	}

	// --- Authenticate with PocketBase ---
	err = authenticateAdmin(*adminEmail, *adminPassword)
	if err != nil {
		log.Fatalf("Authentication failed: %v", err)
	}
	fmt.Println("Successfully authenticated with PocketBase.")


	// --- Get Existing Collections ---
	existingCollections, err := getExistingCollections() // Map name -> PBListCollectionItem
	if err != nil {
		log.Fatalf("Failed to fetch existing collections: %v", err)
	}
	fmt.Printf("Found %d existing user collections.\n", len(existingCollections))

	// --- Pass 1: Create/Update Collections (Basic Fields Only) ---
	fmt.Println("\n--- Pass 1: Creating/Updating Collections (Basic Fields) ---")
	collectionNameMap := make(map[string]string)       // Map Original Model Name -> snake_case name
	collectionIDMap := make(map[string]string)         // Map snake_case name -> PocketBase ID
	pendingRelations := []PendingRelation{}            // Store relations to process in Pass 2
	processedSchemas := make(map[string]PocketBaseSchema) // Store schemas generated in Pass 1

	for modelName := range convertedModels {
		collectionName := strcase.ToSnake(modelName)
		collectionNameMap[modelName] = collectionName
	}

	for modelName, fields := range convertedModels {
		collectionName := collectionNameMap[modelName]
		fmt.Printf("Processing Model: %s -> Collection: %s\n", modelName, collectionName)

		pbSchema := PocketBaseSchema{
			Name:    collectionName,
			Type:    "base", // Default
			System:  false,
			Schema:  []PocketBaseField{},
			Options: make(map[string]interface{}),
		}

		// Apply default rules
		applyDefaultRules(&pbSchema, *defaultListRule, *defaultViewRule, *defaultCreateRule, *defaultUpdateRule, *defaultDeleteRule)
		// Detect Auth Collection
		detectAuthCollection(modelName, fields, &pbSchema)

		processedFields := make(map[string]bool)

		// --- Iterate through Blueprint fields ---
		for fieldName, definition := range fields {
			fieldNameSnake := strcase.ToSnake(fieldName)
			if processedFields[fieldNameSnake] || shouldSkipField(fieldName) {
				continue
			}

			// Check for relationship first (only store it for Pass 2)
			isRelation, relInfo := checkForRelationship(definition, fieldNameSnake, collectionName, modelName, fields)
			if isRelation {
				pendingRelations = append(pendingRelations, relInfo)
				// If it's a belongsTo, we might have a foreignId field defined too.
				// Mark the conventional foreign key name (e.g., user_id) as processed
				// if this relationship defines it implicitly.
				if strings.ToLower(relInfo.FieldName) != fieldNameSnake { // e.g., definition is 'author: belongsTo User' but fieldNameSnake is 'author'
                    fkGuess := strcase.ToSnake(fieldName) + "_id" // Guess the FK field name
                    if _, hasExplicitFkField := fields[fkGuess]; !hasExplicitFkField { // If there isn't a separate author_id: foreignId field defined
                        processedFields[fkGuess] = true // Avoid processing author_id separately later
                        fmt.Printf("  - Marked implicit FK '%s' as handled by relationship '%s'\n", fkGuess, fieldNameSnake)
                    }
				}
				processedFields[fieldNameSnake] = true // Mark the relationship name itself as processed
				fmt.Printf("  - Storing relationship '%s' for Pass 2\n", fieldNameSnake)
				continue // Skip adding relation fields in Pass 1
			}

			// If not a relationship, parse as standard field
			fmt.Printf("  - Processing Field: %s (%s)\n", fieldNameSnake, definition)
			pbField, err := mapBlueprintFieldToPocketBase(fieldNameSnake, definition, fields)
			if err != nil {
				log.Printf("  ! Warning: %v. Skipping field.", err)
				continue
			}

			// Special handling for foreignId: treat as TEXT in pass 1 if we didn't store a pending relation for it
            // This is a fallback in case the 'relation: belongsTo Model' syntax wasn't used.
            if pbField.Type == "relation" && !isPendingRelation(pendingRelations, collectionName, fieldNameSnake) {
                 fmt.Printf("    ! Warning: Field '%s' looks like a foreign key but no corresponding 'belongsTo' relation found. Creating as 'text' for now. Define relationships explicitly (e.g., 'author: belongsTo User') for proper relation fields.\n", fieldNameSnake)
                 pbField.Type = "text" // Fallback: store the ID as text in Pass 1
				 pbField.Options = PocketBaseOptions{} // Clear relation options
            }

			// Only add non-relation fields in Pass 1
			if pbField.Type != "relation" {
				pbSchema.Schema = append(pbSchema.Schema, pbField)
				processedFields[fieldNameSnake] = true
			} else {
                 // This case should ideally be handled by checkForRelationship storing a PendingRelation
                 fmt.Printf("    > Skipping relation-type field '%s' in Pass 1 (will be handled by explicit relationship definition or fallback).\n", fieldNameSnake)
                 processedFields[fieldNameSnake] = true // Mark as processed
            }
		} // End field loop

		// --- Send API Request (Create or Update) ---
		existing, exists := existingCollections[collectionName]
		var createdOrUpdatedCollection PBListCollectionItem
		var apiErr error

		if exists {
			fmt.Printf("  > Collection '%s' exists (ID: %s). Updating...\n", collectionName, existing.ID)
			pbSchema.ID = existing.ID // Ensure ID is set for PATCH
			createdOrUpdatedCollection, apiErr = updateCollection(pbSchema)
			collectionIDMap[collectionName] = existing.ID // Store existing ID
		} else {
			fmt.Printf("  > Collection '%s' does not exist. Creating...\n", collectionName)
			createdOrUpdatedCollection, apiErr = createCollection(pbSchema)
			if apiErr == nil {
				collectionIDMap[collectionName] = createdOrUpdatedCollection.ID // Store new ID
			}
		}

		if apiErr != nil {
			log.Printf("  ! Error processing collection %s: %v", collectionName, apiErr)
            // Optionally decide if the whole process should stop on error
            // continue // Continue with the next collection despite error
            os.Exit(1) // Or exit immediately
		} else {
             // Store the schema state *after* Pass 1 API call, including the ID
             finalSchema := PocketBaseSchema{ // Convert from PBListCollectionItem back to PocketBaseSchema
                ID: createdOrUpdatedCollection.ID,
                Name: createdOrUpdatedCollection.Name,
                Type: createdOrUpdatedCollection.Type,
                System: createdOrUpdatedCollection.System,
                Schema: createdOrUpdatedCollection.Schema,
                ListRule: createdOrUpdatedCollection.ListRule,
                ViewRule: createdOrUpdatedCollection.ViewRule,
                CreateRule: createdOrUpdatedCollection.CreateRule,
                UpdateRule: createdOrUpdatedCollection.UpdateRule,
                DeleteRule: createdOrUpdatedCollection.DeleteRule,
                Options: createdOrUpdatedCollection.Options,
             }
			 processedSchemas[collectionName] = finalSchema
             fmt.Printf("  > Successfully processed '%s' (ID: %s).\n", collectionName, collectionIDMap[collectionName])
        }

	} // End model loop (Pass 1)

	// --- Pass 2: Update Collections with Relation Fields ---
	fmt.Println("\n--- Pass 2: Updating Collections with Relation Fields ---")
	if len(pendingRelations) == 0 {
		fmt.Println("No relations defined in draft.yml. Skipping Pass 2.")
	}

	// Group pending relations by the collection they belong to
    relationsByOwner := make(map[string][]PendingRelation)
    for _, rel := range pendingRelations {
        relationsByOwner[rel.OwnerCollectionName] = append(relationsByOwner[rel.OwnerCollectionName], rel)
    }

    // Process relations for each collection that has them
    for ownerCollectionName, ownerRelations := range relationsByOwner {
        fmt.Printf("Processing relations for collection: %s\n", ownerCollectionName)

        currentSchema, ok := processedSchemas[ownerCollectionName]
        if !ok {
            log.Printf("  ! Error: Schema for owner collection '%s' not found from Pass 1. Skipping relations.", ownerCollectionName)
            continue
        }

        schemaNeedsUpdate := false
        updatedSchemaFields := make([]PocketBaseField, len(currentSchema.Schema))
        copy(updatedSchemaFields, currentSchema.Schema) // Start with existing fields

        existingFieldNames := make(map[string]int) // Map name to index in updatedSchemaFields
        for i, f := range updatedSchemaFields {
            existingFieldNames[f.Name] = i
        }

        for _, rel := range ownerRelations {
            targetCollectionName, ok := collectionNameMap[rel.TargetModelName]
            if !ok {
                log.Printf("  ! Error: Target model '%s' for relation '%s' on '%s' not found in draft.yml. Skipping.", rel.TargetModelName, rel.FieldName, ownerCollectionName)
                continue
            }
            targetCollectionID, ok := collectionIDMap[targetCollectionName]
            if !ok {
                log.Printf("  ! Error: ID for target collection '%s' not found (was it created successfully in Pass 1?). Skipping relation '%s'.", targetCollectionName, rel.FieldName)
                continue
            }

            fmt.Printf("  - Adding/Updating relation field: %s -> %s (ID: %s)\n", rel.FieldName, targetCollectionName, targetCollectionID)

            pbField := PocketBaseField{
                Name:       rel.FieldName,
                Type:       "relation",
                System:     false,
                Required:   rel.Required,
                Presentable: true,
                Unique:     false, // Usually false for relations, except maybe some hasOne
                Options: PocketBaseOptions{
                    "collectionId":  targetCollectionID, // Use the actual ID!
                    "cascadeDelete": rel.CascadeDelete,
                    "minSelect":     nil,
                    "maxSelect":     nil, // Default unlimited for multiple
                    "displayFields": nil, // User configures this
                },
            }

            if rel.IsMultiple {
                pbField.Options["maxSelect"] = nil // Many relation
            } else {
                pbField.Options["maxSelect"] = 1 // Single relation (belongsTo, hasOne)
            }

            // Set minSelect if required
			if rel.Required {
				pbField.Options["minSelect"] = 1
			} else {
                 pbField.Options["minSelect"] = nil // Explicitly set nil for optional
            }


            // Check if the field already exists (maybe from a previous run or manual creation)
            if index, fieldExists := existingFieldNames[rel.FieldName]; fieldExists {
                 // Field exists, check if it needs updating (type or options changed)
                 existingField := updatedSchemaFields[index]
                 if existingField.Type != "relation" || !areOptionsEqual(existingField.Options, pbField.Options) {
                     fmt.Printf("    > Updating existing field '%s' to relation.\n", rel.FieldName)
                     updatedSchemaFields[index] = pbField // Replace the existing field definition
                     schemaNeedsUpdate = true
                 } else {
                     fmt.Printf("    > Relation field '%s' already exists and seems up-to-date.\n", rel.FieldName)
                 }
            } else {
                 // Field doesn't exist, add it
                 fmt.Printf("    > Adding new relation field '%s'.\n", rel.FieldName)
                 updatedSchemaFields = append(updatedSchemaFields, pbField)
                 schemaNeedsUpdate = true
                 existingFieldNames[rel.FieldName] = len(updatedSchemaFields)-1 // Update index map
            }
        } // End loop through relations for this owner

        // If any relations were added/updated for this collection, send PATCH request
        if schemaNeedsUpdate {
             fmt.Printf("  > Sending update for collection '%s' with new/modified relations...\n", ownerCollectionName)
             currentSchema.Schema = updatedSchemaFields // Update the schema array in the payload
             _, apiErr := updateCollection(currentSchema) // Send PATCH with the updated schema list
             if apiErr != nil {
                 log.Printf("  ! Error updating collection %s with relations: %v", ownerCollectionName, apiErr)
                 // Continue or exit based on desired robustness
             } else {
                 fmt.Printf("  > Successfully updated '%s' with relations.\n", ownerCollectionName)
             }
        } else {
             fmt.Printf("  > No relation updates needed for '%s'.\n", ownerCollectionName)
        }
    } // End loop through owners (Pass 2)


	fmt.Println("\n--- Sync Process Completed ---")
	fmt.Println("Review PocketBase UI and logs for any potential issues.")
}

// --- Helper Functions ---

// Simplified check for option equality (only checks keys used in relation)
func areOptionsEqual(o1, o2 PocketBaseOptions) bool {
    keys := []string{"collectionId", "cascadeDelete", "minSelect", "maxSelect"}
    for _, k := range keys {
        v1, ok1 := o1[k]
        v2, ok2 := o2[k]
        if ok1 != ok2 || fmt.Sprintf("%v", v1) != fmt.Sprintf("%v", v2) { // Basic value comparison
            return false
        }
    }
    return true
}

// Check if a relation is already stored in pendingRelations
func isPendingRelation(pending []PendingRelation, ownerName, fieldName string) bool {
    for _, p := range pending {
        if p.OwnerCollectionName == ownerName && p.FieldName == fieldName {
            return true
        }
    }
    return false
}


func applyDefaultRules(schema *PocketBaseSchema, list, view, create, update, delete string) {
	if list != "" { schema.ListRule = &list } else { schema.ListRule = nil }
	if view != "" { schema.ViewRule = &view } else { schema.ViewRule = nil }
	if create != "" { schema.CreateRule = &create } else { schema.CreateRule = nil }
	if update != "" { schema.UpdateRule = &update } else { schema.UpdateRule = nil }
	if delete != "" { schema.DeleteRule = &delete } else { schema.DeleteRule = nil }
}

func detectAuthCollection(modelName string, fields map[string]string, schema *PocketBaseSchema) {
	if strings.EqualFold(modelName, "user") || strings.EqualFold(modelName, "auth") {
		hasEmail := false
		hasPassword := false
		hasUsername := false // Check for username auth possibility

		for fieldName := range fields {
			lFieldName := strings.ToLower(fieldName)
			if lFieldName == "email" { hasEmail = true }
			if strings.Contains(lFieldName, "password") { hasPassword = true } // Basic check
            if lFieldName == "username" { hasUsername = true}
		}

		if hasEmail && hasPassword {
			fmt.Printf("    > Detected potential auth collection: %s\n", schema.Name)
			schema.Type = "auth"
			schema.Options["allowEmailAuth"] = true
			schema.Options["allowOAuth2Auth"] = true // Default true
			schema.Options["allowUsernameAuth"] = hasUsername // Enable if username field found
			schema.Options["exceptEmailDomains"] = nil
			schema.Options["manageRule"] = nil
			schema.Options["onlyEmailDomains"] = nil
			// PocketBase automatically adds username/email constraints based on allow options
			// schema.Options["requireEmail"] = true // PB handles this based on allowEmailAuth
		}
	}
}

func shouldSkipField(fieldName string) bool {
	// Skip special Blueprint fields handled by PocketBase implicitly or not applicable
	skip := map[string]bool{
		"id":            true,
		"timestamps":    true,
		"softDeletes":   true,
		"rememberToken": true,
	}
	_, shouldSkip := skip[fieldName]
    if shouldSkip {
        fmt.Printf("  - Skipping Blueprint meta-field: %s\n", fieldName)
    }
	return shouldSkip
}

// Checks if a blueprint definition string defines a relationship.
// If it does, returns true and a PendingRelation struct.
func checkForRelationship(definition, fieldNameSnake, ownerCollectionName, ownerModelName string, allFields map[string]string) (bool, PendingRelation) {
	relMatch := relationshipRegex.FindStringSubmatch(definition)
	if len(relMatch) == 0 {
		return false, PendingRelation{} // Not a relationship definition like "author: belongsTo User"
	}

	relType := strings.ToLower(relMatch[1])
	relModel := relMatch[2]
	// relModifiersStr := strings.TrimSpace(relMatch[3]) // TODO: Parse modifiers like foreignKey for cascadeDelete?

	isMultiple := false
	cascadeDelete := false // Default false, maybe infer from modifiers later

	switch relType {
	case "belongsto", "hasone", "morphto": // Treat as single relation
		isMultiple = false
	case "hasmany", "belongstomany", "morphmany", "morphtomany": // Treat as multiple relation
		isMultiple = true
	default:
		fmt.Printf("  ! Warning: Unsupported relationship type '%s' for field '%s'. Skipping relation.\n", relType, fieldNameSnake)
		return false, PendingRelation{} // Treat as not a valid relation for processing
	}

    // Determine required status from original definition if possible
    required := true // Default to required for belongsTo unless nullable found
     if isMultiple {
         required = false // Many relations are usually not required
     } else {
         _, _, modifiersStr := parseFieldDefinition(definition)
         modifiers := parseModifiers(modifiersStr)
         if _, nullable := modifiers["nullable"]; nullable {
             required = false
         }
     }

     // Look for explicit foreign key definition to check for `constrained`
     fkFieldNameGuess := fieldNameSnake + "_id" // e.g., author -> author_id
     if fkDef, ok := allFields[fkFieldNameGuess]; ok {
         _, _, modifiersStr := parseFieldDefinition(fkDef)
         modifiers := parseModifiers(modifiersStr)
         if _, constrained := modifiers["constrained"]; constrained {
              fmt.Printf("    > Found 'constrained' on FK field '%s', enabling cascade delete for relation '%s'.\n", fkFieldNameGuess, fieldNameSnake)
              cascadeDelete = true
         }
     }


	return true, PendingRelation{
		OwnerCollectionName: ownerCollectionName,
		FieldName:           fieldNameSnake,
		TargetModelName:     relModel,
		IsMultiple:          isMultiple,
        Required:            required,
        CascadeDelete:       cascadeDelete,
	}
}

// Maps a single Blueprint field definition (non-relationship) to PocketBaseField
func mapBlueprintFieldToPocketBase(fieldNameSnake, definition string, allFields map[string]string) (PocketBaseField, error) {
	fieldType, typeOptionsStr, modifiersStr := parseFieldDefinition(definition)
	modifiers := parseModifiers(modifiersStr)

	pbField := PocketBaseField{
		Name:       fieldNameSnake,
		Type:       "text", // Default
		System:     false,
		Required:   true, // Default required unless 'nullable'
		Presentable: true,
		Unique:     false,
		Options:    make(PocketBaseOptions),
	}

	if _, exists := modifiers["nullable"]; exists {
		pbField.Required = false
	}
	if _, exists := modifiers["unique"]; exists {
		pbField.Unique = true
	}
    // PocketBase handles default values differently (not in schema definition)

	parts := strings.Split(fieldType, ":")
	baseType := parts[0]
	typeArgs := ""
	if len(parts) > 1 { typeArgs = parts[1] }

	// --- Type Mapping Logic (mostly same as previous script) ---
	switch baseType {
	case "string", "char":
		pbField.Type = "text"
		if typeArgs != "" { pbField.Options["max"] = parseInt(typeArgs, 0) }
		pbField.Options["min"] = nil; pbField.Options["pattern"] = ""
		// Infer email/url
		lFieldName := strings.ToLower(fieldNameSnake)
		if strings.Contains(lFieldName, "email") {
			pbField.Type = "email"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil
		} else if strings.Contains(lFieldName, "url") || strings.Contains(lFieldName, "website") {
			pbField.Type = "url"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil
		}
	case "integer", "tinyint", "smallint", "mediumint", "bigint", "unsignedInteger", "unsignedTinyint", "unsignedSmallint", "unsignedMediumint", "unsignedBigint", "increments", "tinyIncrements", "smallIncrements", "mediumIncrements", "bigIncrements":
		pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil
	case "float", "double", "decimal", "unsignedDecimal":
		pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil
	case "boolean":
		pbField.Type = "bool"
	case "date", "datetime", "datetimez", "timestamp", "timestampz", "time", "timez", "year":
		pbField.Type = "date"; pbField.Options["min"] = ""; pbField.Options["max"] = ""
	case "text", "tinytext", "mediumtext", "longtext":
		pbField.Type = "text"; pbField.Options["min"] = nil; pbField.Options["max"] = nil; pbField.Options["pattern"] = ""
	case "json", "jsonb":
		pbField.Type = "json"
	case "uuid":
		pbField.Type = "text"; // Optional: pbField.Options["pattern"] = "..."
	case "enum":
		pbField.Type = "select"
		values := []string{}
		if typeOptionsStr != "" { values = strings.Split(typeOptionsStr, ",") }
		for i := range values { values[i] = strings.TrimSpace(values[i]) }
		pbField.Options["values"] = values; pbField.Options["maxSelect"] = 1
    case "file": // Handle Blueprint file type
        pbField.Type = "file"
        pbField.Options["maxSelect"] = 1 // Default to single file
        pbField.Options["maxSize"] = 5242880 // Default 5MB
        pbField.Options["mimeTypes"] = []string{} // Allow any by default
        pbField.Options["thumbs"] = []string{} // No thumbs by default
        // Check for modifiers like 'multiple' if needed
	case "foreignId", "foreignUuid":
		// This is tricky. If handled by an explicit relation definition, it's skipped earlier.
        // If reached here, it means only `user_id: foreignId` was defined.
        // We *could* try to infer the relation here, but it's better handled by Pass 2 from explicit definitions.
        // For Pass 1, we'll mark it as a relation type but with a placeholder collectionId. Pass 2 *should* ideally overwrite this if an explicit relation exists. If not, it might remain a text field (see logic in main loop).
        pbField.Type = "relation" // Tentative type
        pbField.Options["maxSelect"] = 1
        pbField.Options["minSelect"] = nil
        if pbField.Required { pbField.Options["minSelect"] = 1 }
        pbField.Options["collectionId"] = "PLACEHOLDER_" + strings.TrimSuffix(fieldNameSnake, "_id") // Placeholder
        pbField.Options["cascadeDelete"] = false
         if _, constrained := modifiers["constrained"]; constrained {
             pbField.Options["cascadeDelete"] = true
         }
        pbField.Options["displayFields"] = nil
        // A warning about this fallback case is printed in the main loop if needed.
	default:
		return pbField, fmt.Errorf("unmapped Blueprint type '%s' for field '%s'. Defaulting to PocketBase type 'text'", baseType, fieldNameSnake)
	}

	return pbField, nil
}

// --- PocketBase API Interaction ---

func makeAPIRequest(method, apiPath string, payload interface{}) ([]byte, error) {
	var reqBody io.Reader
	if payload != nil {
		jsonData, err := json.Marshal(payload)
		if err != nil {
			return nil, fmt.Errorf("failed to marshal payload: %w", err)
		}
		reqBody = bytes.NewBuffer(jsonData)
		fmt.Printf("DEBUG: %s %s Payload: %s\n", method, apiPath, string(jsonData))
	}

	// Debug the full URL being requested
	fullURL, err := url.Parse(pbBaseURL)
	if err != nil {
		return nil, fmt.Errorf("invalid base URL: %w", err)
	}
	
	// Ensure the API path starts with /api/
	if !strings.HasPrefix(apiPath, "/api/") {
		apiPath = "/api/" + strings.TrimPrefix(apiPath, "/")
	}
	
	// Handle query parameters separately
	pathParts := strings.SplitN(apiPath, "?", 2)
	fullURL.Path = path.Join(fullURL.Path, pathParts[0])
	if len(pathParts) > 1 {
		fullURL.RawQuery = pathParts[1]
	}
	fmt.Printf("DEBUG: Full URL: %s\n", fullURL.String())

	req, err := http.NewRequest(method, fullURL.String(), reqBody)
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	if adminToken != "" {
		req.Header.Set("Authorization", adminToken)
	}

	fmt.Printf("DEBUG: Making request to: %s %s\n", req.Method, req.URL.String())
	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	fmt.Printf("DEBUG: Response status: %s\n", resp.Status)
	defer resp.Body.Close()

	respBody, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return respBody, fmt.Errorf("API error: %s (%d) - %s", http.StatusText(resp.StatusCode), resp.StatusCode, string(respBody))
	}

	return respBody, nil
}

func authenticateAdmin(email, password string) error {
	// First verify the instance is reachable
	_, err := makeAPIRequest(http.MethodGet, "/api/health", nil)
	if err != nil {
		return fmt.Errorf("failed to reach PocketBase instance: %w\n" +
			"Please ensure:\n" +
			"1. The PocketBase instance is running at the specified URL\n" +
			"2. The URL is correct and accessible", err)
	}

	authPayload := map[string]string{
		"identity": email,
		"password": password,
	}

	// Try admin authentication
	respBody, err := makeAPIRequest(http.MethodPost, "/api/collections/_superusers/auth-with-password", authPayload)
	if err != nil {
		if strings.Contains(err.Error(), "400") {
			return fmt.Errorf("invalid admin credentials\n" +
				"Please ensure:\n" +
				"1. You're using the correct admin email and password\n" +
				"2. The admin account exists and is enabled\n" +
				"3. The password is correct")
		}
		return fmt.Errorf("admin authentication failed: %w", err)
	}

	var authResp AdminAuthResponse
	if err := json.Unmarshal(respBody, &authResp); err != nil {
		return fmt.Errorf("failed to parse auth response: %w - Body: %s", err, string(respBody))
	}

	if authResp.Token == "" {
		return fmt.Errorf("authentication succeeded but token is empty")
	}
	adminToken = authResp.Token // Store token globally
	return nil
}


func getExistingCollections() (map[string]PBListCollectionItem, error) {
	collections := make(map[string]PBListCollectionItem)
	page := 1
	for {
		// Use the correct collections endpoint for v0.26.3
		apiPath := fmt.Sprintf("/api/collections?page=%d&perPage=500", page)
		fmt.Printf("DEBUG: Making request to: %s\n", apiPath)
		respBody, err := makeAPIRequest(http.MethodGet, apiPath, nil)
		if err != nil {
			return nil, fmt.Errorf("failed to list collections (page %d): %w", page, err)
		}

		var listResp PBListCollectionsResponse
		if err := json.Unmarshal(respBody, &listResp); err != nil {
			return nil, fmt.Errorf("failed to parse collection list response (page %d): %w - Body: %s", page, err, string(respBody))
		}

		for _, item := range listResp.Items {
			collections[item.Name] = item
		}

		if listResp.Page >= listResp.TotalPages {
			break // No more pages
		}
		page++
	}
	return collections, nil
}

func createCollection(schema PocketBaseSchema) (PBListCollectionItem, error) {
    var createdCollection PBListCollectionItem
	respBody, err := makeAPIRequest(http.MethodPost, "/api/collections", schema)
	if err != nil {
		return createdCollection, err
	}
    if err := json.Unmarshal(respBody, &createdCollection); err != nil {
        return createdCollection, fmt.Errorf("failed to parse created collection response: %w - Body: %s", err, string(respBody))
    }
	return createdCollection, nil
}

func updateCollection(schema PocketBaseSchema) (PBListCollectionItem, error) {
	if schema.ID == "" {
		return PBListCollectionItem{}, fmt.Errorf("cannot update collection '%s', ID is missing", schema.Name)
	}
    var updatedCollection PBListCollectionItem
	apiPath := fmt.Sprintf("/api/collections/%s", schema.ID)
    // Send the whole schema definition for update. PocketBase PATCH should handle replacing/updating fields.
	respBody, err := makeAPIRequest(http.MethodPatch, apiPath, schema)
	if err != nil {
		return updatedCollection, err
	}
    if err := json.Unmarshal(respBody, &updatedCollection); err != nil {
        // Sometimes PATCH returns 204 No Content on success with empty body
        if len(respBody) == 0 {
             fmt.Printf("    > Update for '%s' returned 204 No Content (assumed successful).\n", schema.Name)
             // Return the schema we sent, assuming it was applied
             // PB doesn't return the full object on 204
            tempSchema := PBListCollectionItem{
                ID: schema.ID, Name: schema.Name, Type: schema.Type, System: schema.System,
                Schema: schema.Schema, ListRule: schema.ListRule, ViewRule: schema.ViewRule,
                CreateRule: schema.CreateRule, UpdateRule: schema.UpdateRule, DeleteRule: schema.DeleteRule,
                Options: schema.Options,
             }
             return tempSchema, nil
        }
        return updatedCollection, fmt.Errorf("failed to parse updated collection response: %w - Body: %s", err, string(respBody))
    }
	return updatedCollection, nil
}


// --- YAML/Blueprint Parsing Helpers (mostly same as before) ---
func parseFieldDefinition(def string) (fieldType, typeOptions string, modifiers string) {
	match := fieldRegex.FindStringSubmatch(def)
	if len(match) < 4 {
		parts := strings.Fields(def)
		if len(parts) > 0 {
			fieldType = parts[0]
			if len(parts) > 1 { modifiers = strings.Join(parts[1:], " ") }
		}
		return
	}
	fieldType = match[1]; typeOptions = match[2]; modifiers = strings.TrimSpace(match[3])
	return
}

func parseModifiers(modStr string) map[string]string {
	mods := make(map[string]string)
	matches := modifierRegex.FindAllStringSubmatch(modStr, -1)
	for _, match := range matches {
		if len(match) > 1 {
			key := match[1]; value := ""; if len(match) > 2 { value = match[2] }
			mods[key] = value
		}
	}
	return mods
}

func parseInt(s string, defaultVal int) int {
	var i int; _, err := fmt.Sscan(s, &i); if err != nil { return defaultVal }; return i
}