Split monolithic engine.go (776 lines) into specialized files: - engine.go: Core orchestration (142 lines, 82% reduction) - collection.go: Collection processing and management (445 lines) - content.go: Content injection and extraction (152 lines) - discovery.go: Element discovery and DOM traversal (85 lines) Benefits: - Single responsibility principle applied to each file - Better code organization and navigation - Improved testability of individual components - Easier team development and code reviews - Maintained full API compatibility with no breaking changes
444 lines
16 KiB
Go
444 lines
16 KiB
Go
package engine
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/insertr/insertr/internal/db"
|
||
"golang.org/x/net/html"
|
||
)
|
||
|
||
// CollectionElement represents an insertr-add collection element found in HTML
|
||
type CollectionElement struct {
|
||
Node *html.Node
|
||
}
|
||
|
||
// hasInsertrAddClass checks if node has class="insertr-add" (collection)
|
||
func (e *ContentEngine) hasInsertrAddClass(node *html.Node) bool {
|
||
classes := GetClasses(node)
|
||
return ContainsClass(classes, "insertr-add")
|
||
}
|
||
|
||
// processCollection handles collection detection, persistence and reconstruction
|
||
func (e *ContentEngine) processCollection(collectionNode *html.Node, collectionID, siteID string) error {
|
||
// 1. Check if collection exists in database
|
||
existingCollection, err := e.client.GetCollection(context.Background(), siteID, collectionID)
|
||
collectionExists := (err == nil && existingCollection != nil)
|
||
|
||
if !collectionExists {
|
||
// 2. New collection: extract container HTML and create collection record
|
||
containerHTML := e.extractOriginalTemplate(collectionNode)
|
||
|
||
_, err := e.client.CreateCollection(context.Background(), siteID, collectionID, containerHTML, "system")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create collection %s: %w", collectionID, err)
|
||
}
|
||
|
||
// 3. Extract templates and store initial items from existing children
|
||
err = e.extractAndStoreTemplatesAndItems(collectionNode, collectionID, siteID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to extract templates and items for collection %s: %w", collectionID, err)
|
||
}
|
||
|
||
fmt.Printf("✅ Created new collection: %s with templates and initial items\n", collectionID)
|
||
} else {
|
||
// 4. Existing collection: Always reconstruct from database (database is source of truth)
|
||
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
|
||
}
|
||
|
||
// Optional: Show item count for feedback
|
||
existingItems, _ := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
|
||
fmt.Printf("✅ Reconstructed collection: %s from database (%d items)\n", collectionID, len(existingItems))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// extractAndStoreTemplatesAndItems extracts templates and stores initial items from existing collection children
|
||
func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.Node, collectionID, siteID string) error {
|
||
var templateIDs []int
|
||
templateCount := 0
|
||
|
||
// Walk through direct children of the collection
|
||
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
||
if child.Type == html.ElementNode {
|
||
templateCount++
|
||
}
|
||
}
|
||
|
||
// If no templates found, create a default template
|
||
if templateCount == 0 {
|
||
_, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, "default", "<div>New item</div>", true)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create default template: %w", err)
|
||
}
|
||
fmt.Printf("✅ Created default template for collection %s\n", collectionID)
|
||
return nil
|
||
}
|
||
|
||
// Create templates for each unique child structure
|
||
templateIndex := 0
|
||
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
||
if child.Type == html.ElementNode {
|
||
templateName := fmt.Sprintf("template_%d", templateIndex+1)
|
||
templateHTML := e.extractCleanTemplate(child)
|
||
isDefault := templateIndex == 0
|
||
|
||
template, err := e.client.CreateCollectionTemplate(context.Background(), siteID, collectionID, templateName, templateHTML, isDefault)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create template %s: %w", templateName, err)
|
||
}
|
||
templateIDs = append(templateIDs, template.TemplateID)
|
||
templateIndex++
|
||
fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
|
||
}
|
||
}
|
||
|
||
// Store original children as initial collection items (database-first approach)
|
||
err := e.storeChildrenAsCollectionItems(collectionNode, collectionID, siteID, templateIDs)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to store initial collection items: %w", err)
|
||
}
|
||
|
||
// Clear HTML children and reconstruct from database (ensures consistency)
|
||
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to reconstruct initial collection items: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// reconstructCollectionItems rebuilds collection items from database and adds them to DOM
|
||
func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, collectionID, siteID string) error {
|
||
// Get all items for this collection from database
|
||
items, err := e.client.GetCollectionItems(context.Background(), siteID, collectionID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get collection items: %w", err)
|
||
}
|
||
|
||
// Get templates for this collection
|
||
templates, err := e.client.GetCollectionTemplates(context.Background(), siteID, collectionID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get collection templates: %w", err)
|
||
}
|
||
|
||
// Build template lookup for efficiency
|
||
templateLookup := make(map[int]*db.CollectionTemplateItem)
|
||
for _, template := range templates {
|
||
templateLookup[template.TemplateID] = &template
|
||
}
|
||
|
||
// Clear existing children from the collection node
|
||
for child := collectionNode.FirstChild; child != nil; {
|
||
next := child.NextSibling
|
||
collectionNode.RemoveChild(child)
|
||
child = next
|
||
}
|
||
|
||
// Reconstruct items in order from database
|
||
for _, item := range items {
|
||
_, exists := templateLookup[item.TemplateID]
|
||
if !exists {
|
||
fmt.Printf("⚠️ Template %d not found for item %s, skipping\n", item.TemplateID, item.ItemID)
|
||
continue
|
||
}
|
||
|
||
// Parse the stored structural template HTML
|
||
structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
|
||
if err != nil {
|
||
fmt.Printf("⚠️ Failed to parse structural template for item %s: %v\n", item.ItemID, err)
|
||
continue
|
||
}
|
||
|
||
// Find the body and extract its children (stored as complete structure)
|
||
var structuralChild *html.Node
|
||
e.walkNodes(structuralDoc, func(n *html.Node) {
|
||
if n.Type == html.ElementNode && n.Data == "body" {
|
||
// Get the first element child of body
|
||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||
if child.Type == html.ElementNode {
|
||
structuralChild = child
|
||
break
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
if structuralChild != nil {
|
||
// Remove from its current parent before adding to collection
|
||
if structuralChild.Parent != nil {
|
||
structuralChild.Parent.RemoveChild(structuralChild)
|
||
}
|
||
|
||
// Add hydrated structural elements directly to collection (stored HTML has complete structure)
|
||
// The structural template already contains hydrated content from database
|
||
|
||
// Inject data-item-id attribute for collection item identification
|
||
if structuralChild.Type == html.ElementNode {
|
||
SetAttribute(structuralChild, "data-item-id", item.ItemID)
|
||
}
|
||
|
||
collectionNode.AppendChild(structuralChild)
|
||
}
|
||
}
|
||
|
||
fmt.Printf("✅ Reconstructed %d items for collection %s\n", len(items), collectionID)
|
||
return nil
|
||
}
|
||
|
||
// processChildElementsAsContent processes .insertr elements within a collection child and stores them as individual content
|
||
func (e *ContentEngine) processChildElementsAsContent(childElement *html.Node, siteID, itemID string) ([]ContentEntry, error) {
|
||
var contentEntries []ContentEntry
|
||
|
||
// Walk through the child element and find .insertr elements
|
||
e.walkNodes(childElement, func(n *html.Node) {
|
||
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||
// Generate content ID for this .insertr element
|
||
contentID := e.idGenerator.Generate(n, "collection-item")
|
||
|
||
// Extract the content
|
||
htmlContent := e.extractHTMLContent(n)
|
||
template := e.extractCleanTemplate(n)
|
||
|
||
// Store content entry
|
||
contentEntries = append(contentEntries, ContentEntry{
|
||
ID: contentID,
|
||
SiteID: siteID,
|
||
HTMLContent: htmlContent,
|
||
Template: template,
|
||
})
|
||
|
||
// Set the data-content-id attribute
|
||
SetAttribute(n, "data-content-id", contentID)
|
||
|
||
// Clear content - this will be hydrated during reconstruction
|
||
for child := n.FirstChild; child != nil; {
|
||
next := child.NextSibling
|
||
n.RemoveChild(child)
|
||
child = next
|
||
}
|
||
}
|
||
})
|
||
|
||
return contentEntries, nil
|
||
}
|
||
|
||
// generateStructuralTemplateFromChild creates a structural template with placeholders for content
|
||
func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.Node, contentEntries []ContentEntry) (string, error) {
|
||
// Clone the child to avoid modifying the original
|
||
clonedChild := e.cloneNode(childElement)
|
||
|
||
// Walk through and replace .insertr content with data-content-id attributes
|
||
entryIndex := 0
|
||
e.walkNodes(clonedChild, func(n *html.Node) {
|
||
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
||
if entryIndex < len(contentEntries) {
|
||
// Set the data-content-id attribute
|
||
SetAttribute(n, "data-content-id", contentEntries[entryIndex].ID)
|
||
|
||
// Clear content - this will be hydrated during reconstruction
|
||
for child := n.FirstChild; child != nil; {
|
||
next := child.NextSibling
|
||
n.RemoveChild(child)
|
||
child = next
|
||
}
|
||
|
||
entryIndex++
|
||
}
|
||
}
|
||
})
|
||
|
||
// Generate HTML for the structural template
|
||
var buf strings.Builder
|
||
if err := html.Render(&buf, clonedChild); err != nil {
|
||
return "", fmt.Errorf("failed to render structural template: %w", err)
|
||
}
|
||
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// createVirtualElementFromTemplate creates a virtual DOM element from template HTML
|
||
func (e *ContentEngine) createVirtualElementFromTemplate(templateHTML string) (*html.Node, error) {
|
||
// Parse template HTML into a virtual DOM
|
||
templateDoc, err := html.Parse(strings.NewReader(templateHTML))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to parse template HTML: %w", err)
|
||
}
|
||
|
||
// Find the first element in the body
|
||
var templateElement *html.Node
|
||
e.walkNodes(templateDoc, func(n *html.Node) {
|
||
if templateElement == nil && n.Type == html.ElementNode && n.Data != "html" && n.Data != "head" && n.Data != "body" {
|
||
templateElement = n
|
||
}
|
||
})
|
||
|
||
if templateElement == nil {
|
||
return nil, fmt.Errorf("no valid element found in template HTML")
|
||
}
|
||
|
||
return templateElement, nil
|
||
}
|
||
|
||
// CreateCollectionItemFromTemplate creates a collection item using the unified engine approach
|
||
func (e *ContentEngine) CreateCollectionItemFromTemplate(
|
||
siteID, collectionID string,
|
||
templateID int,
|
||
templateHTML string,
|
||
lastEditedBy string,
|
||
) (*db.CollectionItemWithTemplate, error) {
|
||
// Create virtual element from template for ID generation
|
||
virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create virtual element: %w", err)
|
||
}
|
||
|
||
// Generate unique item ID using unified generator with collection context
|
||
itemID := e.idGenerator.Generate(virtualElement, "collection-item")
|
||
|
||
// Process any .insertr elements within the template and store as content
|
||
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to process child elements: %w", err)
|
||
}
|
||
|
||
// Store individual content entries in content table
|
||
for _, entry := range contentEntries {
|
||
_, err := e.client.CreateContent(context.Background(), entry.SiteID, entry.ID, entry.HTMLContent, entry.Template, lastEditedBy)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create content entry %s: %w", entry.ID, err)
|
||
}
|
||
}
|
||
|
||
// Generate structural template for the collection item
|
||
structuralTemplate, err := e.generateStructuralTemplateFromChild(virtualElement, contentEntries)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate structural template: %w", err)
|
||
}
|
||
|
||
// Create collection item with structural template
|
||
collectionItem, err := e.client.CreateCollectionItem(context.Background(),
|
||
siteID, collectionID, itemID, templateID, structuralTemplate, 0, lastEditedBy,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create collection item: %w", err)
|
||
}
|
||
|
||
return collectionItem, nil
|
||
}
|
||
|
||
// storeChildrenAsCollectionItems stores HTML children as collection items in database
|
||
func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
|
||
var childElements []*html.Node
|
||
|
||
// Walk through direct children of the collection
|
||
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
||
if child.Type == html.ElementNode {
|
||
childElements = append(childElements, child)
|
||
}
|
||
}
|
||
|
||
if len(childElements) == 0 {
|
||
fmt.Printf("ℹ️ No children found to store as collection items for %s\n", collectionID)
|
||
return nil
|
||
}
|
||
|
||
// Store each child as a collection item
|
||
for i, childElement := range childElements {
|
||
// Use corresponding template ID, or default to first template
|
||
templateID := templateIDs[0] // Default to first template
|
||
if i < len(templateIDs) {
|
||
templateID = templateIDs[i]
|
||
}
|
||
|
||
// Generate item ID using unified generator with collection context
|
||
itemID := e.idGenerator.Generate(childElement, "collection-item")
|
||
|
||
// Process any .insertr elements within this child and store as content
|
||
contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to process child elements: %w", err)
|
||
}
|
||
|
||
// Store individual content entries in content table
|
||
for _, entry := range contentEntries {
|
||
_, err := e.client.CreateContent(context.Background(), entry.SiteID, entry.ID, entry.HTMLContent, entry.Template, "system")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create content entry %s: %w", entry.ID, err)
|
||
}
|
||
}
|
||
|
||
// Generate structural template for this collection item
|
||
structuralTemplate, err := e.generateStructuralTemplateFromChild(childElement, contentEntries)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to generate structural template: %w", err)
|
||
}
|
||
|
||
// Store structural template in collection_items (content lives in content table)
|
||
_, err = e.client.CreateCollectionItem(context.Background(), siteID, collectionID, itemID, templateID, structuralTemplate, i+1, "system")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create collection item %s: %w", itemID, err)
|
||
}
|
||
|
||
fmt.Printf("✅ Stored initial collection item: %s (template %d) with %d content entries\n", itemID, templateID, len(contentEntries))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// collectionProcessor handles collection-specific processing logic
|
||
type collectionProcessor struct {
|
||
engine *ContentEngine
|
||
}
|
||
|
||
// newCollectionProcessor creates a collection processor
|
||
func (e *ContentEngine) newCollectionProcessor() *collectionProcessor {
|
||
return &collectionProcessor{engine: e}
|
||
}
|
||
|
||
// process handles the full collection processing workflow
|
||
func (cp *collectionProcessor) process(collectionNode *html.Node, collectionID, siteID string) error {
|
||
return cp.engine.processCollection(collectionNode, collectionID, siteID)
|
||
}
|
||
|
||
// extractAndStoreTemplatesAndItems delegates to engine method
|
||
func (cp *collectionProcessor) extractAndStoreTemplatesAndItems(collectionNode *html.Node, collectionID, siteID string) error {
|
||
return cp.engine.extractAndStoreTemplatesAndItems(collectionNode, collectionID, siteID)
|
||
}
|
||
|
||
// reconstructItems delegates to engine method
|
||
func (cp *collectionProcessor) reconstructItems(collectionNode *html.Node, collectionID, siteID string) error {
|
||
return cp.engine.reconstructCollectionItems(collectionNode, collectionID, siteID)
|
||
}
|
||
|
||
// cloneNode creates a deep copy of an HTML node
|
||
func (e *ContentEngine) cloneNode(node *html.Node) *html.Node {
|
||
cloned := &html.Node{
|
||
Type: node.Type,
|
||
Data: node.Data,
|
||
DataAtom: node.DataAtom,
|
||
Namespace: node.Namespace,
|
||
}
|
||
|
||
// Clone attributes
|
||
for _, attr := range node.Attr {
|
||
cloned.Attr = append(cloned.Attr, html.Attribute{
|
||
Namespace: attr.Namespace,
|
||
Key: attr.Key,
|
||
Val: attr.Val,
|
||
})
|
||
}
|
||
|
||
// Clone children recursively
|
||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||
clonedChild := e.cloneNode(child)
|
||
cloned.AppendChild(clonedChild)
|
||
}
|
||
|
||
return cloned
|
||
}
|