Refactor engine into focused files to improve maintainability
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
This commit is contained in:
443
internal/engine/collection.go
Normal file
443
internal/engine/collection.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
144
internal/engine/content.go
Normal file
144
internal/engine/content.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addContentAttributes adds data-content-id attribute only
|
||||||
|
func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) {
|
||||||
|
// Add data-content-id attribute
|
||||||
|
SetAttribute(node, "data-content-id", contentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectContent injects content from database into elements
|
||||||
|
func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string) error {
|
||||||
|
for i := range elements {
|
||||||
|
elem := &elements[i]
|
||||||
|
|
||||||
|
// Get content from database by ID
|
||||||
|
contentItem, err := e.client.GetContent(nil, siteID, elem.ID)
|
||||||
|
if err != nil {
|
||||||
|
// Content not found - skip silently (enhancement mode should not fail on missing content)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentItem != nil {
|
||||||
|
// Inject the content into the element
|
||||||
|
elem.Content = contentItem.HTMLContent
|
||||||
|
|
||||||
|
// Update injector siteID for this operation
|
||||||
|
// HACK: I do not like this. Injector refactor?
|
||||||
|
e.injector.siteID = siteID
|
||||||
|
e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHTMLContent extracts the inner HTML content from a node
|
||||||
|
func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
// Render all child nodes in order to preserve HTML structure
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
if err := html.Render(&content, child); err == nil {
|
||||||
|
// All nodes (text and element) rendered in correct order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(content.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
|
||||||
|
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := html.Render(&buf, node); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCleanTemplate extracts a clean template without data-content-id attributes and with placeholder content. Used for collection template variants.
|
||||||
|
func (e *ContentEngine) extractCleanTemplate(node *html.Node) string {
|
||||||
|
// Clone the node to avoid modifying the original
|
||||||
|
clonedNode := e.cloneNode(node)
|
||||||
|
|
||||||
|
// Remove all data-content-id attributes and replace content with placeholders
|
||||||
|
e.walkNodes(clonedNode, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
// Remove data-content-id attribute
|
||||||
|
e.removeAttribute(n, "data-content-id")
|
||||||
|
|
||||||
|
// If this is an .insertr element, replace content with placeholder
|
||||||
|
if e.hasClass(n, "insertr") {
|
||||||
|
placeholderText := e.getPlaceholderForElement(n.Data)
|
||||||
|
// Clear existing children and add placeholder text
|
||||||
|
for child := n.FirstChild; child != nil; {
|
||||||
|
next := child.NextSibling
|
||||||
|
n.RemoveChild(child)
|
||||||
|
child = next
|
||||||
|
}
|
||||||
|
n.AppendChild(&html.Node{
|
||||||
|
Type: html.TextNode,
|
||||||
|
Data: placeholderText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := html.Render(&buf, clonedNode); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAttribute removes an attribute from an HTML node
|
||||||
|
func (e *ContentEngine) removeAttribute(n *html.Node, key string) {
|
||||||
|
for i, attr := range n.Attr {
|
||||||
|
if attr.Key == key {
|
||||||
|
n.Attr = slices.Delete(n.Attr, i, i+1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasClass checks if a node has a specific class
|
||||||
|
func (e *ContentEngine) hasClass(n *html.Node, className string) bool {
|
||||||
|
for _, attr := range n.Attr {
|
||||||
|
if attr.Key == "class" {
|
||||||
|
classes := strings.Fields(attr.Val)
|
||||||
|
if slices.Contains(classes, className) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPlaceholderForElement returns appropriate placeholder text for different element types
|
||||||
|
func (e *ContentEngine) getPlaceholderForElement(elementType string) string {
|
||||||
|
placeholders := map[string]string{
|
||||||
|
"h1": "Heading 1",
|
||||||
|
"h2": "Heading 2",
|
||||||
|
"h3": "Heading 3",
|
||||||
|
"h4": "Heading 4",
|
||||||
|
"h5": "Heading 5",
|
||||||
|
"h6": "Heading 6",
|
||||||
|
"p": "Paragraph text",
|
||||||
|
"span": "Text",
|
||||||
|
"div": "Content block",
|
||||||
|
"button": "Button",
|
||||||
|
"a": "Link text",
|
||||||
|
"li": "List item",
|
||||||
|
"blockquote": "Quote text",
|
||||||
|
}
|
||||||
|
|
||||||
|
if placeholder, exists := placeholders[elementType]; exists {
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
return "Enter content..."
|
||||||
|
}
|
||||||
84
internal/engine/discovery.go
Normal file
84
internal/engine/discovery.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InsertrElement represents an insertr element found in HTML
|
||||||
|
type InsertrElement struct {
|
||||||
|
Node *html.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEditableElements finds all editable elements (.insertr and .insertr-add)
|
||||||
|
func (e *ContentEngine) findEditableElements(doc *html.Node) ([]InsertrElement, []CollectionElement) {
|
||||||
|
// Phase 1: Pure discovery
|
||||||
|
insertrElements, collectionElements, containers := e.discoverElements(doc)
|
||||||
|
|
||||||
|
// Phase 2: Container expansion (separate concern)
|
||||||
|
expandedElements := e.expandContainers(containers)
|
||||||
|
insertrElements = append(insertrElements, expandedElements...)
|
||||||
|
|
||||||
|
return insertrElements, collectionElements
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverElements performs pure element discovery without transformation
|
||||||
|
func (e *ContentEngine) discoverElements(doc *html.Node) ([]InsertrElement, []CollectionElement, []*html.Node) {
|
||||||
|
var insertrElements []InsertrElement
|
||||||
|
var collectionElements []CollectionElement
|
||||||
|
var containersToTransform []*html.Node
|
||||||
|
|
||||||
|
// Walk the document and categorize elements
|
||||||
|
e.walkNodes(doc, func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
if hasInsertrClass(n) {
|
||||||
|
if isContainer(n) {
|
||||||
|
// Container element - mark for transformation
|
||||||
|
containersToTransform = append(containersToTransform, n)
|
||||||
|
} else {
|
||||||
|
// Regular element - add directly
|
||||||
|
insertrElements = append(insertrElements, InsertrElement{
|
||||||
|
Node: n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.hasInsertrAddClass(n) {
|
||||||
|
// Collection element - add directly (no container transformation for collections)
|
||||||
|
collectionElements = append(collectionElements, CollectionElement{
|
||||||
|
Node: n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return insertrElements, collectionElements, containersToTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandContainers transforms container elements by removing .insertr from containers
|
||||||
|
// and adding .insertr to their viable children
|
||||||
|
func (e *ContentEngine) expandContainers(containers []*html.Node) []InsertrElement {
|
||||||
|
var expandedElements []InsertrElement
|
||||||
|
|
||||||
|
for _, container := range containers {
|
||||||
|
// Remove .insertr class from container
|
||||||
|
RemoveClass(container, "insertr")
|
||||||
|
|
||||||
|
// Find viable children and add .insertr class to them
|
||||||
|
viableChildren := FindViableChildren(container)
|
||||||
|
for _, child := range viableChildren {
|
||||||
|
AddClass(child, "insertr")
|
||||||
|
expandedElements = append(expandedElements, InsertrElement{
|
||||||
|
Node: child,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedElements
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkNodes walks through all nodes in the document
|
||||||
|
func (e *ContentEngine) walkNodes(n *html.Node, fn func(*html.Node)) {
|
||||||
|
fn(n)
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
e.walkNodes(c, fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/insertr/insertr/internal/db"
|
"github.com/insertr/insertr/internal/db"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthProvider represents authentication provider information
|
// AuthProvider represents authentication provider information
|
||||||
@@ -141,629 +140,3 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
|
|||||||
GeneratedIDs: generatedIDs,
|
GeneratedIDs: generatedIDs,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertrElement represents an insertr element found in HTML
|
|
||||||
type InsertrElement struct {
|
|
||||||
Node *html.Node
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectionElement represents an insertr-add collection element found in HTML
|
|
||||||
type CollectionElement struct {
|
|
||||||
Node *html.Node
|
|
||||||
}
|
|
||||||
|
|
||||||
// findEditableElements finds all editable elements (.insertr and .insertr-add)
|
|
||||||
func (e *ContentEngine) findEditableElements(doc *html.Node) ([]InsertrElement, []CollectionElement) {
|
|
||||||
var insertrElements []InsertrElement
|
|
||||||
var collectionElements []CollectionElement
|
|
||||||
var containersToTransform []*html.Node
|
|
||||||
|
|
||||||
// First pass: find all .insertr and .insertr-add elements
|
|
||||||
e.walkNodes(doc, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode {
|
|
||||||
if hasInsertrClass(n) {
|
|
||||||
if isContainer(n) {
|
|
||||||
// Container element - mark for transformation
|
|
||||||
containersToTransform = append(containersToTransform, n)
|
|
||||||
} else {
|
|
||||||
// Regular element - add directly
|
|
||||||
insertrElements = append(insertrElements, InsertrElement{
|
|
||||||
Node: n,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e.hasInsertrAddClass(n) {
|
|
||||||
// Collection element - add directly (no container transformation for collections)
|
|
||||||
collectionElements = append(collectionElements, CollectionElement{
|
|
||||||
Node: n,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Second pass: transform .insertr containers (remove .insertr from container, add to children)
|
|
||||||
for _, container := range containersToTransform {
|
|
||||||
// Remove .insertr class from container
|
|
||||||
e.removeClass(container, "insertr")
|
|
||||||
|
|
||||||
// Find viable children and add .insertr class to them
|
|
||||||
viableChildren := FindViableChildren(container)
|
|
||||||
for _, child := range viableChildren {
|
|
||||||
AddClass(child, "insertr")
|
|
||||||
insertrElements = append(insertrElements, InsertrElement{
|
|
||||||
Node: child,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return insertrElements, collectionElements
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkNodes walks through all nodes in the document
|
|
||||||
func (e *ContentEngine) walkNodes(n *html.Node, fn func(*html.Node)) {
|
|
||||||
fn(n)
|
|
||||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
||||||
e.walkNodes(c, fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasInsertrAddClass checks if node has class="insertr-add" (collection)
|
|
||||||
func (e *ContentEngine) hasInsertrAddClass(node *html.Node) bool {
|
|
||||||
classes := GetClasses(node)
|
|
||||||
return slices.Contains(classes, "insertr-add")
|
|
||||||
}
|
|
||||||
|
|
||||||
// addContentAttributes adds data-content-id attribute only
|
|
||||||
func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) {
|
|
||||||
// Add data-content-id attribute
|
|
||||||
SetAttribute(node, "data-content-id", contentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeClass safely removes a class from an HTML node
|
|
||||||
func (e *ContentEngine) removeClass(node *html.Node, className string) {
|
|
||||||
var classIndex int = -1
|
|
||||||
|
|
||||||
// Find existing class attribute
|
|
||||||
for idx, attr := range node.Attr {
|
|
||||||
if attr.Key == "class" {
|
|
||||||
classIndex = idx
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if classIndex == -1 {
|
|
||||||
return // No class attribute found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse existing classes
|
|
||||||
classes := strings.Fields(node.Attr[classIndex].Val)
|
|
||||||
|
|
||||||
// Filter out the target class
|
|
||||||
var newClasses []string
|
|
||||||
for _, class := range classes {
|
|
||||||
if class != className {
|
|
||||||
newClasses = append(newClasses, class)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or remove class attribute
|
|
||||||
if len(newClasses) == 0 {
|
|
||||||
// Remove class attribute entirely if no classes remain
|
|
||||||
node.Attr = slices.Delete(node.Attr, classIndex, classIndex+1)
|
|
||||||
} else {
|
|
||||||
// Update class attribute with remaining classes
|
|
||||||
node.Attr[classIndex].Val = strings.Join(newClasses, " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectContent injects content from database into elements
|
|
||||||
func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string) error {
|
|
||||||
for i := range elements {
|
|
||||||
elem := &elements[i]
|
|
||||||
|
|
||||||
// Try to get content from database
|
|
||||||
contentItem, err := e.client.GetContent(context.Background(), siteID, elem.ID)
|
|
||||||
if err != nil {
|
|
||||||
// Content not found is not an error - element just won't have injected content
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentItem != nil {
|
|
||||||
// Inject the content into the element
|
|
||||||
elem.Content = contentItem.HTMLContent
|
|
||||||
|
|
||||||
// Update injector siteID for this operation
|
|
||||||
// HACK: I do not like this. Injector refactor?
|
|
||||||
e.injector.siteID = siteID
|
|
||||||
e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHTMLContent extracts the inner HTML content from a node
|
|
||||||
func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
|
|
||||||
var content strings.Builder
|
|
||||||
|
|
||||||
// Render all child nodes in order to preserve HTML structure
|
|
||||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
||||||
if err := html.Render(&content, child); err == nil {
|
|
||||||
// All nodes (text and element) rendered in correct order
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(content.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
|
|
||||||
// HACK: Rename
|
|
||||||
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
|
|
||||||
var buf strings.Builder
|
|
||||||
if err := html.Render(&buf, node); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractCleanTemplate extracts a clean template without data-content-id attributes and with placeholder content. Used for collection template variants.
|
|
||||||
func (e *ContentEngine) extractCleanTemplate(node *html.Node) string {
|
|
||||||
// Clone the node to avoid modifying the original
|
|
||||||
clonedNode := e.cloneNode(node)
|
|
||||||
|
|
||||||
// Remove all data-content-id attributes and replace content with placeholders
|
|
||||||
e.walkNodes(clonedNode, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode {
|
|
||||||
// Remove data-content-id attribute
|
|
||||||
e.removeAttribute(n, "data-content-id")
|
|
||||||
|
|
||||||
// If this is an .insertr element, replace content with placeholder
|
|
||||||
if e.hasClass(n, "insertr") {
|
|
||||||
placeholderText := e.getPlaceholderForElement(n.Data)
|
|
||||||
// Clear existing children and add placeholder text
|
|
||||||
for child := n.FirstChild; child != nil; {
|
|
||||||
next := child.NextSibling
|
|
||||||
n.RemoveChild(child)
|
|
||||||
child = next
|
|
||||||
}
|
|
||||||
n.AppendChild(&html.Node{
|
|
||||||
Type: html.TextNode,
|
|
||||||
Data: placeholderText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf strings.Builder
|
|
||||||
if err := html.Render(&buf, clonedNode); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeAttribute removes an attribute from an HTML node
|
|
||||||
func (e *ContentEngine) removeAttribute(n *html.Node, key string) {
|
|
||||||
for i, attr := range n.Attr {
|
|
||||||
if attr.Key == key {
|
|
||||||
n.Attr = slices.Delete(n.Attr, i, i+1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasClass checks if an HTML node has a specific class
|
|
||||||
func (e *ContentEngine) hasClass(n *html.Node, className string) bool {
|
|
||||||
for _, attr := range n.Attr {
|
|
||||||
if attr.Key == "class" {
|
|
||||||
classes := strings.Fields(attr.Val)
|
|
||||||
if slices.Contains(classes, className) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPlaceholderForElement returns appropriate placeholder text for an element type
|
|
||||||
func (e *ContentEngine) getPlaceholderForElement(elementType string) string {
|
|
||||||
placeholders := map[string]string{
|
|
||||||
"blockquote": "Enter your quote here...",
|
|
||||||
"cite": "Enter author name...",
|
|
||||||
"h1": "Enter heading...",
|
|
||||||
"h2": "Enter heading...",
|
|
||||||
"h3": "Enter heading...",
|
|
||||||
"p": "Enter text...",
|
|
||||||
"span": "Enter text...",
|
|
||||||
"div": "Enter content...",
|
|
||||||
"a": "Enter link text...",
|
|
||||||
}
|
|
||||||
|
|
||||||
if placeholder, exists := placeholders[elementType]; exists {
|
|
||||||
return placeholder
|
|
||||||
}
|
|
||||||
return "Enter content..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get final item count for logging
|
|
||||||
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 {
|
|
||||||
// Find existing children elements to use as templates
|
|
||||||
var templateElements []*html.Node
|
|
||||||
|
|
||||||
// Walk through direct children of the collection
|
|
||||||
for child := collectionNode.FirstChild; child != nil; child = child.NextSibling {
|
|
||||||
if child.Type == html.ElementNode {
|
|
||||||
templateElements = append(templateElements, child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(templateElements) == 0 {
|
|
||||||
// No existing children - create a default empty template
|
|
||||||
_, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract templates from existing children and store them
|
|
||||||
var templateIDs []int
|
|
||||||
for i, templateElement := range templateElements {
|
|
||||||
templateHTML := e.extractCleanTemplate(templateElement)
|
|
||||||
templateName := fmt.Sprintf("template-%d", i+1)
|
|
||||||
isDefault := (i == 0) // First template is default
|
|
||||||
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconstruct items from database to ensure proper data-item-id injection
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a template map for quick lookup
|
|
||||||
templateMap := make(map[int]string)
|
|
||||||
for _, template := range templates {
|
|
||||||
templateMap[template.TemplateID] = template.HTMLTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing children (they will be replaced with database items)
|
|
||||||
for child := collectionNode.FirstChild; child != nil; {
|
|
||||||
next := child.NextSibling
|
|
||||||
collectionNode.RemoveChild(child)
|
|
||||||
child = next
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add items from database in position order using unified .insertr approach
|
|
||||||
for _, item := range items {
|
|
||||||
// Parse the stored structural HTML with content IDs (no template needed for reconstruction)
|
|
||||||
structuralDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("⚠️ Failed to parse stored HTML for %s: %v\n", item.ItemID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var structuralBody *html.Node
|
|
||||||
e.walkNodes(structuralDoc, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && n.Data == "body" {
|
|
||||||
structuralBody = n
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if structuralBody != nil {
|
|
||||||
// Process each .insertr element using Injector pattern (unified approach)
|
|
||||||
injector := NewInjector(e.client, siteID, nil)
|
|
||||||
|
|
||||||
// Walk through structural elements and hydrate with content from content table
|
|
||||||
e.walkNodes(structuralBody, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
|
||||||
// Get content ID from data attribute
|
|
||||||
contentID := GetAttribute(n, "data-content-id")
|
|
||||||
if contentID != "" {
|
|
||||||
// Use Injector to hydrate content (unified .insertr approach)
|
|
||||||
element := &Element{Node: n, Type: "html"}
|
|
||||||
err := injector.InjectContent(element, contentID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("⚠️ Failed to inject content for %s: %v\n", contentID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add hydrated structural elements directly to collection (stored HTML has complete structure)
|
|
||||||
for structuralChild := structuralBody.FirstChild; structuralChild != nil; {
|
|
||||||
next := structuralChild.NextSibling
|
|
||||||
structuralBody.RemoveChild(structuralChild)
|
|
||||||
|
|
||||||
// Inject data-item-id attribute for collection item identification
|
|
||||||
if structuralChild.Type == html.ElementNode {
|
|
||||||
SetAttribute(structuralChild, "data-item-id", item.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
collectionNode.AppendChild(structuralChild)
|
|
||||||
structuralChild = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
elementIndex := 0
|
|
||||||
|
|
||||||
// Walk through child element to find .insertr elements
|
|
||||||
e.walkNodes(childElement, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && e.hasClass(n, "insertr") {
|
|
||||||
// Use core IDGenerator for unified ID generation (like individual .insertr elements)
|
|
||||||
contentID := e.idGenerator.Generate(n, "collection-item")
|
|
||||||
|
|
||||||
// Extract actual content from the element
|
|
||||||
actualContent := ExtractTextContent(n)
|
|
||||||
|
|
||||||
// Store as individual content entry (unified .insertr approach)
|
|
||||||
_, err := e.client.CreateContent(context.Background(), siteID, contentID, actualContent, "", "system")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("⚠️ Failed to create content %s: %v\n", contentID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to content entries for structural template generation
|
|
||||||
contentEntries = append(contentEntries, ContentEntry{
|
|
||||||
ID: contentID,
|
|
||||||
SiteID: siteID,
|
|
||||||
HTMLContent: actualContent,
|
|
||||||
Template: e.extractOriginalTemplate(n),
|
|
||||||
})
|
|
||||||
|
|
||||||
elementIndex++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return contentEntries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateStructuralTemplateFromChild creates structure-only HTML from child element with content IDs
|
|
||||||
func (e *ContentEngine) generateStructuralTemplateFromChild(childElement *html.Node, contentEntries []ContentEntry) (string, error) {
|
|
||||||
// Clone the child element to avoid modifying original
|
|
||||||
clonedChild := e.cloneNode(childElement)
|
|
||||||
entryIndex := 0
|
|
||||||
|
|
||||||
// Walk through cloned element and replace content with content IDs
|
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Render the complete structural template including container
|
|
||||||
var sb strings.Builder
|
|
||||||
html.Render(&sb, clonedChild)
|
|
||||||
|
|
||||||
return sb.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createVirtualElementFromTemplate creates a virtual DOM element from template HTML for API usage
|
|
||||||
// This allows API path to use the same structure extraction as enhancement path
|
|
||||||
func (e *ContentEngine) createVirtualElementFromTemplate(templateHTML string) (*html.Node, error) {
|
|
||||||
// Parse the template HTML
|
|
||||||
templateDoc, err := html.Parse(strings.NewReader(templateHTML))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse template HTML: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the body element and extract the template structure
|
|
||||||
var templateBody *html.Node
|
|
||||||
e.walkNodes(templateDoc, func(n *html.Node) {
|
|
||||||
if n.Type == html.ElementNode && n.Data == "body" {
|
|
||||||
templateBody = n
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if templateBody != nil && templateBody.FirstChild != nil {
|
|
||||||
// Return the first child of body (the actual template element)
|
|
||||||
return templateBody.FirstChild, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("template does not contain valid structure")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCollectionItemFromTemplate creates a collection item using the unified engine approach
|
|
||||||
// This replaces TemplateProcessor with engine-native functionality
|
|
||||||
func (e *ContentEngine) CreateCollectionItemFromTemplate(
|
|
||||||
siteID, collectionID string,
|
|
||||||
templateID int,
|
|
||||||
templateHTML string,
|
|
||||||
lastEditedBy string,
|
|
||||||
) (*db.CollectionItemWithTemplate, error) {
|
|
||||||
// Create virtual element from template (like enhancement path)
|
|
||||||
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")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create virtual element: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process .insertr elements and create content entries (unified approach)
|
|
||||||
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to process content entries: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate structural template using unified engine method
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeChildrenAsCollectionItems stores HTML children as collection items in database
|
|
||||||
func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node, collectionID, siteID string, templateIDs []int) error {
|
|
||||||
// Find existing children elements to store as items
|
|
||||||
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 using unified .insertr approach (content table + structural template)
|
|
||||||
for i, childElement := range childElements {
|
|
||||||
// Generate item ID using unified generator with collection context
|
|
||||||
itemID := e.idGenerator.Generate(childElement, "collection-item")
|
|
||||||
|
|
||||||
// Process .insertr elements within this child (unified approach)
|
|
||||||
contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to process content for item %s: %w", itemID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate structural template with content IDs (no actual content)
|
|
||||||
structuralTemplate, err := e.generateStructuralTemplateFromChild(childElement, contentEntries)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate structural template for item %s: %w", itemID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use appropriate template ID (cycle through available templates)
|
|
||||||
templateID := templateIDs[i%len(templateIDs)]
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user